"""Minimal Wyoming TCP ASR client for Alfred. No external Wyoming Python dependency. Transport: PCM signed 16-bit LE mono, 16 kHz by default, following the official transcribe/audio-start/chunk/stop flow. """ from __future__ import annotations import json import socket from typing import Optional class WyomingSTTError(RuntimeError): pass def _event(kind: str, data: Optional[dict] = None, payload: bytes = b"") -> bytes: header = {"type": kind} if data is not None: header["data"] = data if payload: header["payload_length"] = len(payload) return (json.dumps(header, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8") + payload def _read_event(stream) -> tuple[str, dict]: header_bytes = stream.readline(65537) if not header_bytes: raise WyomingSTTError("Connexion Wyoming fermée sans transcription") if len(header_bytes) > 65536 or not header_bytes.endswith(b"\n"): raise WyomingSTTError("En-tête Wyoming invalide ou trop long") try: header = json.loads(header_bytes) if not isinstance(header, dict): raise ValueError("en-tête non objet") data_length = int(header.get("data_length", 0)) payload_length = int(header.get("payload_length", 0)) if not 0 <= data_length <= 65536 or not 0 <= payload_length <= 1_048_576: raise ValueError("taille d'événement excessive") data = header.get("data") or {} if not isinstance(data, dict): raise ValueError("données non objet") if data_length: extra_data = stream.read(data_length) if len(extra_data) != data_length: raise WyomingSTTError("Données Wyoming incomplètes") more = json.loads(extra_data) if not isinstance(more, dict): raise ValueError("données additionnelles non objet") data.update(more) if payload_length: payload = stream.read(payload_length) if len(payload) != payload_length: raise WyomingSTTError("Charge utile Wyoming incomplète") return str(header["type"]), data except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc: raise WyomingSTTError(f"Message Wyoming incorrect : {exc}") from exc def transcribe_pcm( pcm: bytes, *, host: str = "192.168.1.13", port: int = 10300, language: str = "fr", sample_rate: int = 16000, connect_timeout: float = 3.0, response_timeout: float = 20.0, ) -> str: """Send recorded S16LE/mono PCM to Wyoming ASR and return the transcript.""" if not pcm or len(pcm) % 2: raise WyomingSTTError("Audio PCM vide ou mal aligné") if sample_rate <= 0: raise WyomingSTTError("Fréquence PCM invalide") if not 0 < response_timeout <= 300: raise WyomingSTTError("Délai Wyoming invalide") try: with socket.create_connection((host, int(port)), timeout=connect_timeout) as sock: sock.settimeout(response_timeout) sock.sendall(_event("transcribe", {"language": language})) audio_meta = {"rate": int(sample_rate), "width": 2, "channels": 1} sock.sendall(_event("audio-start", audio_meta)) for offset in range(0, len(pcm), 16000): chunk = pcm[offset : offset + 16000] sock.sendall(_event("audio-chunk", audio_meta, chunk)) sock.sendall(_event("audio-stop")) with sock.makefile("rb") as stream: # Some servers send informational events before the transcript. for _ in range(16): kind, data = _read_event(stream) if kind == "transcript": return str(data.get("text") or "").strip() if kind == "error": raise WyomingSTTError(str(data.get("text") or data.get("message") or data)) raise WyomingSTTError("Aucune transcription après 16 événements Wyoming") except (OSError, TimeoutError) as exc: raise WyomingSTTError(f"Serveur Whisper {host}:{port} indisponible : {exc}") from exc