Drop audioop: request 16 kHz directly via Speaches sample_rate extension
Test, Build and Push / test-gateway (push) Successful in 33s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped

Settles the Python version question: floor >=3.11, no ceiling.
Container moves to python:3.13-slim, CI tests on 3.13. The embedded
Piper fallback resamples with numpy (already present via [speech]).
Verified with a live round trip at 16 kHz.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 18:13:58 +02:00
co-authored by Claude Fable 5
parent f5df5049db
commit 22f00fda0f
6 changed files with 31 additions and 18 deletions
+20 -10
View File
@@ -1,11 +1,10 @@
"""Text-to-speech: Speaches/Kokoro over HTTP (default) or embedded Piper.
Output is always s16le mono PCM at settings.sample_rate (the device WS contract).
Kokoro synthesizes at 24 kHz, so the speaches path resamples via audioop —
which pins the runtime to Python 3.12 (audioop is removed in 3.13).
The speaches path requests the target rate directly via the server's
`sample_rate` extension, so no local resampling is needed.
"""
import audioop
import io
import wave
@@ -35,6 +34,7 @@ def _synthesize_speaches(text: str) -> bytes:
"voice": settings.tts_voice,
"input": text,
"response_format": "wav",
"sample_rate": settings.sample_rate,
},
)
response.raise_for_status()
@@ -42,12 +42,12 @@ def _synthesize_speaches(text: str) -> bytes:
with wave.open(io.BytesIO(response.content), "rb") as w:
rate, channels, width = w.getframerate(), w.getnchannels(), w.getsampwidth()
frames = w.readframes(w.getnframes())
if width != 2:
frames = audioop.lin2lin(frames, width, 2)
if channels == 2:
frames = audioop.tomono(frames, 2, 0.5, 0.5)
if rate != settings.sample_rate:
frames, _state = audioop.ratecv(frames, 2, 1, rate, settings.sample_rate, None)
if (rate, channels, width) != (settings.sample_rate, 1, 2):
raise RuntimeError(
f"Speaches returned {rate} Hz/{channels}ch/{8 * width}-bit audio; "
f"expected {settings.sample_rate} Hz mono 16-bit — check the server's "
"sample_rate support"
)
return frames
@@ -61,4 +61,14 @@ def _synthesize_embedded(text: str) -> bytes:
chunks = bytearray()
for chunk in _embedded_voice.synthesize_stream_raw(text):
chunks.extend(chunk)
return bytes(chunks)
native_rate = _embedded_voice.config.sample_rate
if native_rate == settings.sample_rate:
return bytes(chunks)
import numpy as np
audio = np.frombuffer(bytes(chunks), dtype=np.int16)
n_out = int(len(audio) * settings.sample_rate / native_rate)
positions = np.linspace(0, len(audio) - 1, n_out)
resampled = np.interp(positions, np.arange(len(audio)), audio.astype(np.float32))
return resampled.astype(np.int16).tobytes()