Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrpipe/providers.py
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

939 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Unified ASR provider abstraction for local GPU (qwen3cam) and online models
(Soniox, Gemini 3.5 Transcribe via LiteLLM, OpenAI Transcribe via LiteLLM, Grok).
"""
from __future__ import annotations
import json
import mimetypes
import os
import re
import shutil
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
import http.client
from pathlib import Path
from typing import Any
from .config import CFG
from .log import log
class EmptyAudioError(RuntimeError):
"""Raised when an audio file contains silence/noise or produces zero ASR speech tokens."""
pass
PROVIDERS: dict[str, dict[str, str]] = {
"qwen3cam": {
"label": "内网 Qwen3-ASR 1.7B + CAM++ (分角色 + 声纹)",
"default_model": "qwen3-asr-1.7b-cam++",
"mode": "lan-diarized-voiceprint",
},
"soniox": {
"label": "Soniox Async v5 (线上分角色 + 词库热词注入)",
"default_model": "stt-async-v5",
"mode": "cloud-diarized",
},
"gemini": {
"label": "Gemini 3.5 Transcribe (LiteLLM 统一路由)",
"default_model": "aistudio/gemini-3.5-transcribe",
"mode": "litellm-segmented",
},
"openai": {
"label": "OpenAI GPT-4o Transcribe Diarize (LiteLLM 统一路由)",
"default_model": "openai/gpt-4o-transcribe-diarize",
"mode": "litellm-diarized",
},
"grok": {
"label": "Grok Voice Transcribe 2.0 (线上分角色)",
"default_model": "grok-voice-transcribe-2.0",
"mode": "cloud-diarized",
},
}
@dataclass
class TranscribeResult:
provider: str
model: str
transcript_text: str
raw_response: Any
diarized: bool = True
voiceprint_session: str = ""
duration_seconds: int = 0
meta: dict[str, Any] = field(default_factory=dict)
def normalize_provider_name(name: str | None) -> str:
raw = (name or CFG.asr_provider or "qwen3cam").strip().lower()
if raw in ("paraformer", "qwen3", "qwen3-cam"):
return "qwen3cam"
if raw in ("gemini-3.5-transcribe", "aistudio/gemini-3.5-transcribe",
"vertex/gemini-3.5-transcribe"):
return "gemini"
if raw in ("openai-diarize", "gpt-4o-transcribe-diarize", "gpt-4o-transcribe",
"gpt-transcribe"):
return "openai"
if raw in ("xai", "grok-voice-transcribe-2.0"):
return "grok"
return raw if raw in PROVIDERS else "qwen3cam"
def provider_status() -> list[dict[str, Any]]:
"""Return readiness metadata for each ASR provider without exposing secrets."""
has_litellm = False
try:
has_litellm = bool(CFG.api_key())
except Exception:
has_litellm = False
return [
{
"id": "qwen3cam",
"label": PROVIDERS["qwen3cam"]["label"],
"model": PROVIDERS["qwen3cam"]["default_model"],
"ready": bool(CFG.diarize_url),
"endpoint": CFG.diarize_url,
"via": "LAN GPU (:10099)",
},
{
"id": "soniox",
"label": PROVIDERS["soniox"]["label"],
"model": CFG.soniox_model,
"ready": bool(CFG.soniox_api_key),
"endpoint": CFG.soniox_api_base,
"via": "SONIOX_API_KEY 直连",
},
{
"id": "gemini",
"label": PROVIDERS["gemini"]["label"],
"model": CFG.gemini_asr_model,
"ready": has_litellm,
"endpoint": CFG.transcriptions_endpoint,
"via": "LiteLLM 统一网关",
},
{
"id": "openai",
"label": PROVIDERS["openai"]["label"],
"model": CFG.openai_asr_model,
"ready": has_litellm,
"endpoint": CFG.transcriptions_endpoint,
"via": "LiteLLM 统一网关",
},
{
"id": "grok",
"label": PROVIDERS["grok"]["label"],
"model": CFG.grok_asr_model,
"ready": bool(CFG.grok_api_key),
"endpoint": CFG.grok_api_base + "/stt",
"via": "XAI_API_KEY 直连",
},
]
def collect_hotwords(limit: int = 120) -> list[str]:
"""Collect canonical entity names and glossary terms for ASR context biasing."""
terms: list[str] = []
seen: set[str] = set()
def add(word: str) -> None:
w = word.strip()
if not w or len(w) < 2 or len(w) > 40 or w in seen:
return
if re.match(r"^\d{4}-\d{2}|^_|speaker_", w):
return
seen.add(w)
terms.append(w)
try:
from . import lexicon
con = lexicon.connect()
for row in con.execute(
"SELECT canonical FROM entity ORDER BY synced_at DESC LIMIT ?",
(limit,)).fetchall():
add(str(row["canonical"]))
for row in con.execute(
"SELECT DISTINCT canonical FROM alias WHERE confirmed=1 LIMIT ?",
(limit,)).fetchall():
add(str(row["canonical"]))
con.close()
except Exception:
pass
if CFG.glossary.exists():
for raw in CFG.glossary.read_text(errors="ignore").splitlines():
s = raw.strip()
if not s.startswith(("-", "*")) or re.search(r"[→⇒]|->", s):
continue
s = re.split(r"\s[—–-]\s|[::]",
re.sub(r"^[-*+\d\.\)\s]+", "", s), maxsplit=1)[0].strip()
s = re.sub(r"\[\[|\]\]", "", s).strip()
add(s)
if len(terms) >= limit:
break
return terms[:limit]
def _normalize_speaker_label(raw: Any, mapping: dict[str, str]) -> str:
s = str(raw if raw is not None else "").strip()
if not s or s == "?":
return "speaker_0"
if re.match(r"^[\u4e00-\u9fff]{2,8}$", s):
return s
if re.match(r"^speaker_\d+$", s):
return s
m = re.match(r"^[sS](?:peaker)?[_ -]?(\d+)$", s)
if m:
return f"speaker_{m.group(1)}"
if s.isdigit():
# Soniox often uses 1-based speaker numbers ("1", "2"); normalize to speaker_0..N-1
if s not in mapping:
mapping[s] = f"speaker_{len(mapping)}"
return mapping[s]
if s not in mapping:
mapping[s] = f"speaker_{len(mapping)}"
return mapping[s]
def format_diarized_transcript(
segments: list[dict[str, Any]],
*,
provider: str,
model: str,
header_title: str,
voiceprint_session: str = "",
matched_names: list[str] | None = None,
) -> str:
if not segments:
raise EmptyAudioError(
f"ASR ({provider}) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)"
)
speaker_map: dict[str, str] = {}
turns: list[list[str]] = []
speakers_seen: set[str] = set()
for seg in segments:
raw_spk = seg.get("speaker")
text = str(seg.get("text") or "").strip()
if not text:
continue
spk = (_normalize_speaker_label(raw_spk, speaker_map)
if provider != "qwen3cam"
else str(raw_spk if raw_spk is not None else "speaker_0"))
speakers_seen.add(spk)
if turns and turns[-1][0] == spk:
turns[-1][1] += text
else:
turns.append([spk, text])
if not turns:
raise EmptyAudioError(
f"ASR ({provider}) 未识别到任何有效文字(音频可能为静音或底噪)"
)
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
if voiceprint_session or provider == "qwen3cam":
lines.append(
f"<!-- voiceprint_session: {voiceprint_session} "
f"clusters: {len(speakers_seen)} -->")
hdr = f"# 转写({header_title})"
if matched_names:
hdr += " | 声纹命名: " + ", ".join(matched_names)
lines.append(hdr)
lines.append("")
lines.extend(f"【{spk}】{txt}\n" for spk, txt in turns)
return "\n".join(lines).strip() + "\n"
def transcript_from_qwen3cam_response(payload: dict[str, Any]) -> str:
segments = payload.get("segments")
if not isinstance(segments, list) or not segments:
raise EmptyAudioError("ASR (qwen3cam) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [
f"{m.get('speaker_local')}={m.get('person_name')}"
for m in vp.get("matches", [])
if m.get("status") == "matched"
]
return format_diarized_transcript(
segments,
provider="qwen3cam",
model="qwen3-asr-1.7b-cam++",
header_title="Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹",
voiceprint_session=sid,
matched_names=matched,
)
def transcript_from_soniox_response(
payload: dict[str, Any],
*,
model: str = "stt-async-v5",
) -> str:
"""Convert Soniox token-level or segment-level diarized response into turns."""
tokens = payload.get("tokens")
segments: list[dict[str, Any]] = []
if isinstance(tokens, list) and tokens:
cur_speaker: Any = None
cur_pieces: list[str] = []
for tok in tokens:
txt = str(tok.get("text") or "")
if not txt:
continue
spk = tok.get("speaker")
if cur_speaker is not None and spk != cur_speaker and cur_pieces:
segments.append({"speaker": cur_speaker,
"text": "".join(cur_pieces).strip()})
cur_pieces = []
cur_speaker = spk if spk is not None else (cur_speaker or "1")
cur_pieces.append(txt)
if cur_pieces:
segments.append({"speaker": cur_speaker or "1",
"text": "".join(cur_pieces).strip()})
elif isinstance(payload.get("segments"), list) and payload["segments"]:
segments = payload["segments"]
elif isinstance(payload.get("text"), str) and payload["text"].strip():
segments = [{"speaker": "speaker_0", "text": payload["text"].strip()}]
return format_diarized_transcript(
segments,
provider="soniox",
model=model,
header_title=f"Soniox {model} 分角色",
)
def transcript_from_openai_response(
payload: dict[str, Any],
*,
model: str,
) -> str:
"""Convert OpenAI / LiteLLM transcription response (diarized or plain) into text."""
segments = payload.get("segments")
if isinstance(segments, list) and segments and any(
s.get("speaker") is not None for s in segments if isinstance(s, dict)):
return format_diarized_transcript(
segments,
provider="openai",
model=model,
header_title=f"OpenAI {model} 分角色",
)
text = str(payload.get("text") or payload.get("transcript") or "").strip()
if not text and isinstance(segments, list):
text = "".join(str(s.get("text") or "") for s in segments).strip()
if not text:
raise RuntimeError(f"OpenAI ASR ({model}) returned empty transcript")
if re.search(r"【[^】]+】", text):
return (
f"<!-- asr_provider: openai model: {model} -->\n"
f"# 转写(OpenAI {model})\n\n{text}\n"
)
return (
f"<!-- asr_provider: openai model: {model} -->\n"
f"--- 第1段 [00:00 - 结束] ---\n{text}\n"
)
def transcript_from_grok_response(
payload: dict[str, Any],
*,
model: str = "grok-voice-transcribe-2.0",
) -> str:
segments = payload.get("segments") or payload.get("utterances") or payload.get("turns")
if isinstance(segments, list) and segments:
norm_segs = []
for s in segments:
if not isinstance(s, dict):
continue
spk = s.get("speaker")
if spk is None:
spk = s.get("speaker_id")
if spk is None:
spk = "speaker_0"
txt = str(s.get("text") or s.get("transcript") or "").strip()
if txt:
norm_segs.append({"speaker": spk, "text": txt})
if norm_segs:
return format_diarized_transcript(
norm_segs,
provider="grok",
model=model,
header_title=f"Grok {model} 分角色",
)
words = payload.get("words")
if isinstance(words, list) and words and any(
isinstance(w, dict) and w.get("speaker") is not None for w in words):
norm_segs = []
cur_spk = None
buf: list[str] = []
for w in words:
if not isinstance(w, dict):
continue
txt = str(w.get("word") or w.get("text") or "")
spk = w.get("speaker")
if cur_spk is not None and spk != cur_spk and buf:
norm_segs.append({"speaker": cur_spk, "text": "".join(buf).strip()})
buf = []
cur_spk = spk if spk is not None else (cur_spk or "0")
buf.append(txt)
if buf:
norm_segs.append({"speaker": cur_spk or "0", "text": "".join(buf).strip()})
if norm_segs:
return format_diarized_transcript(
norm_segs,
provider="grok",
model=model,
header_title=f"Grok {model} 分角色",
)
text = str(payload.get("text") or payload.get("transcript") or "").strip()
if not text:
raise RuntimeError(f"Grok ASR ({model}) returned empty transcript")
return format_diarized_transcript(
[{"speaker": "speaker_0", "text": text}],
provider="grok",
model=model,
header_title=f"Grok {model}",
)
def probe_duration_seconds(audio: Path) -> int:
try:
out = subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", str(audio),
], text=True, timeout=30).strip()
return max(1, int(float(out) + 0.999)) if out else 0
except Exception:
return 0
def _multipart_framing(
fields: dict[str, str],
file_field: str,
file_path: Path,
) -> tuple[bytes, bytes, str, int]:
"""Build multipart head/tail without reading the audio into memory."""
boundary = f"----ASRBoundary{uuid.uuid4().hex}"
safe_name = file_path.name.replace('"', "").replace("\r", "").replace("\n", "") or "audio"
head = bytearray()
for name, val in fields.items():
head.extend(f"--{boundary}\r\n".encode())
head.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
head.extend(str(val).encode("utf-8"))
head.extend(b"\r\n")
mime = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
head.extend(f"--{boundary}\r\n".encode())
head.extend(
f'Content-Disposition: form-data; name="{file_field}"; '
f'filename="{safe_name}"\r\n'.encode())
head.extend(f"Content-Type: {mime}\r\n\r\n".encode())
tail = f"\r\n--{boundary}--\r\n".encode()
total = len(head) + file_path.stat().st_size + len(tail)
return bytes(head), tail, f"multipart/form-data; boundary={boundary}", total
def _open_http(url: str, timeout: int) -> tuple[http.client.HTTPConnection, str]:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise RuntimeError(f"unsupported ASR URL: {url}")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if parsed.scheme == "https":
conn: http.client.HTTPConnection = http.client.HTTPSConnection(
parsed.hostname, port, timeout=timeout)
else:
conn = http.client.HTTPConnection(parsed.hostname, port, timeout=timeout)
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
return conn, path
def _http_multipart(
url: str,
*,
fields: dict[str, str],
file_field: str,
file_path: Path,
headers: dict[str, str] | None = None,
timeout: int = 900,
retries: int = 3,
) -> dict[str, Any]:
"""POST audio as multipart, streaming the file in 1 MiB chunks."""
head, tail, content_type, total = _multipart_framing(fields, file_field, file_path)
last_exc: Exception | None = None
raw = ""
for attempt in range(1, max(1, retries) + 1):
conn, path = _open_http(url, timeout)
try:
conn.putrequest("POST", path)
conn.putheader("Content-Type", content_type)
conn.putheader("Content-Length", str(total))
for key, value in (headers or {}).items():
conn.putheader(key, value)
conn.endheaders()
conn.send(head)
with file_path.open("rb") as stream:
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
conn.send(chunk)
conn.send(tail)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", errors="ignore")
status = resp.status
if status in (502, 503, 504) and attempt < retries:
last_exc = RuntimeError(f"ASR HTTP {status}")
time.sleep(2 * attempt)
continue
if status >= 400:
raise RuntimeError(f"ASR HTTP {status}: {raw[:300]}")
break
except RuntimeError:
raise
except (http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
last_exc = exc
if attempt < retries:
log(f"multipart upload transient error (attempt {attempt}/{retries}): {exc}")
time.sleep(2 * attempt)
continue
raise RuntimeError(f"ASR upload connection error after {retries} attempts: {exc}") from exc
finally:
conn.close()
else:
raise RuntimeError(f"ASR upload failed: {last_exc}")
try:
return json.loads(raw)
except ValueError as exc:
raise RuntimeError(f"ASR returned invalid JSON: {raw[:200]}") from exc
def _http_json(
method: str,
url: str,
*,
payload: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: int = 120,
retries: int = 3,
) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req_headers = {"Accept": "application/json", **(headers or {})}
if data is not None:
req_headers["Content-Type"] = "application/json"
last_exc: Exception | None = None
for attempt in range(1, max(1, retries) + 1):
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
if exc.code in (502, 503, 504) and attempt < retries:
last_exc = exc
time.sleep(1.5 * attempt)
continue
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
last_exc = exc
if attempt < retries:
time.sleep(1.5 * attempt)
continue
raise RuntimeError(f"ASR HTTP request failed after {retries} attempts: {exc}") from exc
else:
raise RuntimeError(f"ASR HTTP request failed: {last_exc}")
if not raw.strip():
return {}
try:
return json.loads(raw)
except ValueError as exc:
raise RuntimeError(f"ASR returned invalid JSON: {raw[:200]}") from exc
def _compress_or_split_audio(
audio: Path,
tmpdir: Path,
*,
max_bytes: int = 23 * 1024 * 1024,
segment_seconds: int = 2400,
force_split: bool = False,
) -> list[tuple[Path, int, int]]:
"""Return [(segment_path, start_sec, end_sec), ...] suitable for upstream API."""
duration = probe_duration_seconds(audio)
if not force_split and audio.stat().st_size <= max_bytes and audio.suffix.lower() in {
".mp3", ".m4a", ".wav", ".ogg", ".webm", ".flac"}:
return [(audio, 0, duration)]
if not shutil.which("ffmpeg"):
if audio.stat().st_size <= max_bytes:
return [(audio, 0, duration)]
raise RuntimeError("audio exceeds size limit and ffmpeg is not installed")
if not force_split:
compressed = tmpdir / "compressed.mp3"
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(audio), "-ar", "16000", "-ac", "1", "-b:a", "48k",
str(compressed),
], check=True, timeout=600)
if compressed.exists() and compressed.stat().st_size <= max_bytes:
return [(compressed, 0, duration)]
total = max(duration, 1)
step = max(60, segment_seconds)
n_segs = (total + step - 1) // step
out: list[tuple[Path, int, int]] = []
for i in range(n_segs):
start = i * step
end = min(total, start + step)
seg = tmpdir / f"segment_{i + 1:03d}.mp3"
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-ss", str(start), "-t", str(step), "-i", str(audio),
"-ar", "16000", "-ac", "1", "-b:a", "64k", str(seg),
], check=True, timeout=600)
out.append((seg, start, end))
return out
def transcribe_qwen3cam(audio: Path, *, model: str = "") -> TranscribeResult:
result = subprocess.run([
"curl", "-sS", "--fail-with-body", "--max-time", "900",
"-F", f"file=@{audio}", "-F", "language=Chinese",
"-F", "save_voiceprint_session=true", CFG.diarize_url,
], capture_output=True, timeout=930)
if result.returncode:
detail = result.stderr.decode("utf-8", errors="ignore")[:240]
raise RuntimeError(f"qwen3cam ASR HTTP failed ({result.returncode}): {detail}")
try:
payload = json.loads(result.stdout)
except ValueError as exc:
raise RuntimeError("qwen3cam returned invalid JSON") from exc
text = transcript_from_qwen3cam_response(payload)
vp = payload.get("voiceprint") or {}
return TranscribeResult(
provider="qwen3cam",
model=model or PROVIDERS["qwen3cam"]["default_model"],
transcript_text=text,
raw_response=payload,
diarized=True,
voiceprint_session=str(vp.get("session_id") or ""),
)
def build_soniox_context(title: str = "", user_context: str = "") -> dict[str, Any]:
ctx: dict[str, Any] = {}
general = [{"key": "domain", "value": "Biotech / Investment / Corporate Meeting"}]
if title.strip():
general.append({"key": "title", "value": title.strip()[:120]})
ctx["general"] = general
if user_context.strip():
ctx["text"] = user_context.strip()[:2000]
terms = collect_hotwords(120)
if terms:
ctx["terms"] = terms
return ctx
def transcribe_soniox(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
poll_interval: float = 3.0,
max_wait_seconds: float = 900.0,
) -> TranscribeResult:
key = CFG.soniox_api_key
if not key:
raise RuntimeError("SONIOX_API_KEY is not configured")
base = CFG.soniox_api_base
chosen_model = model or CFG.soniox_model or "stt-async-v5"
headers = {"Authorization": f"Bearer {key}"}
with tempfile.TemporaryDirectory(prefix="asr-soniox-") as tmp:
upload_path = audio
if audio.exists() and audio.stat().st_size > 8 * 1024 * 1024 and shutil.which("ffmpeg"):
compressed = Path(tmp) / "soniox_upload.mp3"
try:
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(audio), "-ar", "16000", "-ac", "1", "-b:a", "48k",
str(compressed),
], check=True, timeout=600)
if compressed.exists() and 0 < compressed.stat().st_size < audio.stat().st_size:
upload_path = compressed
except Exception as exc:
log(f"soniox audio pre-compress skipped: {exc}")
file_resp = _http_multipart(
f"{base}/files",
fields={},
file_field="file",
file_path=upload_path,
headers=headers,
timeout=600,
)
file_id = str(file_resp.get("id") or "")
if not file_id:
raise RuntimeError(f"Soniox file upload returned no id: {file_resp}")
transcription_id = ""
try:
job_payload: dict[str, Any] = {
"model": chosen_model,
"file_id": file_id,
"enable_speaker_diarization": True,
"enable_language_identification": True,
"language_hints": ["zh", "en"],
"context": build_soniox_context(title, user_context),
}
job_resp = _http_json(
"POST", f"{base}/transcriptions",
payload=job_payload, headers=headers, timeout=60,
)
transcription_id = str(job_resp.get("id") or "")
if not transcription_id:
raise RuntimeError(f"Soniox transcription create returned no id: {job_resp}")
deadline = time.time() + max_wait_seconds
status_resp: dict[str, Any] = {}
while time.time() < deadline:
status_resp = _http_json(
"GET", f"{base}/transcriptions/{transcription_id}",
headers=headers, timeout=60,
)
st = str(status_resp.get("status") or "").lower()
if st == "completed":
break
if st in ("error", "failed"):
err = status_resp.get("error_message") or status_resp
raise RuntimeError(f"Soniox transcription failed: {err}")
time.sleep(poll_interval)
else:
raise RuntimeError("Soniox transcription timed out waiting for completion")
if status_resp.get("tokens") or status_resp.get("segments"):
transcript_payload = status_resp
else:
transcript_payload = _http_json(
"GET", f"{base}/transcriptions/{transcription_id}/transcript",
headers=headers, timeout=120,
)
text = transcript_from_soniox_response(
transcript_payload, model=chosen_model)
return TranscribeResult(
provider="soniox",
model=chosen_model,
transcript_text=text,
raw_response=transcript_payload,
diarized=True,
)
finally:
for cleanup_url in (
f"{base}/transcriptions/{transcription_id}" if transcription_id else "",
f"{base}/files/{file_id}" if file_id else "",
):
if cleanup_url:
try:
_http_json("DELETE", cleanup_url, headers=headers, timeout=15)
except Exception:
pass
def transcribe_gemini(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
chosen_model = model or CFG.gemini_asr_model or "aistudio/gemini-3.5-transcribe"
headers = {"Authorization": f"Bearer {CFG.api_key()}"}
with tempfile.TemporaryDirectory(prefix="asr-gemini-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp),
max_bytes=18 * 1024 * 1024,
segment_seconds=CFG.segment_seconds,
force_split=probe_duration_seconds(audio) > CFG.segment_seconds + 60,
)
responses: list[dict[str, Any]] = []
parts: list[str] = [f"<!-- asr_provider: gemini model: {chosen_model} -->"]
for idx, (seg_path, start, end) in enumerate(segments, 1):
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields={"model": chosen_model, "response_format": "json"},
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
responses.append(resp)
seg_text = str(resp.get("text") or resp.get("transcript") or "").strip()
if not seg_text:
raise RuntimeError(f"Gemini segment {idx} returned empty text")
parts.append(
f"--- 第{idx}段 [{start // 60:02d}:{start % 60:02d} - "
f"{end // 60:02d}:{end % 60:02d}] ---\n{seg_text}\n")
return TranscribeResult(
provider="gemini",
model=chosen_model,
transcript_text="\n\n".join(parts).strip() + "\n",
raw_response={"segments": responses},
diarized=False,
)
def transcribe_openai(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
chosen_model = model or CFG.openai_asr_model or "openai/gpt-4o-transcribe-diarize"
headers = {"Authorization": f"Bearer {CFG.api_key()}"}
is_diarize = "diarize" in chosen_model.lower()
with tempfile.TemporaryDirectory(prefix="asr-openai-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp),
max_bytes=23 * 1024 * 1024,
segment_seconds=2400,
force_split=False,
)
responses: list[dict[str, Any]] = []
all_diarized_segs: list[dict[str, Any]] = []
plain_parts: list[str] = [f"<!-- asr_provider: openai model: {chosen_model} -->"]
hotwords = collect_hotwords(80)
prompt = ""
if not is_diarize and hotwords:
prompt = "会议专有名词参考:" + "、".join(hotwords)[:800]
for idx, (seg_path, start, end) in enumerate(segments, 1):
fields: dict[str, str] = {
"model": chosen_model,
"language": "zh",
"response_format": "diarized_json" if is_diarize else "json",
}
if prompt:
fields["prompt"] = prompt
try:
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields=fields,
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
except RuntimeError:
if is_diarize:
# Fallback if gateway enforces response_format=json
fields["response_format"] = "json"
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields=fields,
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
else:
raise
responses.append(resp)
if isinstance(resp.get("segments"), list) and resp["segments"]:
all_diarized_segs.extend(resp["segments"])
else:
txt = str(resp.get("text") or resp.get("transcript") or "").strip()
if txt:
plain_parts.append(
f"--- 第{idx}段 [{start // 60:02d}:{start % 60:02d} - "
f"{end // 60:02d}:{end % 60:02d}] ---\n{txt}\n")
if all_diarized_segs:
text = transcript_from_openai_response(
{"segments": all_diarized_segs}, model=chosen_model)
diarized = True
elif len(responses) == 1:
text = transcript_from_openai_response(responses[0], model=chosen_model)
diarized = "【" in text
else:
if len(plain_parts) <= 1:
raise RuntimeError(f"OpenAI ASR ({chosen_model}) returned no text")
text = "\n\n".join(plain_parts).strip() + "\n"
diarized = False
return TranscribeResult(
provider="openai",
model=chosen_model,
transcript_text=text,
raw_response={"segments": responses} if len(responses) > 1 else responses[0],
diarized=diarized,
)
def transcribe_grok(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
key = CFG.grok_api_key
if not key:
raise RuntimeError("XAI_API_KEY is not configured")
chosen_model = model or CFG.grok_asr_model or "grok-voice-transcribe-2.0"
headers = {"Authorization": f"Bearer {key}"}
url = f"{CFG.grok_api_base}/stt"
with tempfile.TemporaryDirectory(prefix="asr-grok-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp), max_bytes=23 * 1024 * 1024, segment_seconds=2400)
all_texts: list[str] = []
raw_list: list[dict[str, Any]] = []
for seg_path, _start, _end in segments:
resp = _http_multipart(
url,
fields={"model": chosen_model, "diarize": "true", "language": "zh"},
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
raw_list.append(resp)
all_texts.append(transcript_from_grok_response(resp, model=chosen_model))
text = all_texts[0] if len(all_texts) == 1 else "\n\n".join(all_texts)
return TranscribeResult(
provider="grok",
model=chosen_model,
transcript_text=text,
raw_response=raw_list[0] if len(raw_list) == 1 else {"chunks": raw_list},
diarized=True,
)
def transcribe_audio(
audio: Path,
*,
provider: str | None = None,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
"""Dispatch transcription to the selected provider and return normalized result."""
prov = normalize_provider_name(provider)
log(f"transcribe start provider={prov} model={model or 'default'} file={audio.name}")
if prov == "qwen3cam":
return transcribe_qwen3cam(audio, model=model)
if prov == "soniox":
return transcribe_soniox(audio, model=model, title=title, user_context=user_context)
if prov == "gemini":
return transcribe_gemini(audio, model=model, title=title, user_context=user_context)
if prov == "openai":
return transcribe_openai(audio, model=model, title=title, user_context=user_context)
if prov == "grok":
return transcribe_grok(audio, model=model, title=title, user_context=user_context)
raise ValueError(f"unsupported ASR provider: {prov}")