366 lines
15 KiB
Plaintext
366 lines
15 KiB
Plaintext
# 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.isfile(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})
|