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
121
stt_wyoming.py
Normal file
121
stt_wyoming.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Wyoming-Protokoll-Adapter für Home Assistant.
|
||||
|
||||
Läuft im selben Prozess wie stt_service.py und teilt sich Engine + Worker (ein
|
||||
Modell im Speicher). In Home Assistant: Einstellungen → Geräte & Dienste →
|
||||
Integration „Wyoming Protocol“ → Host dieses Rechners, Port 10300.
|
||||
|
||||
Ablauf (HA → Server): transcribe → audio-start → audio-chunk* → audio-stop
|
||||
Antwort: transcript. Das Äußerungsende bestimmt HA bzw. der Satellit (ESPHome)
|
||||
selbst (requires_external_vad), wir transkribieren das komplette Audio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from wyoming.asr import Transcribe, Transcript
|
||||
from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop
|
||||
from wyoming.event import Event
|
||||
from wyoming.info import AsrModel, AsrProgram, Attribution, Describe, Info
|
||||
from wyoming.server import AsyncEventHandler, AsyncTcpServer
|
||||
|
||||
import stt_core as core
|
||||
|
||||
log = logging.getLogger("stt.wyoming")
|
||||
|
||||
PROJECT_URL = "https://kitux.de/forgejo/dschlueter/SpeechToText"
|
||||
|
||||
|
||||
def _whisper_languages() -> list[str]:
|
||||
try:
|
||||
from faster_whisper.tokenizer import _LANGUAGE_CODES
|
||||
|
||||
return sorted(_LANGUAGE_CODES)
|
||||
except ImportError:
|
||||
return ["de", "en"]
|
||||
|
||||
|
||||
def build_info(engine: core.SttEngine) -> Info:
|
||||
model_name = engine.active_model or engine.config.model
|
||||
return Info(
|
||||
asr=[
|
||||
AsrProgram(
|
||||
name="speech-to-text",
|
||||
description="Lokaler STT-Dienst (faster-whisper)",
|
||||
attribution=Attribution(name="dschlueter", url=PROJECT_URL),
|
||||
installed=True,
|
||||
version=core.__version__,
|
||||
models=[
|
||||
AsrModel(
|
||||
name=model_name,
|
||||
description=f"Whisper {model_name}",
|
||||
attribution=Attribution(
|
||||
name="OpenAI / SYSTRAN", url="https://github.com/SYSTRAN/faster-whisper"
|
||||
),
|
||||
installed=True,
|
||||
version=None,
|
||||
languages=_whisper_languages(),
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SttEventHandler(AsyncEventHandler):
|
||||
def __init__(self, worker, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.worker = worker
|
||||
self._converter = AudioChunkConverter(rate=core.SAMPLE_RATE, width=2, channels=1)
|
||||
self._reset()
|
||||
|
||||
def _reset(self) -> None:
|
||||
self._audio = bytearray()
|
||||
self._language: str | None = None
|
||||
self._hotwords: str | None = None
|
||||
|
||||
async def handle_event(self, event: Event) -> bool:
|
||||
if Describe.is_type(event.type):
|
||||
await self.write_event(build_info(self.worker.engine).event())
|
||||
return True
|
||||
|
||||
if Transcribe.is_type(event.type):
|
||||
req = Transcribe.from_event(event)
|
||||
self._language = req.language
|
||||
terms = (req.transcript_names or []) + (req.transcript_terms or [])
|
||||
self._hotwords = " ".join(terms) or None
|
||||
return True
|
||||
|
||||
if AudioStart.is_type(event.type):
|
||||
self._audio = bytearray()
|
||||
self._converter = AudioChunkConverter(rate=core.SAMPLE_RATE, width=2, channels=1)
|
||||
return True
|
||||
|
||||
if AudioChunk.is_type(event.type):
|
||||
chunk = self._converter.convert(AudioChunk.from_event(event))
|
||||
self._audio.extend(chunk.audio)
|
||||
return True
|
||||
|
||||
if AudioStop.is_type(event.type):
|
||||
audio = core.pcm16_to_float(bytes(self._audio))
|
||||
text, language = "", self._language
|
||||
try:
|
||||
tr = await self.worker.transcribe(audio, language=self._language,
|
||||
hotwords=self._hotwords)
|
||||
text, language = tr.text, tr.language
|
||||
log.info("Wyoming-Transkript (%.1fs Audio, %.2fs): %s",
|
||||
tr.duration, tr.processing_s, text)
|
||||
except Exception as exc: # HA erwartet immer eine Antwort
|
||||
log.error("Wyoming-Transkription fehlgeschlagen: %s", exc)
|
||||
await self.write_event(Transcript(text=text, language=language).event())
|
||||
self._reset()
|
||||
return False # HA öffnet pro Anfrage eine neue Verbindung
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def start_server(worker, host: str, port: int) -> AsyncTcpServer:
|
||||
server = AsyncTcpServer(host, port)
|
||||
await server.start(partial(SttEventHandler, worker))
|
||||
return server
|
||||
Loading…
Reference in a new issue