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:
+66
-13
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user