From 22f00fda0f3ce8cc6eebe6622422040540515f60 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 14 Jul 2026 18:13:58 +0200 Subject: [PATCH] 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 --- .gitea/workflows/build.yml | 2 +- AGENTS.md | 8 +++++--- docs/architecture.md | 5 +++-- gateway/Dockerfile | 2 +- gateway/Makefile | 2 +- gateway/src/desklock_gateway/tts.py | 30 +++++++++++++++++++---------- 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 32d9945..ae151ff 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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]" diff --git a/AGENTS.md b/AGENTS.md index e9a1d45..1f13ddf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index b7320e9..d4ccf39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/gateway/Dockerfile b/gateway/Dockerfile index c3fa203..54ae3f5 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.13-slim WORKDIR /app diff --git a/gateway/Makefile b/gateway/Makefile index 4a9bdc5..2d63a62 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -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: diff --git a/gateway/src/desklock_gateway/tts.py b/gateway/src/desklock_gateway/tts.py index 72d7465..f3691ab 100644 --- a/gateway/src/desklock_gateway/tts.py +++ b/gateway/src/desklock_gateway/tts.py @@ -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()