"""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