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:
parent
6b23d8614a
commit
a986cbe42a
1 changed files with 2202 additions and 42 deletions
165
tests/test_core.py
Normal file
165
tests/test_core.py
Normal 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
|
||||
Loading…
Reference in a new issue