feat(asr): 降级兜底——分离路由全挂时退到无分离路由,只交付逐字稿

回退链从「一条主路由 + 一串替补」改成两级:先把同能力(有说话人分离)的路由
试完,全挂才降级到不分离的路由。降级是**本次事实**而不是配置事实,写进第 2 步
产物(capability_degraded / capability_zh),第 3 步与第 5/6 步据此判为不可用。

- transcribe.go:Result 加 CapabilityDegraded,判据 chain[0] 能分离而实际这条
  不能;与 HasSpeakers 分开记(后者可能是「配了却没输出」那种异常)
- audio_handlers.go:闸门从「读路由声明的能力」改成「读稿子里实际有没有标签」
  (audioTranscriptSpeakerKeys),与第 3 步共用同一句 SpeakerKeysOf;本次没分离
  → 哨兵错误 errAudioSpeakersUnavailableThisRun,不再放行去写一份看不出残缺的纪要
- 闸门**不看** capability_degraded:改动前落库的老产物没有这个字段,看它就 fail-open
- 第 1 步「转写要求」提前把降级的后果说清;第 2 步产物带完整措辞与「⚠」日志
- 前端两处(SpecialistPanel.vue / audioSkill.js)判断顺序改为先读实际结果
  has_speakers、再退回声明 speakers —— 顺序反了会在降级那一次照旧显示第 3 步
- ai_config.json:补 3 条云端无分离路由与各自的回退链

验证:三处变异(产物 key 拼错、标记写死 true、闸门 fail-closed)都验过会红;
新增两个用例文件走真实 gin 路由 + 真实鉴权中间件,断言拦下来的**理由**而不只是
「拦下来了」;go test ./... 全绿、gofmt 干净、前端构建通过。

同期把本地 ASR 装成 systemd 常驻服务(deploy/install_asr_local.sh 七步全过,
开机自启,实测 26.7 分钟录音 → 3.1 分钟)。装的过程挖出两个只在服务化时才暴露的坑:

- E12 转写堵住事件循环 → 探活超时 → 本地被判不健康 → auto 静默退云端、音频出网,
  全程没有任何报错。修法 run_in_threadpool(deploy/asr/serve.py)
- E13 服务账号的 ~ 不可写,pyannote 写不了 ~/.pyannote/database.yml,每次转写 500。
  修法 asr.env 加 HOME=<cache 目录>(该目录在 unit 的 ReadWritePaths 里)

顺带收口一处交付缺口:服务源码原先只有 ~/asr-poc 一份,而 DELIVERY.md 的清理计划
要 rm -rf 它 —— 那会让唯一副本变成 /opt 下 root 所有、不在任何版本库里的文件。
现在 deploy/asr/ 是唯一事实源,装机脚本与文档同步改。

已知偏离 / 未做(记在案):
- 界面那句「本次没有说话人分离,后续步骤不可用」只验到后端接口层,没有造出真实
  降级场景渲染出来看过
