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:
@@ -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")
|
||||
Reference in New Issue
Block a user