939 lines
34 KiB
Python
939 lines
34 KiB
Python
#!/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}")
|