Turn-Detection: Smart Turn v3 semantisch (Review P2-11) — live verifiziert
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<ReturnType<typeof MicVAD.new>> | null = null
|
||||
// Semantik-Hold: bei 'incomplete' gepufferte Äußerung, auf die die Fortsetzung wartet.
|
||||
let pending: Float32Array | null = null
|
||||
let holdTimer: ReturnType<typeof setTimeout> | 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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user