Files
Alfredv2/alfred/app/services/alfred_duplex.py
T
2026-10-30 22:10:03 +01:00

366 lines
15 KiB
Python

# 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})