feat(gateway): normalize low-level STT capture

Add _wav_pcm(), _build_wav(), _normalize_wav_for_stt() (peak-normalize
with capped gain) to handle the PLIP handset mic low signal level and
placeholder 0xFFFFFFFF WAV headers from /voice/capture stream.
Rewire _pad_wav_silence() and _transcribe_kyutai via these helpers.
This commit is contained in:
clement
2026-06-16 08:41:43 +02:00
parent 68c81fa3c9
commit 20c0431f51
+66 -13
View File
@@ -1872,23 +1872,76 @@ async def _voice_tts_16k(http: httpx.AsyncClient, text: str, voice: str) -> tupl
return result
def _wav_pcm(wav: bytes) -> tuple[bytes, int, int, int] | None:
"""Parse a WAV by hand → (pcm, framerate, nchannels, sampwidth). Robust to a
placeholder/zero data-chunk length (the PLIP streams /voice/capture with
data_size = 0xFFFFFFFF), which the stdlib `wave` module chokes on."""
if len(wav) < 44 or wav[:4] != b"RIFF" or wav[8:12] != b"WAVE":
return None
i, fmt = 12, None
while i + 8 <= len(wav):
cid = wav[i:i + 4]
sz = int.from_bytes(wav[i + 4:i + 8], "little")
i += 8
if cid == b"fmt ":
ch = int.from_bytes(wav[i + 2:i + 4], "little")
sr = int.from_bytes(wav[i + 4:i + 8], "little")
sw = int.from_bytes(wav[i + 14:i + 16], "little") // 8
fmt = (sr, ch, sw)
i += sz + (sz & 1)
elif cid == b"data":
avail = len(wav) - i
dsz = avail if (sz == 0 or sz > avail or sz == 0xFFFFFFFF) else sz
return (wav[i:i + dsz], *fmt) if fmt else None
else:
i += sz + (sz & 1)
return None
def _build_wav(pcm: bytes, sr: int, ch: int, sw: int) -> bytes:
out = io.BytesIO()
with wave.open(out, "wb") as w:
w.setnchannels(ch)
w.setsampwidth(sw)
w.setframerate(sr)
w.writeframes(pcm)
return out.getvalue()
def _normalize_wav_for_stt(wav_bytes: bytes, target_peak: float = 0.5,
max_gain: float = 25.0) -> bytes:
"""Boost a quiet capture up to `target_peak` before STT. The PLIP handset
mic comes in very low (~1-4 % FS through the SLIC), too quiet for Kyutai to
transcribe — it hears only the noise floor and hallucinates. Peak-normalise
in software (gain capped so pure silence isn't blown up into noise)."""
if _np is None:
return wav_bytes
parsed = _wav_pcm(wav_bytes)
if parsed is None:
return wav_bytes
pcm, sr, ch, sw = parsed
if sw != 2 or not pcm:
return wav_bytes
arr = _np.frombuffer(pcm, dtype=_np.int16).astype(_np.float32)
peak = float(_np.max(_np.abs(arr))) or 1.0
gain = min((target_peak * 32767.0) / peak, max_gain)
if gain <= 1.05: # already loud enough
return _build_wav(pcm, sr, ch, sw)
arr = _np.clip(arr * gain, -32768, 32767).astype(_np.int16)
logging.info("STT normalise: peak %.1f%% FS → gain x%.1f", 100 * peak / 32768, gain)
return _build_wav(arr.tobytes(), sr, ch, sw)
def _pad_wav_silence(wav_bytes: bytes, ms: int = 800) -> bytes:
"""Append `ms` of trailing silence to a WAV. Kyutai STT is streaming with a
~0.5 s delay + semantic VAD; without an end-of-turn (trailing silence) the
LAST words aren't flushed and the transcription is truncated. Padding acts as
that end marker so the full utterance is transcribed."""
try:
with wave.open(io.BytesIO(wav_bytes), "rb") as w:
params = w.getparams()
raw = w.readframes(w.getnframes())
n_silence = int(params.framerate * ms / 1000) * params.nchannels * params.sampwidth
out = io.BytesIO()
with wave.open(out, "wb") as w2:
w2.setparams(params)
w2.writeframes(raw + b"\x00" * n_silence)
return out.getvalue()
except Exception:
return wav_bytes # malformed WAV → send as-is
parsed = _wav_pcm(wav_bytes)
if parsed is None:
return wav_bytes
pcm, sr, ch, sw = parsed
return _build_wav(pcm + b"\x00" * (int(sr * ms / 1000) * ch * sw), sr, ch, sw)
async def _transcribe_kyutai(http: httpx.AsyncClient, audio_wav: bytes) -> str:
@@ -1900,7 +1953,7 @@ async def _transcribe_kyutai(http: httpx.AsyncClient, audio_wav: bytes) -> str:
"""
resp = await http.post(
f"{settings.kyutai_stt_url.rstrip('/')}/transcribe",
content=_pad_wav_silence(audio_wav, 800),
content=_pad_wav_silence(_normalize_wav_for_stt(audio_wav), 800),
headers={"Content-Type": "audio/wav"},
timeout=30.0,
)