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
5 changed files with 2202 additions and 42 deletions
92
tests/conftest.py
Normal file
92
tests/conftest.py
Normal 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
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
|
||||
62
tests/test_integration.py
Normal file
62
tests/test_integration.py
Normal 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
153
tests/test_service.py
Normal 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
62
tests/test_wyoming.py
Normal 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
|
||||
Loading…
Reference in a new issue