fix(tools-pve/asr-pipeline): mount gitea ssh key for Obsidian vault sync and fix provider source_detail
This commit is contained in:
@@ -16,12 +16,18 @@ 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++ (分角色 + 声纹)",
|
||||
@@ -203,7 +209,9 @@ def format_diarized_transcript(
|
||||
matched_names: list[str] | None = None,
|
||||
) -> str:
|
||||
if not segments:
|
||||
raise RuntimeError(f"ASR ({provider}) response contains no segments")
|
||||
raise EmptyAudioError(
|
||||
f"ASR ({provider}) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)"
|
||||
)
|
||||
speaker_map: dict[str, str] = {}
|
||||
turns: list[list[str]] = []
|
||||
speakers_seen: set[str] = set()
|
||||
@@ -221,7 +229,9 @@ def format_diarized_transcript(
|
||||
else:
|
||||
turns.append([spk, text])
|
||||
if not turns:
|
||||
raise RuntimeError(f"ASR ({provider}) segments contained no text")
|
||||
raise EmptyAudioError(
|
||||
f"ASR ({provider}) 未识别到任何有效文字(音频可能为静音或底噪)"
|
||||
)
|
||||
|
||||
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
|
||||
if voiceprint_session or provider == "qwen3cam":
|
||||
@@ -240,7 +250,7 @@ def format_diarized_transcript(
|
||||
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")
|
||||
raise EmptyAudioError("ASR (qwen3cam) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)")
|
||||
vp = payload.get("voiceprint") or {}
|
||||
sid = str(vp.get("session_id") or "")
|
||||
matched = [
|
||||
@@ -431,16 +441,33 @@ def _http_multipart(
|
||||
file_path: Path,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: int = 900,
|
||||
retries: int = 3,
|
||||
) -> 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
|
||||
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="POST")
|
||||
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(2 * 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:
|
||||
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
|
||||
else:
|
||||
raise RuntimeError(f"ASR upload failed: {last_exc}")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
@@ -454,18 +481,34 @@ def _http_json(
|
||||
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"
|
||||
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
|
||||
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:
|
||||
@@ -575,14 +618,29 @@ def transcribe_soniox(
|
||||
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,
|
||||
)
|
||||
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}")
|
||||
@@ -622,7 +680,7 @@ def transcribe_soniox(
|
||||
else:
|
||||
raise RuntimeError("Soniox transcription timed out waiting for completion")
|
||||
|
||||
if "tokens" in status_resp or "segments" in status_resp:
|
||||
if status_resp.get("tokens") or status_resp.get("segments"):
|
||||
transcript_payload = status_resp
|
||||
else:
|
||||
transcript_payload = _http_json(
|
||||
|
||||
Reference in New Issue
Block a user