171 lines
6.0 KiB
Python
171 lines
6.0 KiB
Python
"""UI-free client for Blurt's transcription providers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import logging
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable
|
|
|
|
import numpy as np
|
|
import requests
|
|
from scipy.io import wavfile
|
|
|
|
OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"
|
|
OPENROUTER_TRANSCRIPTIONS_URL = "https://openrouter.ai/api/v1/audio/transcriptions"
|
|
OPENROUTER_APP_URL = "https://git.felixm.de/felixm/blurt"
|
|
OPENROUTER_APP_TITLE = "Blurt"
|
|
SAMPLE_RATE = 16_000
|
|
|
|
TIMEOUT_CONNECT_SECONDS = 10.0
|
|
TIMEOUT_BASE_SECONDS = 30.0
|
|
TIMEOUT_PER_AUDIO_MINUTE = 30.0
|
|
TIMEOUT_MAX_SECONDS = 300.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TranscriptionConfig:
|
|
model: str
|
|
language: str
|
|
provider: str
|
|
api_key: str = field(repr=False)
|
|
|
|
|
|
class TranscriptionError(Exception):
|
|
"""The provider answered but returned no usable transcript."""
|
|
|
|
|
|
def transcription_timeout(seconds_of_audio: float) -> tuple[float, float]:
|
|
read = TIMEOUT_BASE_SECONDS + TIMEOUT_PER_AUDIO_MINUTE * (seconds_of_audio / 60.0)
|
|
return TIMEOUT_CONNECT_SECONDS, min(read, TIMEOUT_MAX_SECONDS)
|
|
|
|
|
|
class TranscriptionClient:
|
|
"""Encode audio and send requests using one HTTP session per worker thread.
|
|
|
|
``close`` must be called only after callers have stopped their worker pool.
|
|
"""
|
|
|
|
def __init__(self, session_factory: Callable[[], requests.Session] = requests.Session):
|
|
self._session_factory = session_factory
|
|
self._local = threading.local()
|
|
self._sessions: list[requests.Session] = []
|
|
self._sessions_lock = threading.Lock()
|
|
self._closed = False
|
|
|
|
def _session(self) -> requests.Session:
|
|
if self._closed:
|
|
raise RuntimeError("TranscriptionClient is closed")
|
|
session = getattr(self._local, "session", None)
|
|
if session is not None:
|
|
return session
|
|
with self._sessions_lock:
|
|
if self._closed:
|
|
raise RuntimeError("TranscriptionClient is closed")
|
|
session = self._session_factory()
|
|
self._sessions.append(session)
|
|
self._local.session = session
|
|
return session
|
|
|
|
def transcribe(self, audio: np.ndarray, config: TranscriptionConfig) -> str:
|
|
if audio.dtype != np.int16 or audio.ndim != 1:
|
|
raise ValueError("audio must be a one-dimensional int16 array at 16 kHz")
|
|
if config.provider not in {"openai", "openrouter"}:
|
|
raise ValueError(f"Unsupported transcription provider: {config.provider}")
|
|
|
|
buffer = io.BytesIO()
|
|
wavfile.write(buffer, SAMPLE_RATE, audio)
|
|
wav_bytes = buffer.getvalue()
|
|
seconds = len(audio) / SAMPLE_RATE
|
|
timeout = transcription_timeout(seconds)
|
|
logging.info(
|
|
"Starting transcription: provider=%s model=%s language=%s audio_seconds=%.1f "
|
|
"wav_bytes=%d connect_timeout=%.0fs read_timeout=%.0fs",
|
|
config.provider,
|
|
config.model,
|
|
config.language,
|
|
seconds,
|
|
len(wav_bytes),
|
|
timeout[0],
|
|
timeout[1],
|
|
)
|
|
|
|
if config.provider == "openrouter":
|
|
result = self._transcribe_openrouter(wav_bytes, config, timeout)
|
|
else:
|
|
result = self._transcribe_openai(wav_bytes, config, timeout)
|
|
|
|
text = result.get("text", "").lstrip()
|
|
logging.info("Transcription text length: %d", len(text))
|
|
if "usage" in result:
|
|
logging.info("Transcription usage: %s", result["usage"])
|
|
if not text:
|
|
error = result.get("error")
|
|
message = (
|
|
error.get("message", "Unknown error")
|
|
if isinstance(error, dict)
|
|
else "Unknown error"
|
|
)
|
|
logging.error("Transcription response did not include text")
|
|
raise TranscriptionError(f"Transcription failed: {message}")
|
|
return text
|
|
|
|
def _transcribe_openai(self, wav_bytes, config, timeout) -> dict:
|
|
data = {"model": config.model, "language": config.language}
|
|
if config.model == "gpt-4o-transcribe-diarize":
|
|
data.update(response_format="diarized_json", chunking_strategy="auto")
|
|
response = self._session().post(
|
|
OPENAI_TRANSCRIPTIONS_URL,
|
|
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
files={"file": ("blurt.wav", wav_bytes, "audio/wav")},
|
|
data=data,
|
|
timeout=timeout,
|
|
)
|
|
self._log_response(response)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
def _transcribe_openrouter(self, wav_bytes, config, timeout) -> dict:
|
|
response = self._session().post(
|
|
OPENROUTER_TRANSCRIPTIONS_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {config.api_key}",
|
|
"HTTP-Referer": OPENROUTER_APP_URL,
|
|
"X-OpenRouter-Title": OPENROUTER_APP_TITLE,
|
|
},
|
|
json={
|
|
"model": config.model,
|
|
"input_audio": {
|
|
"data": base64.b64encode(wav_bytes).decode("ascii"),
|
|
"format": "wav",
|
|
},
|
|
"language": config.language,
|
|
},
|
|
timeout=timeout,
|
|
)
|
|
self._log_response(response)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
@staticmethod
|
|
def _log_response(response: requests.Response) -> None:
|
|
logging.info(
|
|
"Transcription response: status=%s content_type=%s bytes=%d",
|
|
response.status_code,
|
|
response.headers.get("content-type", ""),
|
|
len(response.content),
|
|
)
|
|
if not response.ok:
|
|
logging.error("Transcription error response: %s", response.text[:1000])
|
|
|
|
def close(self) -> None:
|
|
with self._sessions_lock:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
sessions, self._sessions = self._sessions, []
|
|
for session in sessions:
|
|
session.close()
|