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