STT-Dienst: Kern, HTTP/WebSocket/Wyoming-Service, CLI, Tests, systemd-Unit

- stt_core.py: Whisper-Engine (einmal laden, CPU-Fallback), Audio-Dekodierung
  jedes Formats, Halluzinationsfilter, Äußerungserkennung per Silero-VAD
- stt_service.py: /health, /v1/audio/transcriptions (OpenAI), /transcribe (Rohdaten),
  WS /ws/stream; Inferenz im Worker-Thread, begrenzte Warteschlange (503)
- stt_wyoming.py: Home-Assistant-Anbindung (TCP 10300)
- stt_cli.py: Datei/Mikrofon über den Dienst oder lokal
- Tests (Unit + Integration), README, requirements.txt, deploy/stt-service.service

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-13 21:34:03 +02:00
commit a986cbe42a
5 changed files with 2202 additions and 42 deletions

92
tests/conftest.py Normal file
View file

@ -0,0 +1,92 @@
import io
import os
import shutil
import subprocess
import sys
import wave
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ.setdefault("STT_WYOMING_PORT", "") # Modul-App in Tests ohne TCP-Server
import stt_core as core # noqa: E402
SR = core.SAMPLE_RATE
def make_wav(audio: np.ndarray, rate: int = SR, channels: int = 1, width: int = 2) -> bytes:
"""float32 (mono oder [n, channels]) → WAV-Bytes."""
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(channels)
w.setsampwidth(width)
w.setframerate(rate)
if width == 2:
w.writeframes((np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes())
elif width == 4:
w.writeframes((np.clip(audio, -1, 1) * 2147483647).astype("<i4").tobytes())
return buf.getvalue()
def tone(seconds: float, freq: float = 220.0, amp: float = 0.5, rate: int = SR) -> np.ndarray:
t = np.arange(int(seconds * rate)) / rate
return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
def silence(seconds: float, rate: int = SR) -> np.ndarray:
return np.zeros(int(seconds * rate), dtype=np.float32)
class EnergyProb:
"""Deterministische Ersatz-VAD für Tests: Fenster mit Energie = Sprache."""
def __call__(self, windows: np.ndarray) -> np.ndarray:
frames = windows.reshape(-1, core.VAD_WINDOW)
rms = np.sqrt((frames ** 2).mean(axis=1))
return (rms > 0.05).astype(np.float32)
class FakeEngine(core.SttEngine):
"""Engine ohne Modell: 'transkribiert' die Audiodauer."""
def __init__(self, delay: float = 0.0):
super().__init__(core.EngineConfig(model="fake"))
self.delay = delay
self.calls: list[dict] = []
self.status = "ready"
self.active_model, self.active_device = "fake", "cpu"
def load(self):
return None
def warmup(self):
return None
def transcribe(self, audio, **kwargs):
import time
if self.delay:
time.sleep(self.delay)
self.calls.append({"samples": int(audio.size), **kwargs})
duration = round(audio.size / SR, 2)
return core.Transcript(
text=f"audio {duration:.1f}s", language=self.resolve_language(kwargs.get("language")) or "de",
language_probability=1.0, duration=duration, processing_s=0.0,
segments=[core.Segment(0.0, duration, f"audio {duration:.1f}s")],
)
def espeak_wav(text: str, voice: str = "de") -> bytes | None:
if not shutil.which("espeak-ng"):
return None
return subprocess.run(["espeak-ng", "-v", voice, "-s", "140", "--stdout", text],
check=True, capture_output=True).stdout
@pytest.fixture
def fake_engine():
return FakeEngine()

165
tests/test_core.py Normal file
View file

@ -0,0 +1,165 @@
import numpy as np
import pytest
import stt_core as core
from conftest import SR, EnergyProb, make_wav, silence, tone
# --- Audio-Hilfen -------------------------------------------------------------
def test_pcm16_to_float_mono_and_channel_selection():
stereo = np.array([[1000, -2000], [3000, -4000]], dtype="<i2").tobytes()
right = core.pcm16_to_float(stereo, channels=2, channel=1)
assert np.allclose(right * 32768, [-2000, -4000])
mixed = core.pcm16_to_float(stereo, channels=2)
assert np.allclose(mixed * 32768, [-500, -500])
# unvollständiger Frame am Ende wird verworfen
assert core.pcm16_to_float(stereo + b"\x01", channels=2).size == 2
def test_pcm16_to_float_rejects_invalid_channel():
with pytest.raises(ValueError):
core.pcm16_to_float(b"\x00" * 8, channels=2, channel=2)
def test_resample_length():
out = core.resample(tone(1.0, rate=48000), 48000)
assert out.dtype == np.float32 and abs(out.size - SR) <= 1
def test_stream_resampler_matches_total_length():
rs = core.StreamResampler(48000)
audio = tone(1.0, rate=48000)
parts = [rs.process(audio[i:i + 4800]) for i in range(0, audio.size, 4800)]
parts.append(rs.process(np.zeros(0, np.float32), last=True))
assert abs(sum(p.size for p in parts) - SR) <= 2
def test_decode_stereo_wav_is_not_misread_as_mono():
"""Alt-Bug in stt.py: Stereo-WAV wurde als Mono mit doppelter Länge gelesen."""
left = tone(1.0, rate=44100)
wav = make_wav(np.stack([left, left], axis=1).reshape(-1), rate=44100, channels=2)
audio = core.decode_audio_bytes(wav)
assert abs(audio.size / SR - 1.0) < 0.05
def test_decode_32bit_wav():
audio = core.decode_audio_bytes(make_wav(tone(0.5), width=4))
assert abs(audio.size / SR - 0.5) < 0.05
def test_decode_garbage_raises():
with pytest.raises(core.AudioDecodeError):
core.decode_audio_bytes(b"das ist kein audio" * 10)
with pytest.raises(core.AudioDecodeError):
core.decode_audio_bytes(b"")
# --- Halluzinationsfilter -----------------------------------------------------
@pytest.mark.parametrize("text", [
"Untertitel im Auftrag des ZDF, 2021",
"Untertitel der Amara.org-Community",
"Vielen Dank fürs Zuschauen!",
"Copyright WDR 2020",
"...",
])
def test_hallucinations_are_detected(text):
assert core.is_hallucination(core.Segment(0, 1, text))
@pytest.mark.parametrize("text", [
"Schalte das Licht im Wohnzimmer ein.",
"Vielen Dank.",
"Die Untertitel im Fernsehen sind zu klein.",
])
def test_real_speech_is_kept(text):
assert not core.is_hallucination(core.Segment(0, 1, text, avg_logprob=-0.3, no_speech_prob=0.1))
def test_uncertain_silence_segment_is_dropped():
assert core.is_hallucination(core.Segment(0, 1, "Hm", avg_logprob=-1.2, no_speech_prob=0.9))
# --- Konfiguration / Sprache --------------------------------------------------
def test_engine_config_from_env(monkeypatch):
monkeypatch.setenv("STT_MODEL", "tiny")
monkeypatch.setenv("STT_DEVICE", "cpu")
monkeypatch.setenv("STT_LANGUAGE", "auto")
monkeypatch.setenv("STT_BEAM_SIZE", "2")
cfg = core.EngineConfig.from_env()
assert (cfg.model, cfg.device, cfg.language, cfg.beam_size) == ("tiny", "cpu", None, 2)
def test_resolve_language():
engine = core.SttEngine(core.EngineConfig(language="de"))
assert engine.resolve_language(None) == "de"
assert engine.resolve_language("auto") is None
assert engine.resolve_language("en-US") == "en"
def test_load_attempts_cpu_fallback_order(monkeypatch):
monkeypatch.setattr(core, "cuda_device_count", lambda: 1)
engine = core.SttEngine(core.EngineConfig(model="large-v3-turbo", cpu_model="small"))
assert engine._load_attempts() == [("cuda", "large-v3-turbo", "float16"), ("cpu", "small", "int8")]
monkeypatch.setattr(core, "cuda_device_count", lambda: 0)
assert engine._load_attempts() == [("cpu", "small", "int8")]
cpu_engine = core.SttEngine(core.EngineConfig(model="medium", device="cpu"))
assert cpu_engine._load_attempts() == [("cpu", "medium", "int8")]
def test_very_short_audio_returns_empty_without_model():
engine = core.SttEngine(core.EngineConfig(model="gibt-es-nicht"))
tr = engine.transcribe(np.zeros(100, np.float32))
assert tr.text == "" and engine.status == "idle"
# --- Segmentierung ------------------------------------------------------------
def _run(segmenter, audio, chunk=1600):
events = []
for i in range(0, audio.size, chunk):
events += segmenter.feed(audio[i:i + chunk])
return events
def test_segmenter_detects_two_utterances():
seg = core.UtteranceSegmenter(core.VadConfig(silence_ms=500), prob_fn=EnergyProb())
audio = np.concatenate([silence(0.5), tone(1.0), silence(1.0), tone(0.8), silence(1.0)])
events = _run(seg, audio)
kinds = [e.kind for e in events]
assert kinds == ["speech_start", "utterance", "speech_start", "utterance"]
first = events[1]
assert first.reason == "silence"
# Äußerung enthält Sprache + Pre-Roll + Nachlauf-Stille, aber nicht die Folgeäußerung
assert 1.2 < first.audio.size / SR < 1.9
def test_segmenter_ignores_short_clicks():
seg = core.UtteranceSegmenter(core.VadConfig(min_speech_ms=200), prob_fn=EnergyProb())
audio = np.concatenate([silence(0.3), tone(0.05), silence(1.5)])
assert _run(seg, audio) == []
def test_segmenter_splits_at_max_length():
seg = core.UtteranceSegmenter(core.VadConfig(max_utterance_s=2.0), prob_fn=EnergyProb())
events = _run(seg, tone(5.0))
utterances = [e for e in events if e.kind == "utterance"]
assert utterances and all(e.reason == "max_length" for e in utterances)
assert all(e.audio.size / SR <= 2.05 for e in utterances)
def test_segmenter_flush_returns_running_utterance():
seg = core.UtteranceSegmenter(prob_fn=EnergyProb())
_run(seg, tone(1.0))
assert seg.in_speech
(event,) = seg.flush()
assert event.reason == "flush" and event.audio.size > 0.9 * SR
assert not seg.in_speech and seg.flush() == []
def test_silero_prob_streaming_on_silence_and_noise():
prob = core.SileroProb()
out = prob(np.zeros(core.VAD_WINDOW * 10, np.float32))
assert out.shape == (10,) and float(out.max()) < 0.2

62
tests/test_integration.py Normal file
View file

@ -0,0 +1,62 @@
"""Echte Transkription mit Whisper (pytest -m integration).
Nutzt espeak-ng als reproduzierbare Sprachquelle und standardmäßig das kleine,
lokal gecachte Modell 'small' auf CPU (STT_TEST_MODEL / STT_TEST_DEVICE überschreibbar).
"""
import os
import numpy as np
import pytest
import stt_core as core
from conftest import espeak_wav, silence
pytestmark = pytest.mark.integration
@pytest.fixture(scope="module")
def engine():
cfg = core.EngineConfig(model=os.environ.get("STT_TEST_MODEL", "small"),
device=os.environ.get("STT_TEST_DEVICE", "cpu"), language="de")
eng = core.SttEngine(cfg)
eng.load()
return eng
def _speech(text, voice="de"):
wav = espeak_wav(text, voice)
if wav is None:
pytest.skip("espeak-ng fehlt")
return core.decode_audio_bytes(wav)
def _words(text):
return set(text.lower().replace(".", " ").replace(",", " ").split())
def test_german_command(engine):
tr = engine.transcribe(_speech("Schalte das Licht im Wohnzimmer ein."))
assert {"licht", "wohnzimmer"} <= _words(tr.text), tr.text
def test_silence_gives_empty_text(engine):
noise = (np.random.default_rng(1).standard_normal(3 * core.SAMPLE_RATE) * 0.003).astype(np.float32)
assert engine.transcribe(noise).text == ""
def test_language_detection(engine):
tr = engine.transcribe(_speech("The weather is nice today.", "en"), language="auto")
assert tr.language == "en", tr
def test_segmenter_with_silero_on_real_speech(engine):
speech = _speech("Schalte das Licht aus.")
stream = np.concatenate([silence(0.5), speech, silence(1.5), speech, silence(1.5)])
seg = core.UtteranceSegmenter()
events = []
for i in range(0, stream.size, 1600):
events += seg.feed(stream[i:i + 1600])
utterances = [e for e in events if e.kind == "utterance"]
assert len(utterances) == 2, [(e.kind, e.time_s) for e in events]
assert "licht" in _words(engine.transcribe(utterances[0].audio).text)

153
tests/test_service.py Normal file
View file

@ -0,0 +1,153 @@
import asyncio
import json
import numpy as np
import pytest
from fastapi.testclient import TestClient
import stt_core as core
import stt_service as service
from conftest import SR, EnergyProb, FakeEngine, make_wav, silence, tone
def _client(engine=None, **settings):
settings = service.ServiceSettings(wyoming_port=None, **settings)
app = service.create_app(engine or FakeEngine(), settings,
core.VadConfig(silence_ms=400), prob_fn_factory=EnergyProb)
return TestClient(app)
def test_health():
with _client() as client:
data = client.get("/health").json()
assert data["status"] == "ok" and data["model"] == "fake" and data["queue"] == 0
def test_openai_transcriptions_formats():
wav = make_wav(tone(1.5))
with _client() as client:
files = {"file": ("a.wav", wav, "audio/wav")}
assert client.post("/v1/audio/transcriptions", files=files).json() == {"text": "audio 1.5s"}
r = client.post("/v1/audio/transcriptions", files=files, data={"response_format": "text"})
assert r.text == "audio 1.5s"
r = client.post("/v1/audio/transcriptions", files=files,
data={"response_format": "verbose_json", "language": "en"})
body = r.json()
assert body["language"] == "en" and body["segments"][0]["text"] == "audio 1.5s"
r = client.post("/v1/audio/transcriptions", files=files, data={"response_format": "srt"})
assert r.status_code == 400
def test_transcribe_raw_pcm_with_resampling_and_channel():
engine = FakeEngine()
stereo = np.stack([tone(1.0, rate=48000), silence(1.0, rate=48000)], axis=1)
pcm = (stereo * 32767).astype("<i2").tobytes()
with _client(engine) as client:
r = client.post("/transcribe?format=pcm&sample_rate=48000&channels=2&channel=0", content=pcm)
assert r.status_code == 200, r.text
assert r.json()["duration"] == 1.0
assert abs(engine.calls[0]["samples"] - SR) <= 2
def test_transcribe_raw_wav_and_errors():
with _client(max_upload_mb=1) as client:
assert client.post("/transcribe", content=make_wav(tone(0.5))).json()["text"] == "audio 0.5s"
assert client.post("/transcribe", content=b"kaputt" * 100).status_code == 400
assert client.post("/transcribe", content=b"").status_code == 400
assert client.post("/transcribe", content=b"\0" * (2 * 1024 * 1024)).status_code == 413
def test_overload_returns_503():
engine = FakeEngine(delay=0.3)
app = service.create_app(engine, service.ServiceSettings(wyoming_port=None, max_queue=1),
prob_fn_factory=EnergyProb)
import httpx
async def scenario():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
wav = make_wav(tone(0.5))
results = await asyncio.gather(*[client.post("/transcribe", content=wav) for _ in range(3)])
return sorted(r.status_code for r in results)
codes = asyncio.run(scenario())
assert codes[0] == 200 and 503 in codes
def test_health_stays_responsive_during_transcription():
"""Nicht blockierend: /health antwortet, während der Worker rechnet."""
import time
import httpx
engine = FakeEngine(delay=1.0)
app = service.create_app(engine, service.ServiceSettings(wyoming_port=None),
prob_fn_factory=EnergyProb)
async def scenario():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
job = asyncio.create_task(client.post("/transcribe", content=make_wav(tone(0.5))))
deadline = time.perf_counter() + 5
while app.state.worker.pending == 0 and time.perf_counter() < deadline:
await asyncio.sleep(0.01) # warten, bis der Auftrag im Worker rechnet
t0 = time.perf_counter()
health = await client.get("/health")
elapsed = time.perf_counter() - t0
await job
return health.json(), elapsed
health, elapsed = asyncio.run(scenario())
assert health["queue"] == 1 and elapsed < 0.5
def _receive_until(ws, wanted_type, limit=20):
events = []
for _ in range(limit):
event = ws.receive_json()
events.append(event)
if event["type"] == wanted_type:
return events
raise AssertionError(f"{wanted_type} nicht empfangen: {events}")
def _pcm(audio):
return (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes()
def test_ws_stream_with_vad_emits_transcripts_per_utterance():
audio = np.concatenate([silence(0.3), tone(1.0), silence(0.8), tone(0.6), silence(0.3)])
with _client() as client, client.websocket_connect("/ws/stream") as ws:
assert ws.receive_json()["type"] == "ready"
ws.send_text(json.dumps({"type": "start", "sample_rate": 16000, "language": "de"}))
assert ws.receive_json()["type"] == "ready"
for i in range(0, audio.size, 1600):
ws.send_bytes(_pcm(audio[i:i + 1600]))
ws.send_text(json.dumps({"type": "end"}))
events = _receive_until(ws, "done")
transcripts = [e for e in events if e["type"] == "transcript"]
assert [e["reason"] for e in transcripts] == ["silence", "flush"]
assert sum(e["type"] == "speech_start" for e in events) == 2
def test_ws_stream_without_vad_and_resampling():
audio = tone(2.0, rate=48000)
with _client() as client, client.websocket_connect("/ws/stream") as ws:
ws.receive_json()
ws.send_text(json.dumps({"type": "start", "sample_rate": 48000, "vad": False}))
ws.receive_json()
for i in range(0, audio.size, 4800):
ws.send_bytes(_pcm(audio[i:i + 4800]))
ws.send_text(json.dumps({"type": "end"}))
events = _receive_until(ws, "done")
(transcript,) = [e for e in events if e["type"] == "transcript"]
assert transcript["duration"] == pytest.approx(2.0, abs=0.02)
def test_ws_invalid_control_frame_keeps_connection():
with _client() as client, client.websocket_connect("/ws/stream") as ws:
ws.receive_json()
ws.send_text("kein json")
assert ws.receive_json()["type"] == "error"
ws.send_text(json.dumps({"type": "end"}))
assert ws.receive_json()["type"] == "done"

62
tests/test_wyoming.py Normal file
View file

@ -0,0 +1,62 @@
import asyncio
import numpy as np
import pytest
pytest.importorskip("wyoming")
from wyoming.asr import Transcribe, Transcript # noqa: E402
from wyoming.audio import AudioChunk, AudioStart, AudioStop # noqa: E402
from wyoming.client import AsyncTcpClient # noqa: E402
from wyoming.info import Describe, Info # noqa: E402
import stt_service as service # noqa: E402
import stt_wyoming # noqa: E402
from conftest import FakeEngine, tone # noqa: E402
async def _with_server(scenario):
engine = FakeEngine()
worker = service.TranscriptionWorker(engine)
server = await stt_wyoming.start_server(worker, "127.0.0.1", 0)
port = server._server.sockets[0].getsockname()[1]
try:
return await scenario(port), engine
finally:
await server.stop()
worker.shutdown()
def test_describe_returns_asr_info():
async def scenario(port):
async with AsyncTcpClient("127.0.0.1", port) as client:
await client.write_event(Describe().event())
return Info.from_event(await client.read_event())
info, _ = asyncio.run(_with_server(scenario))
assert info.asr[0].models[0].name == "fake"
assert "de" in info.asr[0].models[0].languages
def test_transcribe_converts_audio_format():
"""HA schickt 16 kHz mono; hier absichtlich 22,05 kHz stereo → Konvertierung."""
rate = 22050
left = tone(1.0, rate=rate)
stereo = np.stack([left, left], axis=1).reshape(-1)
pcm = (stereo * 32767).astype("<i2").tobytes()
async def scenario(port):
async with AsyncTcpClient("127.0.0.1", port) as client:
await client.write_event(Transcribe(language="de-DE", transcript_names=["Wohnzimmer"]).event())
await client.write_event(AudioStart(rate=rate, width=2, channels=2).event())
step = 4410 * 2
for i in range(0, len(pcm), step):
await client.write_event(AudioChunk(rate=rate, width=2, channels=2, audio=pcm[i:i + step]).event())
await client.write_event(AudioStop().event())
return Transcript.from_event(await client.read_event())
transcript, engine = asyncio.run(_with_server(scenario))
assert transcript.text == "audio 1.0s" and transcript.language == "de"
call = engine.calls[0]
assert call["hotwords"] == "Wohnzimmer" and call["language"] == "de-DE"
assert abs(call["samples"] - 16000) < 50