feat: Barge-in/Turn-Manager und VAD-Aeusserungserkennung (#4 Ausbau)
- Barge-in: Antwort-Turn als abbrechbarer asyncio.Task; {"type":"interrupt"} oder
neue Eingabe bricht laufende Antwort ab -> interrupted-Event (/ws/chat + /ws/voice)
- VAD (app/audio/vad.py): energie-basierte Stille-Erkennung (reines Python, int16-PCM)
- /ws/voice opt-in {"type":"start","vad":true}: automatisches Aeusserungsende ohne end
- Tests: 52 gruen (+5: VAD-Unit, Barge-in, VAD-Auto-Segmentierung)
- Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur)
Offen (schwere Deps/Dienste): echte partielle Live-Transkripte (Streaming-STT),
WebRTC (aiortc). STT laeuft heute pro Aeusserung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9340d3f998
commit
093da817d8
6 changed files with 312 additions and 48 deletions
59
app/audio/vad.py
Normal file
59
app/audio/vad.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Einfache energie-basierte Sprachaktivitaetserkennung (VAD).
|
||||
|
||||
Reines Python (stdlib `array`), arbeitet auf s16le-PCM (mono). Erkennt das Ende
|
||||
einer Aeusserung anhand andauernder Stille nach erkannter Sprache. Damit kann der
|
||||
Server in /ws/voice Aeusserungen automatisch segmentieren, ohne dass der Client
|
||||
ein explizites Ende-Signal schickt.
|
||||
|
||||
Hinweis: Das ersetzt keinen echten Streaming-STT-Dienst (keine wortweisen
|
||||
Teil-Transkripte) - es bestimmt nur die Aeusserungsgrenzen.
|
||||
"""
|
||||
|
||||
import array
|
||||
import math
|
||||
|
||||
|
||||
def rms(pcm: bytes) -> float:
|
||||
"""Lautstaerke (RMS) eines s16le-PCM-Puffers; 0.0 bei leerem Puffer."""
|
||||
usable = len(pcm) - (len(pcm) % 2)
|
||||
if usable <= 0:
|
||||
return 0.0
|
||||
samples = array.array("h")
|
||||
samples.frombytes(pcm[:usable])
|
||||
if not samples:
|
||||
return 0.0
|
||||
return math.sqrt(sum(s * s for s in samples) / len(samples))
|
||||
|
||||
|
||||
class EnergyVAD:
|
||||
def __init__(self, sample_rate: int = 16000, threshold: float = 500.0, silence_ms: float = 700.0):
|
||||
self.sample_rate = sample_rate
|
||||
self.threshold = threshold
|
||||
self.silence_ms = silence_ms
|
||||
self._speech_started = False
|
||||
self._silence_ms = 0.0
|
||||
|
||||
def feed(self, pcm: bytes) -> bool:
|
||||
"""Verarbeitet einen Audio-Chunk.
|
||||
|
||||
Liefert True, sobald nach erkannter Sprache genug Stille (silence_ms)
|
||||
vergangen ist - die Aeusserung gilt dann als beendet.
|
||||
"""
|
||||
level = rms(pcm)
|
||||
n_samples = len(pcm) // 2
|
||||
chunk_ms = (n_samples / self.sample_rate) * 1000.0 if self.sample_rate else 0.0
|
||||
|
||||
if level >= self.threshold:
|
||||
self._speech_started = True
|
||||
self._silence_ms = 0.0
|
||||
return False
|
||||
|
||||
if self._speech_started:
|
||||
self._silence_ms += chunk_ms
|
||||
if self._silence_ms >= self.silence_ms:
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._speech_started = False
|
||||
self._silence_ms = 0.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue