# ALFRED_DUPLEX_CONVERSATION_V2 """Dona duplex V2: short stop commands, interrupted follow-ups and conversation window. No acoustic echo cancellation is provided. A shared, echo-cancelled input device is recommended. This code only stops Piper audio, never a Hermes task. """ from __future__ import annotations import json import os import queue import re import threading import time import unicodedata from collections import deque def normalize(text): text = unicodedata.normalize('NFKD', str(text or '').casefold()) return ' '.join(re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c))).split()) STOP_PHRASES = ('stop', 'stoppe', 'arrete', 'arretes', 'silence', 'tais toi', 'taisez vous', 'chut') LEAD_WORDS = ('attends', 'attendez', 'une seconde', 'minute') def without_wake(text, wake): text = normalize(text) wake = normalize(wake) if wake and (text == wake or text.startswith(wake + ' ')): text = text[len(wake):].strip() return text def stop_only(text, wake=''): cleaned = without_wake(text, wake) return cleaned in STOP_PHRASES or cleaned in LEAD_WORDS def clean_question(text, wake): # Keep original spelling and technical names (Wazuh, Proxmox, LXC). text = str(text or '').strip(' ,.!?\t\n') if wake: prefix = re.match(r'^' + re.escape(wake) + r'\b[\s,.:;!?-]*', text, re.I) if prefix: text = text[prefix.end():].strip() for lead in STOP_PHRASES + LEAD_WORDS: prefix = re.match(r'^' + re.escape(lead) + r'\b[\s,.:;!?-]+', text, re.I) if prefix: text = text[prefix.end():].strip() break return text if len(text) >= 3 and not stop_only(text, wake) else '' def looks_like_echo(candidate, spoken): candidate = normalize(candidate) spoken = normalize(spoken) if not candidate or not spoken: return False if candidate in spoken: return True cw, sw = candidate.split(), set(spoken.split()) return len(cw) >= 3 and sum(w in sw for w in cw) / len(cw) >= 0.85 def _bool(name, default): return os.getenv(name, str(default)).strip().lower() in ('1', 'yes', 'true', 'on', 'oui') def config(): mode = os.getenv('DUPLEX_MODE', 'wake').strip().lower() if mode not in {'off', 'wake', 'natural'}: mode = 'off' try: threshold = float(os.getenv('DUPLEX_MIC_THRESHOLD', '0.025')) except (TypeError, ValueError): threshold = 0.025 try: seconds = int(os.getenv('DUPLEX_CONVERSATION_SECONDS', '6')) except (TypeError, ValueError): seconds = 6 return {'mode': mode, 'threshold': min(0.30, max(0.008, threshold)), 'wake_word': os.getenv('WAKE_WORD', 'alfred').strip().lower(), 'stop_commands': _bool('DUPLEX_STOP_COMMANDS', True), 'handoff': _bool('DUPLEX_HANDOFF', True), 'conversation': _bool('DUPLEX_CONVERSATION', True), 'conversation_seconds': min(15, max(2, seconds)), 'aec': 'not_provided', 'limitations': 'Pas d AEC: risque de faux positifs par le haut-parleur. A utiliser avec une source audio AEC pour le mode naturel.'} def _rms(block): import numpy as np samples = np.frombuffer(block, dtype=np.int16).astype(np.float32) return float(np.sqrt(np.mean(samples * samples)) / 32768.0) if samples.size else 0.0 def _stream(rate, device): import sounddevice as sd frames = max(320, rate // 10) q = queue.Queue(maxsize=64) def cb(indata, _frames, _time_info, _status): try: q.put_nowait(bytes(indata)) except queue.Full: pass return sd.RawInputStream(samplerate=rate, blocksize=frames, dtype='int16', channels=1, device=device, callback=cb), q def _vosk_recognizer(rate, *, wake_only=False, wake_word=''): from vosk import KaldiRecognizer from services.audio import get_vosk_model model_path = os.getenv('WAKE_VOSK_MODEL_PATH') if wake_only else os.getenv('VOSK_MODEL_PATH') if not model_path or not os.path.isdir(model_path): raise RuntimeError('Modele Vosk requis introuvable : ' + str(model_path)) if wake_only: words = [wake_word, *STOP_PHRASES, *LEAD_WORDS, '[unk]'] return KaldiRecognizer(get_vosk_model(model_path), rate, json.dumps(list(dict.fromkeys(words)), ensure_ascii=False)) return KaldiRecognizer(get_vosk_model(model_path), rate) def transcribe_capture(pcm, rate, *, fallback=''): """Uses Whisper for final text when enabled, and local Vosk otherwise.""" if not pcm: return normalize(fallback) if os.getenv('STT_ENGINE', 'vosk').strip().lower() in ('whisper', 'wyoming', 'wyoming_whisper'): try: from services.wyoming_stt import transcribe_pcm result = transcribe_pcm(pcm, host=os.getenv('WYOMING_WHISPER_HOST', '192.168.1.13'), port=int(os.getenv('WYOMING_WHISPER_PORT', '10300')), language=os.getenv('WYOMING_WHISPER_LANGUAGE', 'fr'), sample_rate=rate, connect_timeout=3.0, response_timeout=float(os.getenv('WYOMING_WHISPER_TIMEOUT', '20'))) if result: return result.strip() except Exception as exc: print('[ALFRED DUPLEX V2] Whisper en secours Vosk :', exc, flush=True) try: rec = _vosk_recognizer(rate) rec.AcceptWaveform(pcm) result = json.loads(rec.FinalResult() or '{}').get('text') or '' return result.strip() or str(fallback).strip() except Exception as exc: print('[ALFRED DUPLEX V2] Transcription Vosk indisponible :', exc, flush=True) return str(fallback).strip() class DuplexMonitor: def __init__(self, *, muted, on_interrupt, on_status=None): self.muted = muted self.on_interrupt = on_interrupt self.on_status = on_status or (lambda data: None) self._stop = threading.Event() self._done = threading.Event() self._thread = None self._lock = threading.RLock() self._interrupted = False self._last_error = None self._followup = '' self._reason = None self._spoken = '' def start(self, spoken_text): cfg = config() if cfg['mode'] == 'off' or self.muted(): return False with self._lock: if self._thread and self._thread.is_alive(): return False self._stop.clear() self._done.clear() self._interrupted = False self._followup = '' self._reason = None self._last_error = None self._spoken = spoken_text self._thread = threading.Thread(target=self._listen, args=(spoken_text, cfg), daemon=True, name='dona-duplex-v2') self._thread.start() return True def stop(self): self._stop.set() thread = self._thread if thread and thread is not threading.current_thread(): thread.join(timeout=1.3) def status(self): with self._lock: return {'active': bool(self._thread and self._thread.is_alive() and not self._stop.is_set()), 'interrupted': self._interrupted, 'reason': self._reason, 'error': self._last_error, **config()} def interrupted(self): with self._lock: return self._interrupted def wait_for_followup(self, timeout=9.0): self._done.wait(timeout=timeout) with self._lock: return self._followup def _signal(self, data): try: self.on_status(data) except Exception: pass def _fire(self, reason): with self._lock: if self._interrupted or self._stop.is_set() or self.muted(): return False self._interrupted = True self._reason = reason print('[ALFRED DUPLEX V2] Interruption :', reason, flush=True) try: self.on_interrupt() except Exception as exc: self._last_error = str(exc) self._signal({'mode': reason, 'interrupted': True}) return True def _read_after_interruption(self, q, rate, cfg, seed, hint): """Same microphone stream: don't lose a follow-up immediately after the wake word.""" if not cfg['handoff']: return '' # Capture up to 8 seconds, including a short preroll from before interruption. data = bytearray(seed) start = time.monotonic() last_voice = start voiced = bool(clean_question(hint, cfg['wake_word'])) while time.monotonic() - start < 8 and not self._stop.is_set() and not self.muted(): try: block = q.get(timeout=0.12) except queue.Empty: if voiced and time.monotonic() - last_voice > 1.1: break continue data.extend(block) if len(data) > rate * 2 * 12: break if _rms(block) >= cfg['threshold']: voiced = True last_voice = time.monotonic() if voiced and time.monotonic() - last_voice >= 1.05: break if not voiced and time.monotonic() - start > 2.3: return '' text = clean_question(transcribe_capture(bytes(data), rate, fallback=hint), cfg['wake_word']) if not text or looks_like_echo(text, self._spoken) or stop_only(text, cfg['wake_word']): return '' return text def _listen(self, spoken, cfg): try: rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000')) wake_only = cfg['mode'] == 'wake' rec = _vosk_recognizer(rate, wake_only=wake_only, wake_word=cfg['wake_word']) device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None stream, q = _stream(rate, device) preroll = deque(maxlen=18) stable = '' stable_at = 0.0 started = time.monotonic() with stream: while not self._stop.is_set() and not self.muted(): try: block = q.get(timeout=0.12) except queue.Empty: continue preroll.append(block) if time.monotonic() - started < 0.65 or _rms(block) < cfg['threshold']: continue final = rec.AcceptWaveform(block) payload = json.loads(rec.Result() if final else rec.PartialResult()) heard = normalize(payload.get('text' if final else 'partial') or '') if not heard or looks_like_echo(heard, spoken): stable = '' continue stop = cfg['stop_commands'] and stop_only(heard, cfg['wake_word']) woke = cfg['wake_word'] in heard.split() # Natural mode: a distinct 2+-word utterance, confirmed across # multiple chunks. The speaker itself may still cause false positives. natural = (cfg['mode'] == 'natural' and len(heard.split()) >= 2) candidate = stop or woke or natural if not candidate: stable = '' continue now = time.monotonic() if heard != stable: stable, stable_at = heard, now continue if not final and now - stable_at < (0.20 if stop else 0.43): continue if not self._fire('stop' if stop else 'wake' if woke else 'natural'): continue self._signal({'mode': 'handoff', 'listening': True}) result = self._read_after_interruption(q, rate, cfg, b''.join(preroll), heard) with self._lock: self._followup = result if result: print('[ALFRED DUPLEX V2] Nouvelle question :', repr(result), flush=True) return except Exception as exc: self._last_error = str(exc) print('[ALFRED DUPLEX V2] Capture indisponible :', exc, flush=True) finally: self._done.set() def listen_conversation(self): """Short wake-free listening period AFTER Piper has finished speaking.""" cfg = config() if not cfg['conversation'] or cfg['mode'] == 'off' or self.muted(): return '' self.stop() rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000')) device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None try: stream, q = _stream(rate, device) seconds = cfg['conversation_seconds'] self._signal({'mode': 'conversation', 'listening': True, 'timeout': seconds}) pcm = bytearray() started = time.monotonic() last_voice = None voice_started = False consecutive_voice = 0 with stream: while time.monotonic() - started < seconds and not self.muted(): try: block = q.get(timeout=0.12) except queue.Empty: if last_voice and time.monotonic() - last_voice > 1.15: break continue # Ignore the output audio tail immediately after Piper. if time.monotonic() - started < 0.4: continue pcm.extend(block) if len(pcm) > rate * 2 * 12: break if _rms(block) >= cfg['threshold']: consecutive_voice += 1 if consecutive_voice >= 3: # reject isolated clicks (< ~0.3 s) voice_started = True last_voice = time.monotonic() else: consecutive_voice = 0 if last_voice and time.monotonic() - last_voice > 1.15: break if not voice_started: return '' transcript = transcribe_capture(bytes(pcm), rate) if stop_only(transcript, cfg['wake_word']) or looks_like_echo(transcript, self._spoken): return '' return clean_question(transcript, cfg['wake_word']) except Exception as exc: self._last_error = str(exc) print('[ALFRED DUPLEX V2] Conversation indisponible :', exc, flush=True) return '' finally: self._signal({'mode': 'conversation', 'listening': False})