Transcribe during recording with recoverable chunks and microphone activity icon
This commit is contained in:
+170
@@ -0,0 +1,170 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user