Files
Alfredv2/alfred/app/services/wyoming_stt.py
T

99 lines
4.1 KiB
Python

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