Files
jarvis/jarvis/core/pipeline.py
T

370 lines
17 KiB
Python

import logging, time, collections, numpy as np
from pathlib import Path
from ..config import load_config
from ..audio.capture import AudioCapture
from ..audio.vad import create_vad
from ..audio.wakeword import WakeWordDetector, KeywordSpotterFallback
from ..stt.faster_whisper_engine import FasterWhisperSTT
from ..speaker.identifier import SpeakerIdentifier
from ..vision.face_identifier import FaceIdentifier
from ..llm.echo import EchoLLM
from ..tts.piper import PiperTTS, EchoTTS
log = logging.getLogger(__name__)
class JarvisPipeline:
def __init__(self, config_path=None):
self.cfg = load_config(config_path)
self.samplerate = self.cfg.get('audio',{}).get('samplerate',16000)
self.block_size = self.cfg.get('audio',{}).get('block_size',512)
self.device = self.cfg.get('audio',{}).get('device',None)
self.backend = self.cfg.get('audio',{}).get('backend','auto')
self.capture = AudioCapture(samplerate=self.samplerate, block_size=self.block_size, device=self.device, backend=self.backend)
self.vad = create_vad(self.cfg)
self.wakeword = WakeWordDetector(
models=self.cfg.get('wakeword',{}).get('models',['hey_jarvis']),
threshold=self.cfg.get('wakeword',{}).get('threshold',0.45),
cooldown=self.cfg.get('wakeword',{}).get('cooldown_sec',1.0)
)
self.keyword_fallback = KeywordSpotterFallback(["hey jarvis","jarvis"])
stt_cfg = self.cfg.get('stt',{})
self.stt = FasterWhisperSTT(
model=stt_cfg.get('model','base'),
language=stt_cfg.get('language','en'),
device=stt_cfg.get('device','cpu'),
compute_type=stt_cfg.get('compute_type','int8'),
vad_filter=stt_cfg.get('vad_filter',False)
)
self.speaker_id = None
if self.cfg.get('speaker',{}).get('enabled',True):
try:
self.speaker_id = SpeakerIdentifier(
enrollment_dir=self.cfg.get('speaker',{}).get('enrollment_dir','data/speakers'),
threshold=self.cfg.get('speaker',{}).get('threshold',0.60),
embedding_model=self.cfg.get('speaker',{}).get('embedding_model','speechbrain/spkrec-ecapa-voxceleb')
)
except Exception as e:
log.warning(f"Speaker ID disabled: {e}")
self.face_id = None
if self.cfg.get('vision',{}).get('enabled',True):
try:
self.face_id = FaceIdentifier(
camera_id=self.cfg.get('vision',{}).get('camera_id',0),
enrollment_dir=self.cfg.get('vision',{}).get('enrollment_dir','data/faces'),
threshold=self.cfg.get('vision',{}).get('threshold',0.55)
)
except Exception as e:
log.warning(f"Face ID disabled: {e}")
self.llm = self._create_llm()
self.tts = self._create_tts()
self.pre_roll_sec = self.cfg.get('wakeword',{}).get('pre_roll_sec',0.8)
self.pre_roll_samples = int(self.pre_roll_sec * self.samplerate)
self.pre_roll_buffer = collections.deque(maxlen=self.pre_roll_samples)
self.silence_ms = self.cfg.get('vad',{}).get('min_silence_ms',800)
self.followup_timeout = self.cfg.get('conversation',{}).get('followup_timeout_sec',12)
self.max_followup = self.cfg.get('conversation',{}).get('max_followup_turns',5)
self.history = []
self.transcription_dir = Path(self.cfg.get('logging',{}).get('transcription_dir','data/transcriptions'))
self.transcription_dir.mkdir(parents=True, exist_ok=True)
self.running = False
self.in_followup = False
self.followup_turns = 0
self.last_interaction = 0
self._last_face_result = {"name":"unknown","face_conf":0.0}
def _create_llm(self):
provider = self.cfg.get('llm',{}).get('provider','echo')
log.info(f"LLM provider: {provider}")
if provider == 'ollama':
try:
from ..llm.ollama import OllamaLLM
return OllamaLLM(
model=self.cfg.get('llm',{}).get('model','qwen2.5:3b'),
host=self.cfg.get('llm',{}).get('ollama_host','http://localhost:11434'),
temperature=self.cfg.get('llm',{}).get('temperature',0.7)
)
except Exception as e:
log.warning(f"Ollama failed: {e}, echo")
return EchoLLM()
elif provider == 'gemini_cli':
try:
from ..llm.gemini_cli import GeminiCLILLM
return GeminiCLILLM(workspace_dir="data", system_file=self.cfg.get('llm',{}).get('system_prompt_file','data/soul.md'))
except Exception as e:
log.warning(f"Gemini CLI failed: {e}")
return EchoLLM()
elif provider == 'hermes_api':
try:
from ..llm.hermes_api import HermesAPILLM
return HermesAPILLM(base_url=self.cfg.get('llm',{}).get('base_url','http://192.168.68.110:8000'))
except Exception as e:
log.warning(f"Hermes API failed: {e}")
return EchoLLM()
else:
return EchoLLM()
def _create_tts(self):
engine = self.cfg.get('tts',{}).get('engine','echo')
if engine == 'piper':
try:
return PiperTTS(voice=self.cfg.get('tts',{}).get('voice','en_US-lessac-medium'), models_dir="models", volume=self.cfg.get('tts',{}).get('volume',0.9))
except Exception as e:
log.warning(f"Piper failed: {e}, echo fallback")
return EchoTTS()
else:
return EchoTTS()
def log_transcription(self, text, speaker_info=None):
if not text:
return
from datetime import datetime
date_str = datetime.now().strftime("%Y-%m-%d")
time_str = datetime.now().strftime("%H:%M:%S")
speaker = speaker_info.get('name','unknown') if speaker_info else 'unknown'
log_file = self.transcription_dir / f"{date_str}.log"
try:
with open(log_file,'a') as f:
f.write(f"[{time_str}] ({speaker}) {text}\n")
except Exception as e:
log.warning(f"Log failed: {e}")
def run(self):
print("=== Jarvis v2 Starting ===")
print(f"Config: wakeword={self.cfg.get('wakeword',{}).get('models')} stt={self.cfg.get('stt',{}).get('model')} llm={self.cfg.get('llm',{}).get('provider')} backend={self.backend}")
print(f"Speaker ID: {self.speaker_id is not None} Face ID: {self.face_id is not None}")
print(f"Wakeword model loaded: {self.wakeword.oww_model is not None} names={self.wakeword.model_names}")
self.capture.start()
self.running = True
recording_buffer = []
is_recording = False
silence_start = None
system_prompt = ""
soul_path = self.cfg.get('llm',{}).get('system_prompt_file','data/soul.md')
if soul_path and Path(soul_path).exists():
system_prompt = Path(soul_path).read_text()[:4000]
print("Listening... say 'Hey Jarvis' (or just speak if wakeword model missing, keyword fallback via STT)")
# For keyword fallback mode, we need continuous STT? We'll use VAD-gated recording + STT keyword check
# If wakeword model missing, we go into VAD recording mode and check transcript for keyword
try:
while self.running:
chunk = self.capture.read(timeout=0.2)
if chunk is None:
if self.in_followup and (time.time() - self.last_interaction > self.followup_timeout):
print("Followup window expired, idle")
self.in_followup = False
self.followup_turns = 0
continue
self.pre_roll_buffer.extend(chunk)
if not is_recording:
if not self.vad.is_speech(chunk, self.samplerate):
if not self.in_followup:
continue
detected = None
if not self.in_followup:
detected = self.wakeword.detect(chunk)
# If wakeword model not available, use VAD to start recording and let STT keyword check handle it
if self.wakeword.oww_model is None and self.vad.is_speech(chunk):
# Start recording for keyword fallback
detected = {"model": "vad_fallback", "score": 0.5}
else:
if self.vad.is_speech(chunk, self.samplerate):
detected = {"model": "followup", "score": 1.0}
if detected:
print(f"\n>>> Wake detected: {detected['model']} score={detected.get('score',0):.2f}")
try:
import numpy as np
import sounddevice as sd
sr=16000
t=np.linspace(0,0.15,sr//6)
beep = 0.3*np.sin(2*np.pi*880*t)
# try pipewire playback? ignore
try:
sd.play(beep, sr)
sd.wait()
except:
pass
except:
pass
is_recording = True
recording_buffer = [np.array(self.pre_roll_buffer, dtype=np.float32)]
silence_start = None
face_result = {"name":"unknown","face_conf":0.0}
if self.face_id and self.cfg.get('vision',{}).get('check_on_wake',True):
try:
face_result = self.face_id.check()
print(f"Face: {face_result.get('name')} conf={face_result.get('face_conf',0):.2f} found={face_result.get('faces_found',0)}")
except Exception as e:
log.warning(f"Face check error: {e}")
self._last_face_result = face_result
continue
else:
recording_buffer.append(chunk.copy())
if self.vad.is_speech(chunk, self.samplerate):
silence_start = None
else:
if silence_start is None:
silence_start = time.time()
else:
if (time.time() - silence_start)*1000 > self.silence_ms:
total_len = sum(len(c) for c in recording_buffer)
if total_len > self.samplerate * 0.4:
is_recording = False
audio = np.concatenate(recording_buffer) if recording_buffer else np.array([])
recording_buffer = []
self._process_utterance(audio, system_prompt)
self.wakeword.reset()
self.pre_roll_buffer.clear()
else:
is_recording = False
recording_buffer = []
total_len = sum(len(c) for c in recording_buffer)
if total_len > self.samplerate * 12:
print("Max recording length, processing")
is_recording = False
audio = np.concatenate(recording_buffer) if recording_buffer else np.array([])
recording_buffer = []
self._process_utterance(audio, system_prompt)
self.wakeword.reset()
self.pre_roll_buffer.clear()
except KeyboardInterrupt:
print("\nShutting down...")
finally:
self.capture.stop()
if self.face_id:
self.face_id.close()
def _process_utterance(self, audio, system_prompt):
import numpy as np
if len(audio) < self.samplerate * 0.2:
print("Audio too short")
return
print(f"Transcribing {len(audio)/self.samplerate:.2f}s...")
stt_result = self.stt.transcribe(audio, self.samplerate)
text = stt_result.get('text','').strip()
print(f"STT: '{text}' conf={stt_result.get('confidence',0):.2f}")
if not text:
print("Empty transcription")
return
# Keyword fallback check when wakeword model missing or followup not active
is_keyword_trigger = self.keyword_fallback.check_transcript(text)
if not self.in_followup:
# If we were triggered by VAD fallback, we need keyword in text to confirm it's for Jarvis
if self.wakeword.oww_model is None:
if not is_keyword_trigger:
print(f"No wake keyword in '{text}', ignoring (keyword fallback)")
return
# Extract command after keyword
low = text.lower()
for kw in ["hey jarvis", "jarvis"]:
if kw in low:
idx = low.find(kw)
text = text[idx+len(kw):].strip(" ,:")
break
if not text:
print("Only wakeword, waiting for command in followup")
self.in_followup = True
self.followup_turns = 0
self.last_interaction = time.time()
return
else:
# in followup, allow empty? no
pass
speaker_info = {"name": "unknown", "voice_conf": 0.0, "face_conf": 0.0, "fused_conf": 0.0}
if hasattr(self, '_last_face_result'):
speaker_info['face_conf'] = self._last_face_result.get('face_conf',0.0)
if self.speaker_id:
try:
voice_result = self.speaker_id.identify(audio, self.samplerate)
speaker_info['name'] = voice_result.get('name','unknown')
speaker_info['voice_conf'] = voice_result.get('voice_conf',0.0)
speaker_info['voice_scores'] = voice_result.get('scores',{})
except Exception as e:
log.warning(f"Speaker ID error: {e}")
face_name = getattr(self, '_last_face_result', {}).get('name','unknown')
face_conf = getattr(self, '_last_face_result', {}).get('face_conf',0.0) if hasattr(self, '_last_face_result') else 0.0
final_name = speaker_info.get('name','unknown')
final_conf = speaker_info.get('voice_conf',0.0)
if face_name != "unknown" and final_name != "unknown":
if face_name == final_name:
final_conf = min(max(face_conf, final_conf) * 1.1, 0.99)
final_name = face_name
else:
if face_conf > final_conf:
final_name = face_name
final_conf = face_conf
elif face_name != "unknown" and final_name == "unknown":
final_name = face_name
final_conf = face_conf
speaker_info['name'] = final_name
speaker_info['fused_conf'] = final_conf
speaker_info['face_conf'] = face_conf
print(f"Speaker: {final_name} fused={final_conf:.2f} voice={speaker_info.get('voice_conf',0):.2f} face={face_conf:.2f}")
self.log_transcription(text, speaker_info)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
for h in self.history[-self.cfg.get('llm',{}).get('max_history',8):]:
messages.append(h)
messages.append({"role": "user", "content": text})
print("Thinking...")
try:
response = self.llm.chat(messages, speaker=speaker_info)
except Exception as e:
log.error(f"LLM error: {e}")
response = f"Error: {e}"
print(f"Jarvis: {response}")
self.history.append({"role": "user", "content": text})
self.history.append({"role": "assistant", "content": response})
try:
self.tts.speak(response)
except Exception as e:
log.error(f"TTS error: {e}")
print(f"[TTS error] {response}")
self.in_followup = True
self.followup_turns += 1
self.last_interaction = time.time()
if self.followup_turns >= self.max_followup:
print("Max followup reached, idle")
self.in_followup = False
self.followup_turns = 0