- deploy/asr/ 的引入改变了装机来源:原型目录 $SRC_DIR 从此只提供 venv 与模型,
  服务代码一律从仓库取

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 23:36:00 +08:00
co-authored by Claude Code
parent bc30bfce28
commit b4c8ea38d1
19 changed files with 2002 additions and 151 deletions
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""本地 ASR 核心:faster-whisper large-v3 + pyannote 3.1 说话人分离。
产物刻意做成**后端那个形状**(internal/skills/packages/audio_transcribe/transcribe.go:164-173):
{"duration": 秒, "text": "全文", "segments": [{"speaker","start","end","text"}], "usage": {...}}
所以只要外面套一个 OpenAI 兼容的 HTTP 端点(serve.py),Go 侧一个字都不用改。
三个阶段的代价差很多(wav 几秒 / 转写几分钟 / 分离一两分钟),所以每阶段都往
out_dir 落缓存,重跑读缓存。调分离参数时不该让 large-v3 再跑一遍。
"""
from __future__ import annotations
import gc
import hashlib
import json
import os
import subprocess
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
MODELS = ROOT / "models"
SEG_DIR = MODELS / "pyannote/segmentation-3.0"
EMB_DIR = MODELS / "pyannote/wespeaker-voxceleb-resnet34-LM"
DIA_DIR = MODELS / "pyannote/speaker-diarization-3.1"
FW_DIR = MODELS / "Systran/faster-whisper-large-v3"
# faster-whisper / pyannote 都不是线程安全的,而且显存只有 8G,
# 并发跑两个 large-v3 必爆。整个进程串行化 —— 转写本来就是分钟级的长活,
# 并发几个请求也不会更快。
_LOCK = threading.Lock()
_FW_CACHE = {}
_DIA_CACHE = {}
def log(msg: str):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
# ── 阶段 1:转 wav ────────────────────────────────────────────────
def ensure_wav(src: Path, out_dir: Path) -> Path:
dst = out_dir / "audio16k.wav"
if dst.exists() and dst.stat().st_size > 0:
return dst
out_dir.mkdir(parents=True, exist_ok=True)
log(f"ffmpeg 转 16k 单声道:{src.name}")
subprocess.run(
# -vn 丢掉 mp3 里那个 mjpeg 封面流。不丢的话 pyannote 读音频时会被它绊到,
# 报出来的错跟「音频格式不对」一模一样,很难看出是封面。
["ffmpeg", "-y", "-v", "error", "-i", str(src), "-vn",
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(dst)],
check=True,
)
log(f"wav 就绪:{dst.stat().st_size/1048576:.1f} MB")
return dst
# ── 阶段 2:转写 ──────────────────────────────────────────────────
# 本机显存只有 8 GiB,而 llama-server(仓库 README 里 8080/8081 那两个常驻服务)
# 常年占着约 5 GiB,留给我们的实际不到 2.6 GiB —— large-v3 的 float16 权重
# 本身就要 3 GB,一加载就 `CUDA failed with error out of memory`。
# 所以默认用 int8_float16(权重压到一半左右,实测 2.5 GiB 空闲下装得下),
# 并用 ASR_COMPUTE_TYPE 留一个旋钮:显存宽裕时可以调回 float16 换精度。
_COMPUTE_LADDER = [
os.environ.get("ASR_COMPUTE_TYPE", "int8_float16"),
"int8", # 再小一档
]
def _fw_model():
if "m" not in _FW_CACHE:
import torch
from faster_whisper import WhisperModel
# 先请走 pyannote:它上一次跑完还占着约 2.6G,不清掉这里必 OOM。
# 放在阶梯循环**之前**,否则 int8_float16 / int8 两档会白撞两次。
_free_pipe()
last = None
for ct in dict.fromkeys(_COMPUTE_LADDER): # 去重且保序
free = torch.cuda.mem_get_info()[0] / 2**30
log(f"加载 faster-whisper large-v3(本地目录,不走网络,"
f"compute_type={ct},当前空闲显存 {free:.2f} GiB)")
try:
_FW_CACHE["m"] = WhisperModel(
str(FW_DIR), device="cuda", compute_type=ct, num_workers=1,
)
if ct != _COMPUTE_LADDER[0]:
log(f"注意:{_COMPUTE_LADDER[0]} 装不下,已退到 {ct}(精度略降)")
break
except Exception as e:
last = e
log(f"{ct} 加载失败:{type(e).__name__}: {e}")
else:
# 一档都装不下就别再试了 —— 报出显存实况,比抛一句 CUDA OOM 有用。
holders = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,used_memory,process_name",
"--format=csv,noheader"],
capture_output=True, text=True).stdout.strip()
raise RuntimeError(
f"faster-whisper large-v3 在本机显存里装不下(试过 "
f"{list(dict.fromkeys(_COMPUTE_LADDER))})。\n"
f"当前占显存的进程:\n{holders}\n"
f"处置:腾出显存,或把 ASR_COMPUTE_TYPE 调得更小。"
) from last
return _FW_CACHE["m"]
def _free_fw():
"""把 whisper 请出显存。
本机只剩两三百 MB 余量,whisper 和 pyannote 同时驻留必炸 ——
转写和分离是先后关系,没有并存的理由,所以加载另一个之前先放掉它。
"""
if _FW_CACHE.pop("m", None) is None:
return
gc.collect()
import torch
torch.cuda.empty_cache()
log(f"已释放 whisper 显存,现空闲 {torch.cuda.mem_get_info()[0]/2**30:.2f} GiB")
def _free_pipe():
"""把 pyannote 管线请出显存。
这是 `_free_fw` 的对称面,缺了它服务只能转写**一次**:
显存只有 8G,llama-server 常态占掉约 5G。第一次请求转写完会加载 pyannote
并把它挪上 CUDA,然后一直缓存在 `_DIA_CACHE` 里 —— 原先没有任何地方释放它。
于是第二次请求去加载 whisper 时,空闲显存只剩零点几 G,int8_float16 和 int8
两档接连 OOM,用户看到的是「第一次能转,之后每次都失败」。
原型机是跑一次看一次结果,暴露不出来;接成常驻服务后这是必现的。
"""
if _DIA_CACHE.pop("p", None) is None:
return
gc.collect()
import torch
torch.cuda.empty_cache()
log(f"已释放 pyannote 显存,现空闲 {torch.cuda.mem_get_info()[0]/2**30:.2f} GiB")
def transcribe(wav: Path, out_dir: Path, language: str = "zh") -> dict:
cache = out_dir / "whisper_raw.json"
if cache.exists() and cache.stat().st_size > 0:
return json.loads(cache.read_text())
model = _fw_model()
t0 = time.time()
segments, info = model.transcribe(
str(wav),
language=language or None,
beam_size=5,
vad_filter=True, # 长会议里静音很多,先切掉
vad_parameters={"min_silence_duration_ms": 500},
word_timestamps=True, # 按时间把说话人贴到词上要用
condition_on_previous_text=False, # 长音频上它会放大幻觉,必须关
)
log(f"音频 {info.duration/60:.1f} 分钟,语言 {info.language}"
f"(置信度 {info.language_probability:.2f})")
out = []
for i, seg in enumerate(segments):
out.append({
"start": seg.start, "end": seg.end, "text": seg.text.strip(),
"words": [{"word": w.word, "start": w.start, "end": w.end}
for w in (seg.words or [])
if w.start is not None and w.end is not None],
})
if (i + 1) % 100 == 0:
log(f" 已转写 {i+1} 段,进度 {seg.end/60:.1f}/{info.duration/60:.1f} 分钟")
took = time.time() - t0
log(f"转写完成:{len(out)} 段,{took/60:.1f} 分钟({info.duration/took:.1f}x 实时)")
result = {"duration": info.duration, "language": info.language, "segments": out}
cache.write_text(json.dumps(result, ensure_ascii=False))
return result
# ── 阶段 3:说话人分离 ────────────────────────────────────────────
def _ckpt(repo_dir: Path) -> dict:
"""把本地模型目录说成 pyannote 认得的形式。
pyannote 的 `Model.from_pretrained(x)` 只认**文件**:`os.path.isfile(x)` 不成立
就当成 HF repo id 去联网(core/model.py:588)。传目录会在 huggingface_hub 的
`validate_repo_id` 上炸成 HFValidationError,看着像「路径写错了」,其实是
「它压根没打算读目录」。传 dict 走的是 `Model.from_pretrained(**dict)` 那一支
(pipelines/utils/getter.py:81),checkpoint 指到 .bin 就通了。
不给 hparams_file:权重里自带 PL 的 hparams,而 repo 里那份 config.yaml 是
模型结构配置、没有 `task:` 段,塞进去只会换来 `Missing key setup`。
"""
return {"checkpoint": str(repo_dir / "pytorch_model.bin")}
def _allow_torch_load():
"""放行 pyannote 权重里那几个类,否则 torch>=2.6 一律拒绝加载。
torch 2.6 起 `torch.load` 的 `weights_only` 默认从 False 翻成 True,
pickle 里没在白名单上的全局符号直接拒载。pyannote 3.x 的 .bin 里存着 4 个
数据类(TorchVersion / Specifications / Problem / Resolution),于是
`Pipeline.from_pretrained` 会抛一大段「Weights only load failed」。
这里只放行这 4 个**数据类**,不碰 `weights_only=False` —— 后者等于把
反序列化变成任意代码执行。权重是我们自己下的、有 SHA256SUMS 对过,
但没必要为此把整扇门打开。
"""
import torch
from pyannote.audio.core.task import Problem, Resolution, Specifications
torch.serialization.add_safe_globals(
[torch.torch_version.TorchVersion, Specifications, Problem, Resolution])
def _pipeline():
"""加载 pyannote 管线(带缓存)。
读的是**加工件**里的 config.yaml,它由 `build_ready.sh` 从 raw 生成,
已把两个 HF repo id 改写成 raw 里的本地路径 —— 照原样读会去连
huggingface.co,而本机不通。除那两行外其余参数(含那两个阈值)逐字未改。
路线 B 是兜底:万一 pyannote 升级后不认这份 3.1 配置,就手工组装,阈值照抄
config.yaml。两条路都失败就原样抛出 —— 绝不吞掉错误退化成「没有说话人」,
那种失败从结果上跟「音频里真的只有一个人」长得一模一样,最难查。
"""
if "p" in _DIA_CACHE:
return _DIA_CACHE["p"]
# 与 _fw_model 里的 _free_pipe 对称:两个模型在 8G 卡上不能并存。
# 有了这一步,`run()` 里那句「转写完先放掉 whisper」就多余了 ——
# 谁都不用记住「先放谁」,那是加载器自己的事。
_free_fw()
_allow_torch_load()
import pyannote.audio as pa
from pyannote.audio import Model, Pipeline
log(f"pyannote {pa.__version__}")
try:
log("路线 A:读本地 config.yaml 加载管线")
# 必须给到 config.yaml 这个**文件**:给的目录会被当成 repo id(见 _ckpt)。
pipe = Pipeline.from_pretrained(str(DIA_DIR / "config.yaml"))
log("路线 A 成功")
except Exception as e:
log(f"路线 A 失败:{type(e).__name__}: {e}")
log("路线 B:手工组装(两个子模型直接吃本地 .bin)")
from pyannote.audio.pipelines import SpeakerDiarization
pipe = SpeakerDiarization(
segmentation=Model.from_pretrained(**_ckpt(SEG_DIR)),
embedding=Model.from_pretrained(**_ckpt(EMB_DIR)),
clustering="AgglomerativeClustering",
)
# 两个阈值照抄 speaker-diarization-3.1/config.yaml,不自己调。
pipe.instantiate({
"clustering": {"method": "centroid", "min_cluster_size": 12,
"threshold": 0.7045654963945799},
"segmentation": {"min_duration_off": 0.0},
})
log("路线 B 成功")
try:
import torch
pipe.to(torch.device("cuda"))
log("管线已挪到 GPU")
except Exception as e:
log(f"挪 GPU 失败,用 CPU:{e}")
_DIA_CACHE["p"] = pipe
return pipe
def diarize(wav: Path, out_dir: Path, num_speakers: int | None = None) -> dict:
salt = f".n{num_speakers}" if num_speakers else ""
cache = out_dir / f"diarization{salt}.json"
if cache.exists() and cache.stat().st_size > 0:
return json.loads(cache.read_text())
pipe = _pipeline()
t0 = time.time()
kw = {"num_speakers": num_speakers} if num_speakers else {}
log("开始说话人分离…")
ann = pipe(str(wav), **kw)
turns = [{"start": float(t.start), "end": float(t.end), "speaker": str(lab)}
for t, _, lab in ann.itertracks(yield_label=True)]
speakers = sorted({t["speaker"] for t in turns})
log(f"分离完成:{len(turns)} 个轮次,{len(speakers)} 个说话人 {speakers},"
f"{(time.time()-t0)/60:.1f} 分钟")
result = {"turns": turns, "speakers": speakers}
cache.write_text(json.dumps(result, ensure_ascii=False))
return result
# 短于这个字数的碎片不单独成段。理由见 _absorb_stray_groups。
_MIN_GROUP_CHARS = 4
def _absorb_stray_groups(groups: list) -> list:
"""把一闪而过的「说话人碎片」并回邻居。
说话人边界跟词边界对不齐时,会出现「记」/「住这个要」/「求」这种被切碎的段 ——
实测一份 26 分钟录音里 693 段中有一大批是这么来的,读起来像乱码。
这不是分离算错了,是**切点太细**:pyannote 的边界落在词中间,
而按词投票只能整词归属,于是边界两侧各留下半截。
处置:字数不到 _MIN_GROUP_CHARS 的组不单独成段,并进相邻的组
(优先并给前一组;段首没有前一组就并给后一组)。
并进去等于承认「这一两个词归属存疑」,比切出半截字更接近事实,
也比硬判给某一方诚实。
"""
if len(groups) <= 1:
return groups
def chars(g) -> int:
return len("".join(w["word"] for _, w in g).strip())
out: list = []
for g in groups:
if out and chars(g) < _MIN_GROUP_CHARS:
out[-1].extend(g) # 并给前一组
else:
out.append(list(g))
# 首组可能自己就是碎片(上面没有前一组可并),回头并给后来变成首组的那一组。
# 注意要先 pop 再取 out[0]:pop 之后原 out[1] 才在索引 0 上。
if len(out) > 1 and chars(out[0]) < _MIN_GROUP_CHARS:
first = out.pop(0)
out[0][:0] = first
return out
# ── 阶段 4:拼装 ──────────────────────────────────────────────────
def assign_speakers(transcript: dict, diar: dict) -> dict:
"""把说话人贴到 whisper 段上。
按**词**投票而不是整段取最大重叠:中文会议里一个 whisper 段跨两个人很常见,
按整段会把少数那个人的话一起判给多数,云端 diarize 模型不会犯这个错,
我们自己拼装就得自己处理掉。
"""
turns = diar["turns"]
def spk_of(a: float, b: float) -> str:
best, best_ov = None, 0.0
for t in turns:
ov = min(b, t["end"]) - max(a, t["start"])
if ov > best_ov:
best, best_ov = t, ov
if best is not None:
return best["speaker"]
if not turns:
return ""
# 词落在两轮之间的缝里:归给最近的一轮,不留空。
# 留空会让这一段没有 speaker,前端会当成「这次没做分离」。
mid = (a + b) / 2
return min(turns, key=lambda t: min(abs(mid - t["start"]),
abs(mid - t["end"])))["speaker"]
segments = []
for seg in transcript["segments"]:
words = seg.get("words") or []
if not words:
segments.append({"speaker": spk_of(seg["start"], seg["end"]),
"start": seg["start"], "end": seg["end"],
"text": seg["text"]})
continue
labelled = [(spk_of(w["start"], w["end"]), w) for w in words]
groups, cur = [], [labelled[0]]
for spk, w in labelled[1:]:
if spk == cur[0][0]:
cur.append((spk, w))
else:
groups.append(cur)
cur = [(spk, w)]
groups.append(cur)
groups = _absorb_stray_groups(groups)
for grp in groups:
text = "".join(w["word"] for _, w in grp).strip()
if not text:
continue
segments.append({"speaker": grp[0][0],
"start": round(grp[0][1]["start"], 3),
"end": round(grp[-1][1]["end"], 3),
"text": text})
text = "\n".join(f"说话人{s['speaker']}:{s['text']}" for s in segments)
speakers = sorted({s["speaker"] for s in segments if s["speaker"]},
key=lambda x: int(x) if x.isdigit() else 0)
return {
"duration": transcript["duration"],
"language": transcript.get("language", ""),
"text": text,
"segments": segments,
"speakers": speakers,
}
def run(audio: Path, out_dir: Path, language: str = "zh",
num_speakers: int | None = None, stages: str = "wav,transcribe,diarize,merge") -> dict:
"""完整跑一遍,返回后端要的形状。out_dir 里落各阶段缓存。"""
out_dir.mkdir(parents=True, exist_ok=True)
want = {s.strip() for s in stages.split(",") if s.strip()}
t0 = time.time()
with _LOCK:
wav = out_dir / "audio16k.wav"
if "wav" in want:
wav = ensure_wav(audio, out_dir)
transcript = transcribe(wav, out_dir, language) if "transcribe" in want else \
json.loads((out_dir / "whisper_raw.json").read_text())
# 这里曾经有一句 `if "diarize" in want: _free_fw()`。
# 现已移进 `_pipeline()`:腾显存是加载方的事,不是调用方要记住的规矩 ——
# 放在调用方时,只要有一条路径绕开它就会 OOM(`_fw_model` 那侧原来就是这样)。
diar = diarize(wav, out_dir, num_speakers) if "diarize" in want else \
json.loads((out_dir / "diarization.json").read_text())
merged = assign_speakers(transcript, diar)
merged["model"] = "faster-whisper-large-v3 + pyannote/speaker-diarization-3.1(本地)"
merged["elapsed_seconds"] = round(time.time() - t0, 1)
(out_dir / "asr_result.json").write_text(
json.dumps(merged, ensure_ascii=False, indent=1))
(out_dir / "transcript.txt").write_text(merged["text"])
log(f"合并完成:{len(merged['segments'])} 段,{len(merged['speakers'])} 个说话人,"
f"正文 {len(merged['text'])} 字,总耗时 {merged['elapsed_seconds']/60:.1f} 分钟")
return merged
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""把本地 ASR 包成一个 OpenAI 兼容的 /v1/audio/transcriptions 端点。
存在的意义:后端 internal/skills/packages/audio_transcribe/transcribe.go 是按
OpenAI 兼容协议写的(multipart 发 model/language/file,读回
{duration,text,segments:[{speaker,start,end,text}]})。只要本地服务说同一套协议,
**Go 侧一行都不用改**,改的是 config/ai_config.json 里那条 audio_routes 的 base_url。
用法:
./venv/bin/python serve.py --port 8090
然后 config/ai_config.json 加:
"audio_route_local_whisperx": {
"provider": "local_asr", "base_url": "http://127.0.0.1:8090/v1",
"endpoint": "/audio/transcriptions", "model": "large-v3",
"supports_speakers": true, "timeout_seconds": 3600
}
⚠️ 光改 base_url 还不够:transcribe.go:85 在 APIKey 为空时直接报错,而 key 只从
ProviderSecretKey[provider] 映射来。所以还得往 internal/config/json_loader.go 的
ProviderSecretKey 加一行 "local_asr": "LOCAL_ASR_API_KEY",并在 ai_secrets.json
里塞个占位值(本地服务不校验它,但 Go 侧要求非空)。
curl -s -X POST http://127.0.0.1:8090/v1/audio/transcriptions \
-H "Authorization: Bearer local" -F file=@x.mp3 -F model=large-v3 -F language=zh
"""
import argparse
import hashlib
import json
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import asr_core # noqa: E402
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
from starlette.concurrency import run_in_threadpool # noqa: E402
import uvicorn # noqa: E402
CACHE = asr_core.ROOT / "cache"
CACHE.mkdir(exist_ok=True)
app = FastAPI(title="本地 ASR(OpenAI 兼容)", version="0.1.0")
STARTED_AT = time.time()
@app.get("/health")
def health():
return {
"status": "ok",
"uptime_seconds": round(time.time() - STARTED_AT, 1),
"model": "faster-whisper-large-v3 + pyannote/speaker-diarization-3.1",
"models_dir": str(asr_core.MODELS),
}
@app.get("/v1/models")
def models():
# 后端不查这个端点,但 OpenAI 兼容服务一般都有,留着方便人工确认。
return {"object": "list",
"data": [{"id": "large-v3", "object": "model", "owned_by": "local"}]}
@app.post("/v1/audio/transcriptions")
async def transcriptions(
file: UploadFile = File(...),
model: str = Form("large-v3"),
language: str = Form(""),
response_format: str = Form("json"),
num_speakers: str = Form(""),
authorization: str = Header(default=""),
):
# 后端会带 Authorization: Bearer <key>。这里只要求「有」,不校验具体值 ——
# 本地服务绑 127.0.0.1,本来就不对外。
if not authorization.strip():
raise HTTPException(401, "缺少 Authorization 头")
raw = await file.read()
if not raw:
raise HTTPException(400, "上传的音频是空的")
digest = hashlib.sha256(raw).hexdigest()
work = CACHE / digest
cached = work / "asr_result.json"
# 按内容哈希缓存:同一份音频重发(比如后端重试、或我复跑)不该再烧一次 GPU。
if cached.exists() and cached.stat().st_size > 0:
asr_core.log(f"命中缓存 {digest[:12]},直接返回")
return JSONResponse(_to_openai(json.loads(cached.read_text()), model))
work.mkdir(parents=True, exist_ok=True)
suffix = Path(file.filename or "audio.mp3").suffix or ".mp3"
src = work / f"source{suffix}"
if not src.exists():
src.write_bytes(raw)
n = int(num_speakers) if num_speakers.strip().isdigit() else None
asr_core.log(f"新请求 {digest[:12]}:{file.filename} "
f"({len(raw)/1048576:.1f} MB, language={language or 'auto'})")
try:
# 丢到工作线程去跑,**不能在事件循环里直接调**。
#
# asr_core.run 是分钟级的阻塞调用(26 分钟音频实测 3 分钟),
# 而 uvicorn 只有一个事件循环:直接调的话,这段时间里 /v1/models
# 一个字都回不了。平台的健康探测正是打 /v1/models 的,于是
# 「正在转写」被读成「本地服务挂了」→ audio_route_auto 解到云端路由 →
# 下一个任务的音频就出公网了。整个过程没有任何报错。
#
# 转写本身仍然是串行的(asr_core 里的 _LOCK),这里只是把「等 GPU」
# 从事件循环里挪出去,让探活和排队中的请求还能被受理。
result = await run_in_threadpool(asr_core.run, src, work, language or "zh", n)
except Exception as e:
# 不吞异常:ASR 失败必须让调用方看见 5xx,不能返回一个空稿当成功。
# 后端那边对「空文本」也是按失败处理的(transcribe.go:203)。
asr_core.log(f"失败:{type(e).__name__}: {e}")
raise HTTPException(500, f"本地 ASR 失败:{type(e).__name__}: {e}")
return JSONResponse(_to_openai(result, model))
def _to_openai(result: dict, model: str) -> dict:
"""转成 OpenAI 兼容响应。
`usage` 里带 duration 是照着 SiliconFlow 的 diarize 模型来的 —— 后端
ai/credits.go 按这个记用量。本地跑不要钱,但字段留着,免得下游解析时
因为缺字段而报错。
"""
return {
"text": result["text"],
"duration": result["duration"],
"language": result.get("language", ""),
"model": model,
"segments": result["segments"],
"usage": {"type": "duration", "seconds": result["duration"]},
# 非标准字段,方便人工核对;后端只读上面那几个,多给的会被忽略。
"_local": {"speakers": result.get("speakers", []),
"elapsed_seconds": result.get("elapsed_seconds")},
}
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8090)
args = ap.parse_args()
asr_core.log(f"本地 ASR 服务启动于 http://{args.host}:{args.port}")
asr_core.log(f"模型目录 {asr_core.MODELS}")
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")