feat(tools-pve/asr-pipeline): multi-backend ASR web app with LiteLLM routing
This commit is contained in:
@@ -0,0 +1,844 @@
|
||||
#!/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
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import CFG
|
||||
from .log import log
|
||||
|
||||
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 RuntimeError(f"ASR ({provider}) response contains no segments")
|
||||
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 RuntimeError(f"ASR ({provider}) segments contained no text")
|
||||
|
||||
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 RuntimeError("ASR response contains no segments")
|
||||
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 _encode_multipart(
|
||||
fields: dict[str, str],
|
||||
file_field: str,
|
||||
file_path: Path,
|
||||
) -> tuple[bytes, str]:
|
||||
boundary = f"----ASRBoundary{uuid.uuid4().hex}"
|
||||
body = bytearray()
|
||||
for name, val in fields.items():
|
||||
body.extend(f"--{boundary}\r\n".encode())
|
||||
body.extend(
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
||||
body.extend(str(val).encode("utf-8"))
|
||||
body.extend(b"\r\n")
|
||||
mime = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
||||
body.extend(f"--{boundary}\r\n".encode())
|
||||
body.extend(
|
||||
f'Content-Disposition: form-data; name="{file_field}"; '
|
||||
f'filename="{file_path.name}"\r\n'.encode())
|
||||
body.extend(f"Content-Type: {mime}\r\n\r\n".encode())
|
||||
body.extend(file_path.read_bytes())
|
||||
body.extend(f"\r\n--{boundary}--\r\n".encode())
|
||||
return bytes(body), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def _http_multipart(
|
||||
url: str,
|
||||
*,
|
||||
fields: dict[str, str],
|
||||
file_field: str,
|
||||
file_path: Path,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: int = 900,
|
||||
) -> dict[str, Any]:
|
||||
data, content_type = _encode_multipart(fields, file_field, file_path)
|
||||
req_headers = {"Content-Type": content_type, **(headers or {})}
|
||||
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="ignore")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="ignore")[:300]
|
||||
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from 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,
|
||||
) -> 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"
|
||||
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")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="ignore")[:300]
|
||||
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from 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}"}
|
||||
|
||||
file_resp = _http_multipart(
|
||||
f"{base}/files",
|
||||
fields={},
|
||||
file_field="file",
|
||||
file_path=audio,
|
||||
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 "tokens" in status_resp or "segments" in status_resp:
|
||||
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}")
|
||||
Reference in New Issue
Block a user