From 6f7498a956dde739bb386b60b4d9b3e718601633 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 2 Jul 2026 11:21:45 +0200 Subject: [PATCH] =?UTF-8?q?Turn-Detection:=20Smart=20Turn=20v3=20semantisc?= =?UTF-8?q?h=20(Review=20P2-11)=20=E2=80=94=20live=20verifiziert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - voice_service /turn: smart-turn-v3.2 (8MB ONNX, ~110ms warm inkl. Features); Audio muss LINKS gepadded werden (rechts-Padding -> konstant 'complete', live diagnostiziert) und der Output ist empirisch P(unfertig) — Doku sagt es andersherum, Messung gewinnt - backend /api/voice/turn: Proxy mit fail-open (Turn-Check ist Optimierung, kein Blocker) - useVAD: Semantik-Hold — bei 'incomplete' bis 1,8s auf Fortsetzung warten und anhaengen, statt mitten im Gedanken zu antworten; Deckel 30s; fail-open bei Netzfehlern - Verifiziert: fertig=true(0.74), mitten-im-Wort=false(0.04), via :9001 ok Co-Authored-By: Claude Fable 5 --- backend/routers/voice.py | 19 +++++ .../src/renderer/src/lib/voice/useVAD.ts | 56 ++++++++++++- voice_service/app.py | 80 +++++++++++++++++++ voice_service/requirements.txt | 2 + 4 files changed, 153 insertions(+), 4 deletions(-) diff --git a/backend/routers/voice.py b/backend/routers/voice.py index fe99a78..9cbb602 100644 --- a/backend/routers/voice.py +++ b/backend/routers/voice.py @@ -137,6 +137,25 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default= raise HTTPException(502, f"STT fehlgeschlagen: {exc}") +@router.post("/voice/turn") +async def voice_turn(audio: UploadFile = File(...)) -> dict: + """Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar.""" + data = await audio.read() + if not data: + raise HTTPException(400, "Leeres Audio.") + files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")} + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + with Timer("turn"): + r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files) + r.raise_for_status() + return r.json() + except httpx.HTTPError as exc: + # Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen. + log.warning("Turn-Check fehlgeschlagen: %s", exc) + return {"complete": True, "probability": 1.0, "engine": "fallback"} + + @router.post("/voice/reference") async def voice_set_reference(audio: UploadFile = File(...)) -> dict: """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar.""" diff --git a/client/lucy-desktop/src/renderer/src/lib/voice/useVAD.ts b/client/lucy-desktop/src/renderer/src/lib/voice/useVAD.ts index f77653d..68f91f6 100644 --- a/client/lucy-desktop/src/renderer/src/lib/voice/useVAD.ts +++ b/client/lucy-desktop/src/renderer/src/lib/voice/useVAD.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react" import { MicVAD } from "@ricky0123/vad-web" +import { BOX_URL } from "../../config" // Freisprech-VAD: lauscht dauerhaft am Mikro und liefert komplette Äußerungen als Blob. // Seit Review P1-7 (2026-07-02) Silero VAD v5 (neuronal, via vad-web/onnxruntime-wasm) statt @@ -19,6 +20,11 @@ const REDEMPTION_MS = 450 // so lange Stille -> Äußerung zu Ende (war 900 const PRE_SPEECH_PAD_MS = 320 // Vorlauf mitschneiden (erster Wortanfang nicht abschneiden) const MIN_SPEECH_MS = 160 // kürzer = Klick/Räuspern -> verwerfen const COOLDOWN_MS = 450 // nach Lucys Antwort kurz taub (Echo/Lautsprecher abklingen lassen) +// Semantische Turn-Detection (Smart Turn v3 auf der Box): meldet sie 'incomplete' (User denkt +// mitten im Satz nach), warten wir bis zu HOLD_MS auf die Fortsetzung und hängen sie an, +// statt mitten im Gedanken zu antworten. Hart begrenzt, damit Lucy nie ewig schweigt. +const HOLD_MS = 1800 +const MAX_UTTERANCE_S = 30 // Sicherheitsdeckel fürs Zusammenhängen // Float32-Samples (16 kHz mono) -> WAV-Blob (PCM16). Ersetzt den MediaRecorder-webm-Umweg: // die Box muss kein Opus mehr dekodieren, Parakeet/Whisper bekommen direkt sauberes WAV. @@ -38,6 +44,25 @@ function toWavBlob(samples: Float32Array, sampleRate = 16000): Blob { return new Blob([buf], { type: "audio/wav" }) } +// Fragt die Box, ob die Äußerung semantisch fertig ist. Fehler => true (nie blockieren). +async function isTurnComplete(audio: Float32Array): Promise { + try { + const fd = new FormData() + fd.append("audio", toWavBlob(audio.subarray(Math.max(0, audio.length - 8 * 16000))), "rec.wav") + const r = await fetch(`${BOX_URL}/api/voice/turn`, { method: "POST", body: fd }) + if (!r.ok) return true + return (await r.json()).complete !== false + } catch { + return true + } +} + +function concatAudio(a: Float32Array, b: Float32Array): Float32Array { + const out = new Float32Array(a.length + b.length) + out.set(a); out.set(b, a.length) + return out +} + export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions) { const pausedRef = useRef(paused); pausedRef.current = paused const onUtt = useRef(onUtterance); onUtt.current = onUtterance @@ -51,6 +76,15 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions if (!enabled) return let cancelled = false let vad: Awaited> | null = null + // Semantik-Hold: bei 'incomplete' gepufferte Äußerung, auf die die Fortsetzung wartet. + let pending: Float32Array | null = null + let holdTimer: ReturnType | null = null + + const emit = (audio: Float32Array) => { + pending = null + if (holdTimer) { clearTimeout(holdTimer); holdTimer = null } + onUtt.current(toWavBlob(audio)) + } ;(async () => { try { @@ -69,15 +103,28 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions }), onSpeechStart: () => { if (pausedRef.current || performance.now() < resumeAt.current) return + // User spricht weiter, während eine 'incomplete'-Äußerung gehalten wird -> + // Timer stoppen; die Fortsetzung wird in onSpeechEnd angehängt. + if (holdTimer) { clearTimeout(holdTimer); holdTimer = null } onLst.current?.(true) }, - onSpeechEnd: (audio: Float32Array) => { + onSpeechEnd: async (audio: Float32Array) => { onLst.current?.(false) // Während Lucy denkt/spricht (oder direkt danach) erkannte Sprache = ihr eigenes // Echo bzw. Nachhall -> verwerfen statt transkribieren. - if (cancelled || pausedRef.current || performance.now() < resumeAt.current) return - if (audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz - onUtt.current(toWavBlob(audio)) + if (cancelled || pausedRef.current || performance.now() < resumeAt.current) { pending = null; return } + if (!pending && audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz + let combined = pending ? concatAudio(pending, audio) : audio + if (combined.length > MAX_UTTERANCE_S * 16000) { + combined = combined.subarray(combined.length - MAX_UTTERANCE_S * 16000) + } + const complete = await isTurnComplete(combined) + if (cancelled) return + if (complete) { emit(combined); return } + // Mitten im Gedanken pausiert: kurz auf die Fortsetzung warten, dann notfalls doch senden. + pending = combined + if (holdTimer) clearTimeout(holdTimer) + holdTimer = setTimeout(() => { if (!cancelled && pending) emit(pending) }, HOLD_MS) }, onVADMisfire: () => onLst.current?.(false), }) @@ -90,6 +137,7 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions return () => { cancelled = true + if (holdTimer) clearTimeout(holdTimer) try { vad?.destroy() } catch { /* */ } onLst.current?.(false) } diff --git a/voice_service/app.py b/voice_service/app.py index f54937b..9e4fee9 100644 --- a/voice_service/app.py +++ b/voice_service/app.py @@ -195,6 +195,76 @@ def transcribe(audio_bytes: bytes, suffix: str, language: str, engine: str = "") return transcribe_whisper(audio_bytes, suffix, language) +# ================================================================================= +# Turn-Detection — Smart Turn v3 (pipecat, BSD-2): semantisches Äußerungs-Ende. +# Whisper-Tiny-Encoder + Klassifikator (8 MB int8, ~12 ms CPU). Der Lucy-Client fragt +# nach dem akustischen VAD-Ende hier nach: "War das ein fertiger Satz?" — bei +# 'incomplete' wartet er kurz weiter, statt mitten im Gedanken loszuantworten. +# Inferenz-Pfad 1:1 aus pipecat-ai/smart-turn inference.py (Feature-Shape muss passen). +# ================================================================================= +TURN_REPO = os.environ.get("VOICE_TURN_REPO", "pipecat-ai/smart-turn-v3") +TURN_FILE = os.environ.get("VOICE_TURN_FILE", "smart-turn-v3.2-cpu.onnx") +_turn = None # (session, feature_extractor) +_turn_failed = False + + +def turn_model(): + global _turn, _turn_failed + if _turn is None and not _turn_failed: + try: + import onnxruntime as ort + from huggingface_hub import hf_hub_download + from transformers import WhisperFeatureExtractor + log.info("Lade Smart Turn '%s/%s' …", TURN_REPO, TURN_FILE) + path = hf_hub_download(TURN_REPO, TURN_FILE) + so = ort.SessionOptions() + so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + sess = ort.InferenceSession(path, sess_options=so, providers=["CPUExecutionProvider"]) + _turn = (sess, WhisperFeatureExtractor(chunk_length=8)) + except Exception: + _turn_failed = True + log.exception("Smart Turn nicht verfügbar — /turn meldet complete=true (Fallback).") + return _turn + + +def check_turn(audio_bytes: bytes, suffix: str) -> dict: + model = turn_model() + if model is None: + return {"complete": True, "probability": 1.0, "engine": "none"} + sess, fe = model + from faster_whisper.audio import decode_audio + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf: + tf.write(audio_bytes) + src = tf.name + try: + import numpy as np + arr = decode_audio(src, sampling_rate=16000) + # Smart Turn erwartet das Audio AM ENDE des 8-s-Fensters (Zeros vorn) — die Turn-Ende- + # Hinweise liegen in den letzten Frames. Der FeatureExtractor padded rechts (Zeros hinten), + # damit sah das Modell immer nur Padding und meldete konstant 'complete' (live diagnostiziert). + # Darum manuell links auffüllen und exakt 8 s übergeben. + n = 8 * 16000 + arr = arr[-n:] + if len(arr) < n: + arr = np.concatenate([np.zeros(n - len(arr), dtype=np.float32), arr.astype(np.float32)]) + inputs = fe(arr, sampling_rate=16000, return_tensors="np", padding="do_not_pad", + truncation=True, do_normalize=True) + out = sess.run(None, {"input_features": inputs.input_features}) + # Output-Tensor heißt 'logits' — je nach Export roher Logit ODER schon Sigmoid. + # Robust: Werte außerhalb [0,1] durch Sigmoid schicken, sonst direkt nutzen. + raw = float(out[0][0].item() if hasattr(out[0][0], "item") else out[0][0]) + p = raw if 0.0 <= raw <= 1.0 else 1.0 / (1.0 + float(np.exp(-raw))) + # EMPIRISCH VERIFIZIERT (2026-07-02, Box): v3.2-cpu liefert P(UNFERTIG) — + # fertiger Satz -> 0.26, mitten im Wort abgeschnitten -> 0.96, Stille -> 0.99. + # (Die Upstream-Doku beschreibt es andersherum; Messung schlägt Doku.) + return {"complete": p <= 0.5, "probability": round(1.0 - p, 3), "engine": "smart-turn-v3"} + finally: + try: + os.unlink(src) + except OSError: + pass + + # ================================================================================= # TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben) # ================================================================================= @@ -467,6 +537,16 @@ async def stt(audio: UploadFile = File(...), language: str = Form(default=""), return {"text": text} +@app.post("/turn") +async def turn(audio: UploadFile = File(...)) -> dict: + """Semantische Turn-Detection: War die Äußerung ein fertiger Gedanke? (Smart Turn v3)""" + data = await audio.read() + if not data: + raise HTTPException(400, "Leeres Audio.") + suffix = Path(audio.filename or "rec.wav").suffix or ".wav" + return check_turn(data, suffix) + + @app.post("/tts") def tts(body: TTSIn) -> Response: text = (body.text or "").strip() diff --git a/voice_service/requirements.txt b/voice_service/requirements.txt index e9946c9..52c9566 100644 --- a/voice_service/requirements.txt +++ b/voice_service/requirements.txt @@ -11,3 +11,5 @@ edge-tts # STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller # als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert. onnx-asr[cpu,hub] +# Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features) +transformers