Drop audioop: request 16 kHz directly via Speaches sample_rate extension
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:
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install
|
||||
run: pip install -e "./gateway[dev]"
|
||||
|
||||
@@ -15,9 +15,11 @@ one repo:
|
||||
(Tatlock `/v1/chat/completions`) → TTS. Listens on port **8600**. STT/TTS models live
|
||||
in the shared **Speaches** container (live on port 8601, OpenAI-format API), not in
|
||||
the gateway image; `stt.py`/`tts.py` are pluggable backends (`speaches` default,
|
||||
`embedded` fallback needing the `[speech]` extra). Gateway runs on **Python 3.12
|
||||
exactly** — system python3 on tower-of-joy is 3.8, and `audioop` (used for TTS
|
||||
resampling) is removed in 3.13.
|
||||
`embedded` fallback needing the `[speech]` extra). Gateway needs **Python ≥ 3.11**
|
||||
(no ceiling; the container runs 3.13) — but system python3 on tower-of-joy is 3.8,
|
||||
so `make setup` explicitly uses `python3.12`. No local audio resampling in the
|
||||
default path: the gateway requests 16 kHz output via Speaches' `sample_rate`
|
||||
extension (verified live).
|
||||
|
||||
The device and gateway speak a WebSocket protocol defined in `docs/architecture.md`.
|
||||
**That doc is the contract** — update it in the same change as any protocol edit on
|
||||
|
||||
@@ -98,8 +98,9 @@ we may adopt later for streaming transcription.
|
||||
|
||||
- **Deployed 2026-07-14**: `ghcr.io/speaches-ai/speaches:latest-cuda` on host port
|
||||
**8601**, with `Systran/faster-whisper-small` (STT) and
|
||||
`speaches-ai/Kokoro-82M-v1.0-ONNX` (TTS, 24 kHz — the gateway resamples to the
|
||||
16 kHz device contract; default voice `bm_george`, en-GB male). LAN-only like the
|
||||
`speaches-ai/Kokoro-82M-v1.0-ONNX` (TTS — Kokoro is natively 24 kHz, but the
|
||||
gateway requests the 16 kHz device contract directly via Speaches' `sample_rate`
|
||||
extension, verified live; default voice `bm_george`, en-GB male). LAN-only like the
|
||||
Tatlock internal route — do not expose through NPM without auth. Register in
|
||||
`CONTAINERS.md`.
|
||||
- **Measured** (live round trip through the gateway code, warm): STT ~0.3 s for a
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.PHONY: setup run test lint typecheck clean
|
||||
|
||||
# audioop pins us below 3.13; system python3 on tower-of-joy is 3.8
|
||||
# any Python >= 3.11 works; system python3 on tower-of-joy is 3.8, hence explicit
|
||||
PYTHON ?= python3.12
|
||||
|
||||
setup:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user