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

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