whisper-stt-cli/stt_wyoming.py
dschlueter 884ec5dd90 Projektname whisper-stt-cli: Repository-URL in Wyoming-Info und Doku
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:34:30 +02:00

121 lines
4.4 KiB
Python

"""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/whisper-stt-cli"
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