99 lines
4.1 KiB
Python
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
|