whisper-stt-cli/tests/test_service.py
dschlueter a986cbe42a 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>
2026-09-13 21:34:03 +02:00

153 lines
6.3 KiB
Python

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"