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
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"
|
||||
Loading…
Reference in a new issue