快速启动
# 默认 http://127.0.0.1:8848 ./run.sh # 自定义端口 ASR_PORT=9000 ./run.sh
首次请求才加载模型(约 1–3 秒),之后常驻内存。技术栈一览:
| 层 | 选型 | 要点 |
|---|---|---|
| 后端 | FastAPI | 模型常驻内存,MPS 上 fp16 推理 |
| 前端 | 单文件 HTML | 零外部依赖:无 CDN、无字体请求、断网可用 |
| 引擎 | qwen-asr(Apache-2.0) | 识别 0.6B / 1.7B + 对齐 Qwen3-ForcedAligner-0.6B |
| 实测机器 | M2 Max 64GB | macOS 26.5.2 / torch 2.15.0 |
部署:三个坑
| 坑 | 现象 | 解法 |
|---|---|---|
| PyPI 直连超时 | files.pythonhosted.org 连接 timeout,uv 重试 3 次失败 | 换 --index-url https://mirrors.aliyun.com/pypi/simple/ |
| HuggingFace 不通 | huggingface.co 8 秒无响应 | 走 ModelScope:modelscope download --model Qwen/Qwen3-ASR-1.7B --local_dir ./models/Qwen3-ASR-1.7B,再从本地目录 from_pretrained |
官方示例只写 device_map="cuda:0" | 文档未提 Mac 支持,容易误判为不可用 | 核心推理代码没有 CUDA 硬编码,只读 self.model.device/dtype,device_map="mps" 直接可用 |
模型下载体积:0.6B 1.8GB · 1.7B 4.4GB · 对齐器 1.7GB,总计约 8GB。
字幕(SRT / VTT)
文字只有文本没有时间,做不了字幕。这需要另一个模型:Qwen3-ForcedAligner-0.6B——把已知文本对齐到声学帧上,输出逐字时间戳。
modelscope download --model Qwen/Qwen3-ForcedAligner-0.6B \ --local_dir ./aligner/Qwen3-ForcedAligner-0.6B
对齐器单独放 aligner/,不要塞进 models/。 两个模型的 config.json 里 architectures 和 model_type 完全一样(都是 Qwen3ASRForConditionalGeneration),而 ModelManager.resolve() 默认取「名字排序最大的目录」——Qwen3-ForcedAligner 恰好排在最前,会被当成默认 ASR 模型加载,然后每次识别都失败。
现在靠两道保险拦住:目录名含 forcedaligner 就跳过,以及读 config.json 看 timestamp_token_id 是否非空(ASR 模型这个字段是 None,对齐器是 151705)。
对齐粒度与切条策略
对齐器把中文逐字、英文整词作为 token(日文走 nagisa、韩文走 soynlp)。不能直接拼 token 文本,否则英文会粘成 TheQwen3ASRmodel。做法是把 token 按「消费等量保留字符」回推原文坐标,切条时直接切原文字符串,空格和标点原样保留。
切条规则(build_cues):
| 条件 | 行为 |
|---|---|
强标点 。!?;… | 必断 |
软标点 ,、: 且已 ≥10 字 | 断 |
累计 ≥20 字(max_chars) | 强制断 |
单条 ≥6 秒(max_sec) | 强制断 |
切完还要过一道 fix_timing:起点不早于上一条终点、时长不足 0.8s 补到 0.8s。播放器遇到重叠或零时长的 SRT 会静默吞掉整条字幕。
实测
| 场景 | 音频 | 字位 | 字幕条 | 对齐耗时 | RTF |
|---|---|---|---|---|---|
| 短片段 | 25.0s | 94 | 8 | 0.21–0.36s | ~0.01 |
| 长音频 | 141.2s | 552 | 43 | 1.11s | 0.008 |
141 秒那组:首条 0.48s、末条 140.56s(音频 141.18s),零重叠,最大空隙 1.04s,单条最长 20 字。模型首帧加载 2.8s,之后常驻。
对齐器输出的是时间戳分类 logits(classify_num=5000)而不是全词表,所以长音频不会爆显存——这是它比「用 ASR 模型自己猜时间」更快更省的原因。
前端的三个状态
结果区多了「字幕」按钮:点了才对齐(懒加载,不用字幕的人不付这份内存)。面板给出条数 / 耗时 / 字位数和前 6 条预览,两个按钮直接下载 .srt 与 .vtt,文件名取自音频名。
- 录音停止后音频留在内存(
int16),导出时补 44 字节 WAV 头直接送后端——绕开 webm 有损压缩那条坑(见下文精度损耗)。 - 改过文本旧字幕立即失效,因为时间轴对应的是旧文本。
- 从历史回看必须同时清掉内存里的音频并禁用字幕按钮。这条是真 bug:最初
lastFile没清,给历史记录生成字幕会拿上一份音频,时间轴全错却不报错。
实时识别怎么做的
Qwen3-ASR 官方的流式推理只走 vLLM 后端,Mac 没有 CUDA,跑不了。所以这里用不上 qwen-asr-demo-streaming,而是在前端做能量 VAD 按句切分,逐句送后端识别。
几个关键参数(web/index.html 的 VAD 常量):
| 参数 | 值 | 作用 |
|---|---|---|
SILENCE_CUT_MS | 450 | 静音超过这么久就断句 |
MAX_SEG_MS | 8000 | 单句最长,防止一直说不停憋不出字 |
PREVIEW_MIN_VOICE | 600 | 有 0.6 秒语音就先给临时结果 |
PREVIEW_EVERY_MS | 1200 | 当前句的临时预览刷新间隔 |
实测首字延迟 759ms–2.0s(取决于说话节奏),一句 8 秒的话落定约 0.8s。
为什么要「整段精修」
分段识别丢了上下文,精度必然低于整段。这不是调参能解决的——同一段音频按不同长度切分喂给模型,结果如下:
| 音频长度 | 参考「数据合在一起」 | 参考「总出力」 |
|---|---|---|
| 8s | 十聚合 ✗ | 总处理 ✗ |
| 12s | 十聚合 ✗ | 总处理 ✗ |
| 16s | 十句合 ✗ | 总处理 ✗ |
| 20s | 十句合 ✗ | 总处理 ✗ |
| 25s(完整) | 数据合在一起 ✓ | 总出力 ✓ |
只有拿到完整音频才收敛到正确结果,16–20 秒反而退化成更离谱的「十句合」。所以录完必须让整段音频再过一遍模型:停止后若录音超过 3 秒,自动用完整音频重新识别并替换文本(refineWhole(auto=true)),也可以在结果区手动点「整段精修」重跑。
context 参数救不了分段精度:把前文作为 context 传给模型,总处理 依然是 总处理,只是标点变好。缺的是声学上下文,不是文本上下文。
精度:三种损耗,都是可复现的
除了上面说的「音频长度」,还有两条更隐蔽的损失,都已实测确认:
| 损耗来源 | 实验 | 结果 |
|---|---|---|
| 有损压缩 | 同一段 25s 音频转 webm/opus 96kbps | 稳定把「总出力」识别成「总处理」,wav 版每次都对 |
| 重采样往返 | 16k → 44.1k → 16k(ffmpeg 转两趟) | 一次往返就把「总出力」变成「总处理」 |
| 硬截断 | ffmpeg -t 8 直接切在语音中间 | 开头「数据合」退化成「十聚合」;VAD 切在静音处则不会 |
三条加起来解释了为什么「浏览器录音」的结果总比「上传原始 wav」差一档。工程上的选择是:
- 不做浏览器端降采样:试过
new AudioContext({sampleRate: 16000})让浏览器直接输出 16k,结果反而更差(Chrome 内部重采样质量不如 soxr),已回退,让后端soxr统一降到 16k。 - VAD 切在静音处:宁可多留 450ms 尾部静音,也不硬切。
- 整段精修兜底:最终稿以整段识别为准。
模型选型
真实人声 25s 对比:
| 参考文本 | 0.6B | 1.7B |
|---|---|---|
| 数据合在一起 | 时距和在一起 ❌ | ✅ |
| 总出力 | 总处理 ❌ | ✅ |
| 虚拟电厂 | 虚拟电场 ❌ | ✅ |
1.7B 全面胜出,默认用它。但要注意用 TTS 合成音频测试会得出相反结论:macOS say 生成的音频上 0.6B 全对,1.7B 把「通义千问」读成「通亿千问」(fp32 复测一致,排除精度因素)。合成语音的声学伪影会骗人,选型必须用真实人声。
性能
| 场景 | 音频时长 | 耗时 | RTF |
|---|---|---|---|
| wav 整段 | 25.0s | 2.2s | 0.089 |
| m4a 长音频 | 141.2s | 12.0s | 0.085 |
| 实时单句(2s) | 2.0s | 0.61s | 0.305 |
fp16 相对 fp32 提速 2.5 倍(8.64s → 3.52s),文本逐字不变。短片段 RTF 偏高是固定开销占比大,绝对值仍在 1 秒内。
架构
图 1 从单文件前端到两个本地模型的完整链路:四个 API 入口汇入 FastAPI,两个管理器共享同一把推理锁。
五个关键实现决策
- 推理放线程池。
transcribe是 CPU/MPS 密集的同步调用,直接写在async def里会阻塞事件循环,导致页面 15 秒一次的健康轮询全部超时。用run_in_threadpool包一层。 - 全局推理锁。Apple MPS 不是线程安全的,并发请求必须串行。前端用 Promise 链排队,后端再加一道锁;对齐器和 ASR 共用同一把锁,两个模型交替推理也不会互相打断。
- PCM 接口不走 ffmpeg。实时链路每 1–2 秒发一次请求,每次都起 ffmpeg 子进程不划算。
/api/asr/pcm直接收 raw int16 LE,非 16k 才用soxr重采样。 max_new_tokens按时长动态给(duration × 12 + 128,上限 8192),避免长音频被截断。- 对齐器懒加载。只有点「字幕」才加载,用不上字幕的人省 1.7GB 内存。
前端与验证
界面按桌面应用组织,不是网页文档流:外壳固定为 标题栏 / 工作区 / 状态栏,工作区内再分 会话栏 · 录音台 · 转写面 · 检查器,整页高度锁死 100dvh,滚动只发生在转写正文和列表里。
┌──────────────────────────────────────────────────────────────┐ │ ▮ Qwen3·ASR LOCAL │ ● 已就绪 ⌘K ◐ │ 标题栏 ├──────────┬───────────────────────────────────────────────────┤ │ 会话 4 │ ⬤ 00:12.4 ▁▂▅▇▅▃▂▁▂▃▅ 已识别 3句 ⌗导入音频 │ 录音台 │ ─────── │ ───────────────────────────────────────────── │ │ 数据合… │ [实时识别] [字幕] [语言][模型] │ │ 11/11 … ├─────────────────────────────────────┬─────────────┤ │ │ 转写 [精修][字幕] 复制 ↓ ⌫ │ 源音频 │ │ │ │ 指标 │ 检查器 │ │ 数据合在一起,右上角是园区实时指标… │ 字幕(时间轴)│ ├──────────┴─────────────────────────────────────┴─────────────┤ │ 设备 mps │ 精度 fp16 │ 内存 1.4M·1.1G 音频 25.0s RTF .088 │ 状态栏 └──────────────────────────────────────────────────────────────┘
- 录音台是横置仪表:左边录制键(主操作位)→ 计时 → 波形 → 分句计数,导入收在行的最右端、贴窗口右边缘;DOM 顺序与视觉一致,Tab 键也最后才走到它
- 波形是滚动电平带:待机时是一条静止的起伏剪影(不画假数据,也不留空白),录音时变成红色电平带、左端渐隐、右端一条「当前时刻」亮线
- 转写面是文档:17px / 1.88 行高、72ch 栏宽、居中排版,点空白即可编辑;实时预览像一条钉在正文底部的字幕条
- 检查器(右侧 306px)放源音频、指标、字幕时间轴;空态用骨架条占位
- 明暗双主题(默认浅色,因为这是以读长文本为主的应用),色值全部走 CSS 变量,切换即时生效
- 窄屏(<900px)会话栏变抽屉、检查器折叠成底部条带;<720px 检查器默认收起,只有真的生成了字幕才浮出来
设计约束:4px 栅格、发丝分隔(不堆卡片阴影)、一个信号色(录制红 #c8402c)加一个状态色(确认绿 #0d7a52),数字一律等宽 + tabular-nums 防跳动。所有图标是内联 <symbol>,字体用系统栈,仍然零外部请求。
图 2 项目真实 Chrome 截图(点击可放大)。取自 shots/ 验证产物。
功能面
- 点击录音(或按空格)→ 实时波形 + 计时 → 边说边出字
- 拖拽文件、点选文件、
⌘V粘贴音频都可触发整段识别 - 结果可直接编辑,支持复制、下载 txt、清空
- 「字幕」是一个开关,不是一次操作:选中后每确认一句就追加一条带时间码的字幕,边录边出;定稿前还会先显示一条临时行(弱化样式 + 光标),中间不留空档
- 录完想要逐字精确的时间码,再点工具栏「精确对齐」用 forced aligner 覆盖一版
- 历史记录存 localStorage(最近 20 条),点击可回填
- 语言可选自动检测或强制指定;模型可在线切换(录音中锁定)
- 状态栏常显「内存:页面 JS 堆 · 服务常驻」,
⌘K→「释放内存」可把加速器缓存池还给系统
字幕开关为什么是「持续输出」
实时识别 按句切分,句子边界一到就把这一段送去识别。字幕开关接在这一步上:段落被切出来的那一刻记下 absSamples / sampleRate,等识别结果回来直接用它当时间码,所以字幕和录音是同一条时间轴,结果回得晚一点时间也不会漂。
正式条目要等 VAD 判定句子结束(350ms 有声 + 450ms 静音)再回传,中间必然有几秒空档,所以还有一条临时行:预览识别(每 1.2s 一次)一回来就先把那句话铺上去,定稿后自然被正式条目替换。实测首条临时字幕在 907ms 出现,首条正式字幕在 9.4s。
新条的 start 会被夹到上一条的 end,长度不足 0.6s 补齐——播放器遇到重叠或倒序 SRT 会直接把字吞掉。开着字幕时不做自动整段精修:精修会换掉文本,边录边出的时间轴立刻对不上。
验证:五套真实 Chrome / HTTP 断言
全部通过(30 + 34 + 37 + 24 + 5 = 130 项):
# 30 项:页面结构 / 导入组件靠右 / 零依赖 / 上传识别 / 375px 溢出 / WCAG AA 对比度 NODE_PATH=.../node_modules .../node verify/check-ui.js # 34 项:模拟麦克风跑完整链路 —— 实时出字 / 状态机 / 自动精修 / 连点防护 NODE_PATH=.../node_modules .../node verify/check-live.js # 37 项:字幕 —— 对齐质量(不丢字、不重叠、关键词不被切碎)+ 真实下载落盘 NODE_PATH=.../node_modules .../node verify/check-subtitle.js # 24 项:字幕开关 —— 录音中就有字幕 / 条数持续增长 / 时间码不重叠且单调 / 停录音不丢 NODE_PATH=.../node_modules .../node verify/check-subs-live.js # 5 项:内存回归 —— 连续导入 6 次,JS 堆、DOM 节点、服务 RSS、加速器缓存都必须收敛 NODE_PATH=.../node_modules .../node verify/check-memory.js
check-live.js 用 Chrome 的 --use-file-for-fake-audio-capture 把真实音频喂成麦克风,所以走的是完整链路(getUserMedia → AudioContext → VAD → 后端)。三套脚本的分工是刻意分开的:
- API 层断言模型精度(送原始 16k PCM / wav,无重采样损失,必须识别出「总出力」、对齐必须不丢字)
- UI 层只断言链路可用性(模拟麦克风是 16k→44.1k→16k 双重重采样,精度必然掉,不该拿它考精度)
改界面时先跑 node verify/shot.js 取四张图看效果,再跑三套断言——视觉改动不该靠想象验收。check-subtitle.js 还做了件容易被跳过的事:用 CDP 打开真实下载,等文件落到磁盘再读回内容校验格式。只断言「点了按钮没报错」是抓不到「文件是空的」或「扩展名对但内容是 txt」这类问题的。
内存
用户反馈「每选一个音频文件,内存就往上抬一格」。先量,再改——
| 观察点 | 改前 | 改后 |
|---|---|---|
| 页面 DOM 节点(连续 6 次导入) | 每轮 +13,不回落 | 稳定在 850–870(多出的就是历史项) |
| 页面 JS 堆 | 1.4 → 2.0MB | 稳态增量 0.01MB |
| 服务常驻 RSS(连续 6 次长音频) | 998 → 1160MB 后停住 | 1165.4MB,六轮完全不动 |
| MPS 缓存池 | 每次顶到新高,不再归还 | 5387↔5517 反复回吐(每轮归还 80–90MB) |
四个真实原因,都修了
- 波形每帧新建缓冲区:
drawScopeFrame里new Uint8Array(analyser.fftSize),60 帧/秒 × 2KB = 每秒 120KB 垃圾,长录音时 GC 一直追不上。改成一个复用的缓冲区。 - 15 秒一次的健康轮询重建
<select>:模型列表没变也innerHTML=""再重建 option,一天下来几百个脱离文档的节点。加了列表指纹,没变就一个节点都不动。 - 历史列表每次全量重建:同样加了指纹(
at + 文本长度)。 - 请求体和解码结果同时驻留:
raw字节留到推理结束才释放,大文件时峰值凭空翻倍(200MB 文件 + 400MB float32)。解码完立刻del raw,推理完del wav。
另外,MPS 缓存分配器会把历史峰值一直攥着——这不是泄漏,但确实不还,外部看就是「内存只涨不降」。所以一次性任务(导文件、做字幕)结束后主动 empty_cache(),并加了两件可验证的东西:
GET /api/stats:rss_mb+ 加速器allocated_mb / driver_mbPOST /api/free:gc.collect()+empty_cache(),返回前后对比。连点两次,第二次几乎不掉,就说明之前涨的只是缓存- 状态栏常显这两个数,
⌘K→「释放内存」
判读方法(写在 check-memory.js 注释里):基线取第 2 次(第 1 次含模型预热和分配器首次扩容),后 4 次的增量才是判断依据。读取前必须强制 GC,否则 DOM 节点数统计的是「已脱离文档但还没被扫走」的对象,看起来像泄漏。
踩过的坑
01第二次点录音停不下来(用户报的 bug)
function toggleRec() { if (!rec || rec.state === "inactive") startRec(); else { rec.stop(); rec = null; } // ← 问题在这 } rec.onstop = () => { const type = rec.mimeType || mime; // ← 此时 rec 已是 null,抛 TypeError ... stopStream(); // ← 永远执行不到 };
MediaRecorder.onstop 是异步回调,而 toggleRec 同步把 rec 置成了 null。回调里读 rec.mimeType 直接抛异常,后面最关键的那行 stopStream() 被跳过——麦克风不释放、按钮卡在录音态、计时器不停;再点一次因为 rec 为 null 又会重开一路录音。
修法是换成显式状态机(IDLE/STARTING/RECORDING/STOPPING),所有入口先过状态检查,onstop 只做数据收尾,清理逻辑放 teardown() 且不依赖闭包变量。顺带修了另一个竞态:startRec 是 async,getUserMedia 等待期间 rec 仍是 null,连点会并发开两路麦克风——现在 STARTING 态的点击直接忽略。
断言覆盖:二次点击回 IDLE、麦克风轨道 readyState !== live、计时器读数冻结、可重复启停、同一 tick 连点 5 次最多申请 1 次麦克风。
02hidden 属性被 CSS 吃掉
<button hidden> 的 UA 样式是 display:none,但元素同时有 .btn{display:inline-flex} 时,类选择器优先级更高,按钮照样显示。表现是文件识别后不该出现的「整段精修」按钮跑了出来。加 [hidden]{display:none !important} 兜底。这个坑之前的断言完全抓不到——它只检查了 hidden 属性值,没检查实际可见性。现在断言改成量 getBoundingClientRect() 宽高 + offsetParent。
03375px 下页面被撑到 1002px
canvas 的 width 属性是固有尺寸,grid 项默认 min-width:auto 不肯收缩,而 sizeCanvas() 又去读 getBoundingClientRect(),形成「越读越宽」的正反馈。改为读 clientWidth + grid 项和卡片加 min-width:0 + canvas 加 max-width:100%。
04次级文字对比度只有 2.41:1
--faint:#9aa1ab 在灰底上远不达 WCAG AA 的 4.5:1。调深到 #5f666e 后全部达标。
05对齐器抢走了「默认模型」
对齐器下载到 models/ 后,健康检查突然开始报识别失败。原因是 resolve() 在没有指定模型时取 sorted(avail, reverse=True)[0]——按名字排序最大的那个,而 Qwen3-ForcedAligner-0.6B 排在 Qwen3-ASR-* 前面,直接被当成默认 ASR 模型。更麻烦的是它没法用配置区分:两个模型 config.json 里 architectures 和 model_type 完全一样,唯一差别是对齐器的 timestamp_token_id 非空(151705),ASR 模型是 None。处理方式:权重放到 aligner/ 单独目录,同时在 available() 里加两道过滤,只靠名字过滤太脆——改个目录名就失效。
06历史回看生成的字幕时间轴是错的
show() 同时服务「刚识别完」和「点历史回看」两种场景。历史里只存了文本,没存音频,但内存里的 lastFile 还留着上一份音频——于是给旧记录点「字幕」时,拿的是另一段音频去做强制对齐。文本和音频不匹配,对齐器照样输出一串看起来正常的时间戳,不报错。这类 bug 最难发现:有输出、格式正确、只是全错。 修法是历史回看时一并清掉 lastWhole/lastFile 并禁用按钮,按钮 title 写明「这条结果没有对应音频」。
断言本身也会错
值得单独记:check-ui.js 最初有两条 FAIL 是断言写错而不是产品 bug——
- 「零外部依赖」:CSS 里 data URI 的
xmlns="http://www.w3.org/2000/svg"被new URL()当成了外部域名; - 「历史已写入」:只取
innerText.slice(0, 60),模型名在末尾被截掉了。
同一套脚本里,「首字延迟」最初的 12.2s 也是半误报:只读了 textarea,没算临时预览框——用户其实 2 秒就看到字了。断言误报和产品 bug 必须分开处理,否则会去修不存在的缺陷。
已知边界
| 限制 | 说明 | 出路 |
|---|---|---|
| 不是真流式 | 官方的 token 级流式只支持 vLLM,Mac 无 CUDA | 当前是「按句流式」:一句说完 0.5–0.8 秒出字。要 token 级流式只能上 NVIDIA + vLLM |
| 分段精度低于整段 | 见上文长度梯度实验,结构性限制 | 已用自动「整段精修」兜底 |
| 字幕要求文本与音频一致 | 强制对齐是把给定文本贴到声学帧上,文本与音频不符时时间轴会偏 | 用同一份结果生成;换过文本要重新识别 |
| 字幕不做二次断句优化 | 按标点 + 长度机械切条,不保证语义完整 | 已保证关键词不被切碎;要更自然得接语义分句 |
| 单文件上限 30 分钟 | 整段推理会吃满内存 | 长音频应分段 |
| 整段缓冲上限 10 分钟 | 约 58MB,超了就不再留存给精修 | 够用,超出部分仍走实时结果 |
| 无说话人分离 | 模型不输出 speaker label | 需外挂 pyannote 之类 |
| 单并发 | MPS 推理锁串行 | 多并发得换 CUDA + vLLM |
| 音频不出本机 | 除首次下载权重外全程离线 | — |