feat: SaluteSpeech STT for the pilot + no foreign STT outside the demo (ADR-016)
CI / ruff · pytest (pull_request) Successful in 2m16s
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #121 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+173
-8
@@ -6,13 +6,18 @@ ordinary text message — safety, `scrub()` and the LLM gateway apply there. Aud
|
||||
* `OpenRouterSTT` — an audio-capable model via OpenRouter `/chat/completions` (`input_audio`). TEMPORARY, synthetic
|
||||
data only (ADR-014): voice is personal data that `scrub()` cannot depersonalise, and OpenRouter is a foreign
|
||||
service (152-FZ). Real patients need an RF provider (SaluteSpeech, #121) behind the same `SpeechToText` contract.
|
||||
* `SaluteSTT` — Sber SaluteSpeech, synchronous REST recognition of the whole file (RF contour, #121, ADR-016).
|
||||
The pilot provider: the only one allowed for real patients.
|
||||
* `MockSTT` — offline, returns a fixed synthetic phrase (ADR-004: the demo works without network).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import ssl
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
@@ -56,23 +61,48 @@ class InvalidAudio(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def wav_seconds(audio: bytes) -> float:
|
||||
"""Duration of a PCM WAV file; raises `InvalidAudio` for anything else."""
|
||||
class WavInfo(BaseModel):
|
||||
audio_format: int # 1 = PCM
|
||||
channels: int
|
||||
sample_rate: int
|
||||
byte_rate: int
|
||||
bits: int
|
||||
data_offset: int
|
||||
data_size: int
|
||||
|
||||
|
||||
def wav_info(audio: bytes) -> WavInfo:
|
||||
"""Header of a WAV file; raises `InvalidAudio` for anything else."""
|
||||
if len(audio) < 44 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE":
|
||||
raise InvalidAudio("not a WAV file")
|
||||
pos, byte_rate = 12, 0
|
||||
pos, fmt = 12, None
|
||||
while pos + 8 <= len(audio):
|
||||
chunk_id, size = audio[pos : pos + 4], struct.unpack_from("<I", audio, pos + 4)[0]
|
||||
if chunk_id == b"fmt " and size >= 16:
|
||||
byte_rate = struct.unpack_from("<I", audio, pos + 16)[0]
|
||||
fmt = struct.unpack_from("<HHIIHH", audio, pos + 8)
|
||||
elif chunk_id == b"data":
|
||||
if not byte_rate:
|
||||
if not fmt or not fmt[3]:
|
||||
break
|
||||
return min(size, len(audio) - pos - 8) / byte_rate
|
||||
audio_format, channels, rate, byte_rate, _, bits = fmt
|
||||
return WavInfo(
|
||||
audio_format=audio_format,
|
||||
channels=channels,
|
||||
sample_rate=rate,
|
||||
byte_rate=byte_rate,
|
||||
bits=bits,
|
||||
data_offset=pos + 8,
|
||||
data_size=min(size, len(audio) - pos - 8),
|
||||
)
|
||||
pos += 8 + size + (size & 1)
|
||||
raise InvalidAudio("malformed WAV")
|
||||
|
||||
|
||||
def wav_seconds(audio: bytes) -> float:
|
||||
"""Duration of a PCM WAV file; raises `InvalidAudio` for anything else."""
|
||||
info = wav_info(audio)
|
||||
return info.data_size / info.byte_rate
|
||||
|
||||
|
||||
class SpeechToText(Protocol):
|
||||
name: str
|
||||
|
||||
@@ -150,11 +180,146 @@ class OpenRouterSTT:
|
||||
raise SttError("retries exhausted")
|
||||
|
||||
|
||||
SALUTE_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
|
||||
SALUTE_RECOGNIZE_URL = "https://smartspeech.sber.ru/rest/v1/speech:recognize"
|
||||
|
||||
|
||||
class _SaluteToken:
|
||||
"""OAuth access token, cached until a minute before expiry (Sber tokens live 30 min) and shared per process:
|
||||
a new token per request would burn the OAuth rate limit."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.value = ""
|
||||
self.expires = 0.0 # time.monotonic() deadline
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _salute_token(credentials: str, scope: str) -> _SaluteToken:
|
||||
return _SaluteToken()
|
||||
|
||||
|
||||
class SaluteSTT:
|
||||
name = "salute"
|
||||
|
||||
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
||||
self._credentials = settings.salute_speech_credentials.get_secret_value()
|
||||
self._scope = settings.salute_speech_scope
|
||||
self._timeout = settings.stt_timeout_s
|
||||
self._ca = settings.salute_speech_ca
|
||||
self._client = client
|
||||
self._token = _salute_token(self._credentials, self._scope)
|
||||
|
||||
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
|
||||
info = wav_info(audio)
|
||||
if info.audio_format != 1 or info.bits != 16 or info.channels != 1:
|
||||
raise InvalidAudio("SaluteSpeech needs 16-bit mono PCM")
|
||||
pcm = audio[info.data_offset : info.data_offset + info.data_size]
|
||||
headers = {"Content-Type": f"audio/x-pcm;bit=16;rate={info.sample_rate}"}
|
||||
data = await self._post(pcm, headers)
|
||||
if data.get("status", 200) != 200 or not isinstance(data.get("result"), list):
|
||||
raise SttError("bad response")
|
||||
text = " ".join(str(part).strip() for part in data["result"] if str(part).strip())
|
||||
if not text:
|
||||
raise SttNoSpeech("no speech")
|
||||
return text
|
||||
|
||||
async def _get_token(self, client: httpx.AsyncClient, *, force: bool = False) -> str:
|
||||
tok = self._token
|
||||
if not force and tok.value and time.monotonic() < tok.expires:
|
||||
return tok.value
|
||||
async with tok.lock:
|
||||
if not force and tok.value and time.monotonic() < tok.expires:
|
||||
return tok.value
|
||||
headers = {
|
||||
"Authorization": f"Basic {self._credentials}",
|
||||
"RqUID": str(uuid.uuid4()),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
resp = await client.post(SALUTE_OAUTH_URL, headers=headers, data={"scope": self._scope})
|
||||
except httpx.HTTPError as exc:
|
||||
raise SttError(f"oauth {type(exc).__name__}") from exc
|
||||
if resp.status_code != 200:
|
||||
log.error("stt salute oauth http %s", resp.status_code)
|
||||
raise SttError(f"oauth http {resp.status_code}")
|
||||
try:
|
||||
body = resp.json()
|
||||
value, expires_at_ms = body["access_token"], float(body["expires_at"])
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
raise SttError("oauth bad response") from exc
|
||||
# expires_at is wall-clock epoch ms from Sber; keep a monotonic deadline with a minute of margin.
|
||||
ttl = max(0.0, expires_at_ms / 1000 - time.time() - 60)
|
||||
tok.value, tok.expires = value, time.monotonic() + ttl
|
||||
log.info("stt salute oauth token refreshed")
|
||||
return value
|
||||
|
||||
def _verify(self) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context()
|
||||
if self._ca is not None:
|
||||
context.load_verify_locations(cafile=str(self._ca))
|
||||
return context
|
||||
|
||||
async def _post(self, pcm: bytes, headers: dict[str, str]) -> dict:
|
||||
if self._client is not None:
|
||||
return await self._recognize(self._client, pcm, headers)
|
||||
async with httpx.AsyncClient(timeout=self._timeout, verify=self._verify()) as client:
|
||||
return await self._recognize(client, pcm, headers)
|
||||
|
||||
async def _recognize(self, client: httpx.AsyncClient, pcm: bytes, headers: dict[str, str]) -> dict:
|
||||
refreshed = False
|
||||
for attempt in range(_ATTEMPTS):
|
||||
token = await self._get_token(client)
|
||||
try:
|
||||
resp = await client.post(
|
||||
SALUTE_RECOGNIZE_URL,
|
||||
content=pcm,
|
||||
params={"language": "ru-RU"},
|
||||
headers={**headers, "Authorization": f"Bearer {token}"},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
if attempt + 1 == _ATTEMPTS:
|
||||
raise SttError(type(exc).__name__) from exc
|
||||
await asyncio.sleep(0.5 * 2**attempt)
|
||||
continue
|
||||
if resp.status_code == 401 and not refreshed:
|
||||
# The cached token was revoked or expired early: fetch a new one once.
|
||||
refreshed = True
|
||||
await self._get_token(client, force=True)
|
||||
continue
|
||||
if resp.status_code in _RETRY_STATUSES and attempt + 1 < _ATTEMPTS:
|
||||
log.warning("stt salute http %s, retry %d", resp.status_code, attempt + 1)
|
||||
await asyncio.sleep(0.5 * 2**attempt)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
log.error("stt salute http %s: %s", resp.status_code, resp.text[:200])
|
||||
raise SttError(f"http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
raise SttError("bad json") from exc
|
||||
raise SttError("retries exhausted")
|
||||
|
||||
|
||||
def get_stt(settings: Settings) -> SpeechToText | None:
|
||||
"""The configured provider; `None` = voice input is off. `auto` = OpenRouter if a key is set, else mock."""
|
||||
"""The configured provider; `None` = voice input is off.
|
||||
|
||||
`auto` = SaluteSpeech if credentials are set. Otherwise only in the demo (synthetic data): OpenRouter if a key is
|
||||
set, else mock. Outside the demo `auto` never picks a foreign service or a fake transcript — voice stays off.
|
||||
"""
|
||||
mode = settings.stt_provider
|
||||
has_salute = bool(settings.salute_speech_credentials.get_secret_value())
|
||||
if mode == "auto":
|
||||
mode = "openrouter" if settings.openrouter_api_key else "mock"
|
||||
if has_salute:
|
||||
mode = "salute"
|
||||
elif not settings.demo_clock:
|
||||
return None
|
||||
else:
|
||||
mode = "openrouter" if settings.openrouter_api_key else "mock"
|
||||
if mode == "salute":
|
||||
return SaluteSTT(settings) if has_salute else None
|
||||
if mode == "openrouter":
|
||||
return OpenRouterSTT(settings) if settings.openrouter_api_key else None
|
||||
if mode == "mock":
|
||||
|
||||
Reference in New Issue
Block a user