[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-yuyinzhuanwenzijishushijianfenpianbaohuoshengwenneiwangzhuanxiexitongdesigeguanjianshixian":3},{"id":4,"title":5,"slug":6,"summary":7,"content":8,"contentHtml":9,"wordCount":10,"readingTime":11,"categoryId":12,"tags":13,"coverImage":13,"thumbnail":13,"status":14,"isTop":15,"isRecommended":15,"allowComments":15,"password":13,"viewCount":16,"likeCount":15,"commentCount":15,"seoTitle":5,"seoKeywords":17,"seoDescription":18,"source":13,"sourceUrl":13,"publishTime":19},"2101860362313854978","语音转文字技术实践，分片、保活、声纹：内网转写系统的四个关键实现","yuyinzhuanwenzijishushijianfenpianbaohuoshengwenneiwangzhuanxiexitongdesigeguanjianshixian","经过半个月的时间不断学习和模型的训练，调试，整个语音识别这块的技术栈进行研究，本次主要是端到端，不调用互联网的API接口，自己通过小模型的学习，实现一套支持语音文件识别文字、识别声纹、杂波过滤、支持在线校对等于一体的语音识别文字，整个效果可以实现技术自主可控，成本低廉和可以正式使用的工具； 下面直接先看效果： ![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862133727817728.png) ![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862219169984512.png) ![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862248702078976.png) ![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101863368358948864.png) > 只有 CPU、没有 ...","经过半个月的时间不断学习和模型的训练，调试，整个语音识别这块的技术栈进行研究，本次主要是端到端，不调用互联网的API接口，自己通过小模型的学习，实现一套支持语音文件识别文字、识别声纹、杂波过滤、支持在线校对等于一体的语音识别文字，整个效果可以实现技术自主可控，成本低廉和可以正式使用的工具；\n\n下面直接先看效果：\n\n![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862133727817728.png)\n![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862219169984512.png)\n![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862248702078976.png)\n![](https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101863368358948864.png)\n\n\n> 只有 CPU、没有 GPU 的本地化音视频转写系统：上传音视频 → 异步转写 → 带时间戳的分段文字 → 在线播放与时间轴跳转 → 人工校对 → 四格式导出。\n> 本文讲的是**怎么做到的**，以及**哪些地方真机上才会疼**。全文不含任何内部标识、凭据与目录信息。\n\n---\n\n## 1. 先说清楚约束：这套系统的难点不在\"能转写\"\n\n调一个 `whisper` 把音频变成文字，是个下午的工作量。真正的难点来自下面这些约束，它们决定了架构长什么样：\n\n| 约束                 | 直接后果                                 |\n| ------------------ | ------------------------------------ |\n| 数据不出内网             | 不能用云端 ASR API，模型必须本地跑、可离线加载          |\n| 只有 CPU（int8），无 GPU | 单条 19 分钟会议可能要跑十几分钟，**异步化是硬需求**，不是优化项 |\n| 用户要看到\"在动\"          | 长任务必须有实时进度，否则用户会反复刷新、重复提交            |\n| 会议录音动辄 1 小时+       | 必须分片，且分片不能丢上下文、不能重复、进度不能算错           |\n| 音频是\"任意来源\"          | 手机录音、微信语音、歌曲、视频抽音轨，格式与信噪比全都不可控       |\n| 要区分多人              | 说话人分离必须做，但**不能编造说话人**                |\n| 结果要被人改             | 人工校对后的结果不能被重跑覆盖，误删必须可逆               |\n\n一句话总结：**这是一个人机协作系统，不是一个转写 API。**\n\n---\n\n## 2. 技术选型总览\n\n| 层      | 选型                                               | 选它的理由                                       |\n| ------ | ------------------------------------------------ | ------------------------------------------- |\n| Web 框架 | FastAPI + Uvicorn                                | 原生 async、Pydantic 校验、自带 OpenAPI 文档          |\n| ORM    | SQLAlchemy 2.x（asyncio）                          | API 侧全异步；同一套模型给同步 worker 复用                 |\n| 数据库    | MySQL 8.4 LTS                                    | 共享实例、运维熟悉、utf8mb4 稳定                        |\n| 异步驱动   | `asyncmy`（API） \u002F `pymysql`（worker、迁移）            | **Celery 与 Alembic 不支持 asyncio**，两条驱动同库并存   |\n| 任务队列   | Celery 5.4 + Redis 7                             | 长任务、可取消、可重派；Redis 同时承担进度 Pub\u002FSub            |\n| 迁移     | Alembic                                          | 结构变更有版本、可回退；另出幂等离线 SQL 兜底                   |\n| 配置     | Pydantic v2 + pydantic-settings                  | 环境变量优先，配置即类型                                |\n| 语音识别   | faster-whisper（CTranslate2）+ FFmpeg              | CPU int8 可用，无需 GPU；多档模型可切                   |\n| 声纹     | SpeechBrain ECAPA-TDNN                           | **公开仓库、免令牌**，见 §7.2                         |\n| 前端     | Vue 3 + TypeScript + Vite + Pinia + Element Plus | 组合式 API + 强类型 + 轻量状态管理                      |\n| HTTP   | Axios（响应拦截器统一解包）                                 | 后端统一信封 `{code, message, data}`，前端只处理 `data` |\n| 部署     | Docker Compose                                   | 数据库\u002F缓存可选 profile，默认连外部共享实例                  |\n\n### 2.1 为什么 API 与 Worker 必须是两个进程\n\n这是整套架构的**分水岭**。CPU 推理会占满核心几十秒到几分钟，如果放在 API 进程里，一个转写任务就能把整个 Web 服务卡死。\n\n代价是：**API 进程无法直接观测 Worker 的进度**。所以\"实时进度\"这个看似前端的问题，本质上是一个跨进程通信问题——它只能通过 Redis 解决。后面 §5 会看到，这条约束推导出的一连串设计决策，占了这套系统一半的坑。\n\n### 2.2 状态机与进度分段\n\n```\nPENDING ──▶ PROCESSING ──▶ SUCCESS\n                       └──▶ FAILED (+ error_message)\n```\n\n进度条被切成四段，每段含义明确：\n\n| 区间        | 阶段                               |\n| --------- | -------------------------------- |\n| 0 → 5%    | 取任务、置 PROCESSING                 |\n| 5 → 15%   | FFmpeg 转码（或复用已有 WAV）             |\n| 15 → 90%  | Whisper 解码（分片模式下按**音频绝对时间**线性映射） |\n| 90 → 100% | 落库分段、终态收尾                        |\n\n划分依据只有一个：**让用户知道现在卡在哪一类事情上**。转码慢是 IO，解码慢是模型，两者对用户的暗示完全不同。\n\n---\n\n## 3. 关键实现一：异步转写流水线\n\n### 3.1 统一音频规整：先消灭格式差异\n\n任意输入先由 FFmpeg 统一转成 `WAV 16kHz \u002F 单声道 \u002F PCM S16LE`，再喂给模型。这一步看着笨，但它把\"格式差异\"这个无穷问题压缩成了一个常量——**你不用去猜某种容器 + 某种编码在某个版本的解码器里会出什么岔子**。\n\n一个容易踩的点：**切片时 `-ss` 必须放在 `-i` 之后**。放在前面按关键帧对齐，全片时间戳会整体偏移；放在后面才是采样级精确。\n\n### 3.2 WAV 复用的安全条件\n\n已转码的 WAV 可以复用，省一次 FFmpeg。但复用有个前提：**开过音频增强的任务不能复用未增强的 WAV**。\n\n```python\nreusable = (\n    not task.enhance_audio          # 增强任务必须重新转码\n    and os.path.isfile(wav_path)\n    and os.path.getsize(wav_path) > 0\n)\n```\n\n这行代码的价值在于它拦住的是一类**静默错误**：如果复用错了，任务照样 SUCCESS、进度照样 100%，只是转写质量悄悄变差——这种 bug 靠验收清单是抓不到的，只能靠不变量。\n\n### 3.3 长音频分片：目标 90s \u002F 上限 120s \u002F 重叠 1.5s\n\n```python\nstep = min(max(TARGET_SEC, 30), MAX_SEC)          # 90s，夹在 [30, 120]\noverlap = max(0.0, min(OVERLAP_SEC, 5.0))         # 1.5s\n\nranges, start = [], 0.0\nwhile start \u003C total - 1.0:\n    end = min(start + step, total)\n    ranges.append((start, end))\n    if end >= total:\n        break\n    start = max(start + 1.0, end - overlap)       # 保证严格前进，不会死循环\n```\n\n三个数字各有理由：\n\n- **90s 目标 \u002F 120s 上限**：单次解码的内存与延迟可控，同时不至于把分片切得太碎导致边界问题变多；\n- **1.5s 重叠**：跨边界的词至少在一个分片里是完整的；\n- **`max(start + 1.0, ...)`**：`while` 循环的安全阀，避免重叠参数配错时步长退化为 0 而死循环。\n\n**关键优化：切片在内存里做，不落盘。** WAV 只读一次到 PCM 数组，之后按 `[start*16000 : end*16000]` 直接切片。老做法是逐片调用 FFmpeg 输出临时文件、再解码、再删除——一圈下来纯属浪费，还多了临时文件清理的责任。\n\n### 3.4 分片重叠去重\n\n重叠带来重复。去重不能只看时间（那会把\"跨边界的后半句\"一起裁掉），要**文字与词级时间同时确认**：完全落在已覆盖区间内的段直接丢弃；跨边界的段保留但把起点夹到游标之后（它仍带着前一片没覆盖到的内容）；文字冲突则保留并标记为\"待核对\"，而不是悄悄只裁时间。\n\n### 3.5 进度映射：一个害死过人的\"分母陷阱\"\n\n这是本系统最典型的一个 bug，值得单独讲。\n\n`progress_callback(processed_seconds, total_seconds)` 里的 `total_seconds`，是**传给 Whisper 的那个文件**的时长——分片模式下就是分片自己（90s）。如果拿它当分母：\n\n```python\n# ❌ 错误：分母是分片时长\nfrac = processed \u002F total_seconds\n```\n\n第 1 个分片很快跑到 100%，映射到 90%；**从第 2 个分片开始，`min(chunk_offset + processed, ...)` 被夹到分片时长，`frac` 恒为 1.0** → 进度在整个转写过程中**再也不会更新一次**。\n\n正确写法必须显式把整段时长传进来：\n\n```python\nabsolute = min(chunk_offset + float(processed_seconds), total)   # total = 整段时长\nfrac = min(max(absolute \u002F total, 0.0), 1.0)\nmapped = 15 + int(frac * 75)                                     # 15 -> 90\n# 节流不能吃掉区间上限：90 这个\"毕业值\"必须永远放行\nif mapped >= 90 or mapped - state[\"value\"] >= 3:\n    state[\"value\"] = mapped\n    _update_task_progress(session, task_id, progress=mapped)\n```\n\n第二个陷阱在同两行里：`if mapped - state[\"value\"] >= 3` 的节流会把最后一步（88\u002F89 → 90）挡掉，因为差值只有 1~2。**区间上限必须无条件放行**，否则界面永远停在 90% 不动。\n\n**症状识别**：任务在第一个分片内冲到约 90%，之后一动不动，但日志显示解码仍在继续。\n\n### 3.6 幂等：投递可以重复，执行必须安全\n\nCelery 配了 `acks_late`，崩溃\u002F重启后未确认的消息会被重新投回队列；而 API 侧还有一套\"启动恢复未完成任务\"的逻辑。两者叠加的后果是**同一个任务被投递 N 次**（实测一次事故累积 5 份副本，全部指向同一 task_id）。后果比\"浪费 CPU\"严重得多：**重复副本一旦失败，会把前一次的成功结果覆盖成 FAILED**。\n\n两道守卫：\n\n```python\n# 守卫 1：投递前先看 broker 里是否还有该任务的消息\nis_pending = is_task_message_pending(task_id)   # 查 celery 列表 + unacked 哈希\n\n# 守卫 2：worker 入口检查终态\nif task.status in (SUCCESS, FAILED):\n    return   # 重复投递，直接退出\n```\n\n还有一个坑：**`PROCESSING → PENDING` 的复位只能作用于\"确实要重派的\"任务**。把正在运行的任务复位成 PENDING，会让它的状态与进度长期错乱——因为运行中的写入只更新 progress，不会把 status 改回 PROCESSING。\n\n### 3.7 可取消\n\nCPU 任务必须能取消，否则用户点错一次就得等十几分钟。取消检查被埋在两个粒度上：ASR 解码的每个分段之间、embedding 的每一批之间，且加了 2 秒节流（避免每次分段都查一次库）。\n\n诚实的边界：**单次模型调用是不可中断的**。分片粒度决定了取消的响应时间上界。\n\n---\n\n## 4. 关键实现二：实时进度推送（SSE）\n\n### 4.1 为什么是 SSE 而不是 WebSocket\n\n| 维度   | SSE                      | WebSocket            |\n| ---- | ------------------------ | -------------------- |\n| 数据方向 | 单向（服务端→客户端），**正好匹配进度推送** | 双向，能力过剩              |\n| 协议   | 纯 HTTP，代理\u002F网关零改造          | 需要 Upgrade 握手，中间件容易拦 |\n| 实现成本 | 一个 `StreamingResponse`   | 独立协议栈 + 心跳 + 状态管理    |\n| 断线恢复 | 浏览器原生有重连语义               | 全自己写                 |\n\n进度推送是**单向、低频、可丢弃**的：丢一帧进度不影响正确性，下一帧会覆盖。SSE 是这类场景的最优解。\n\n但 SSE 有三个必须自己填的坑，每个都踩过。\n\n### 4.2 Pub\u002FSub 没有重放：必须\"先订阅、后读快照\"\n\nRedis Pub\u002FSub 是 fire-and-forget，**离线期间的消息不存在**。所以订阅顺序是硬要求：\n\n```\n1. SUBSCRIBE 该任务的进度频道    ← 必须先做\n2. 从 MySQL 读当前行，作为 snapshot 首帧发出\n3. 转发后续 publish 的消息，直到终态\n```\n\n顺序反过来的话，**订阅建立前发生的进度更新会永久丢失**，客户端于是能看到一个\"跳变\"甚至卡住不动。\n\n这引出第二条铁律：**MySQL 永远是唯一事实源，Redis 只是镜像**。Redis 挂了，转写必须照常跑完，只让进度降级——推送是 fire-and-forget，所有 Redis 异常都被吞掉并只记一条 debug 日志。\n\n```python\ndef _publish_progress(task_id: int) -> None:\n    \"\"\"读回刚刚提交的行再发布 —— 保证订阅者收到的值与库里完全一致。\"\"\"\n    try:\n        with SyncSessionFactory() as read_session:        # 一次性 session\n            row = read_session.execute(select(...).where(...)).first()\n        if row is None:\n            return\n        publish_task_progress(task_id, {...})\n    except Exception as exc:\n        logger.debug(\"progress publish skipped: %s\", exc)  # 绝不冒泡\n```\n\n注意那个 `read_session`：**不要在 `commit()` 之后复用同一个 session 做只读查询**。SQLAlchemy 的 autobegin 会立刻开一个新事务，而它要等**下一次 `commit()` 才结束**。实测出现过 350+ 秒的 `idle in transaction`，持有 REPEATABLE READ 快照、阻碍 undo purge（`History list length` 只增不减）。只读读回一律用一次性 session。\n\n### 4.3 心跳必须是\"具名数据帧\"，不能是注释帧\n\nSSE 规范里，以 `:` 开头的是注释帧。它有个致命特性：**浏览器的 EventSource 解析器会完整丢弃注释行**。\n\n于是出现了一个极隐蔽的场景——**没有 FIN 的连接中断**（笔记本休眠、Wi-Fi 切换、NAT 表项老化）：\n\n|               | 用注释帧做心跳       | 用具名数据帧做心跳           |\n| ------------- | ------------- | ------------------- |\n| 中间设备          | 保持连接不老化       | 保持连接                |\n| 浏览器 `onerror` | **不触发**       | 不触发                 |\n| 前端能否感知        | **完全不能**      | 能（收到 `ping` 事件即可计时） |\n| 结果            | 界面无限冻结，重连永不启动 | 静默看门狗判死 → 走重连阶梯     |\n\n所以心跳改成：\n\n```python\ndef _sse(event: str, data: dict) -> str:\n    return f\"event: {event}\\ndata: {json.dumps(data, ensure_ascii=False)}\\n\\n\"\n\n# 空闲超过 HEARTBEAT_SECONDS 就发一帧具名事件\nyield _sse(\"ping\", {\"ts\": int(time.time())})\n```\n\n`event: ping` 是**具名事件**，不会触发 `onmessage`（只触发 `addEventListener('ping')`），因此与 `snapshot` \u002F `progress` \u002F `done` 三个业务事件互不干扰。这是个很干净的技巧：**用同一条流既保活又给客户端一个可观测的活性信号**。\n\n心跳周期取 **20 秒**：必须 ≤ 30s（见过的最短中间代理 idle timeout），又要远小于各跳的超时。\n\n### 4.4 每一个 Redis await 都必须有界\n\n一条无界（或半开）的连接会**挂住整个生成器**——心跳停发、连接被中间设备掐断，而服务端毫不知情。所以：\n\n```python\ntry:\n    message = await asyncio.wait_for(\n        pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0),  # 内层：1s 空闲是正常的\n        timeout=2.0,                                                      # 外层：真卡住的判据\n    )\nexcept asyncio.TimeoutError:\n    # 真卡住 → 关掉 pubsub，转流内 DB 轮询\n    ...\n```\n\n**两层的语义必须分清**：内层返回 `None` 是\"频道空闲没消息\"（完全正常）；外层 `wait_for` 超时才是\"Redis 真的挂了\"。混在一起会导致空闲频道被误判为故障，或者真故障时静默停住。\n\n顺带一提：异步 Redis 客户端也必须配 `socket_connect_timeout` \u002F `socket_timeout` \u002F `health_check_interval`——最初只有同步客户端配了，异步的裸奔，这就是上面那个坑的源头。\n\n同时提供**三层降级链**：\n\n```\nRedis Pub\u002FSub 推送\n  └─ 失败 → 同一流内每秒轮询 MySQL\n       └─ 失败 → 前端降级到 HTTP 轮询接口\n```\n\n生成器里还有连接寿命上限（约 1 小时，略小于任务执行上限）。到期**正常收尾关闭**，让客户端重连——重连后首帧 `snapshot` 会把状态补齐，所以**断连零损失**。这比留一条孤儿订阅好得多，尤其是经历了静默代理掉线之后。\n\n### 4.5 终态必须主动结束响应\n\n如果任务已经结束但连接还开着，`EventSource` 会**对已完成的任务无限重连**。所以：\n\n```python\nif initial[\"status\"] in TERMINAL_STATUSES:\n    yield _sse(\"done\", initial)\n    return                                          # 连接进来时就已经完成\n\nwhile True:\n    ...\n    if payload.get(\"status\") in TERMINAL_STATUSES:\n        yield _sse(\"done\", payload)\n        break                                       # 运行中到达终态\n```\n\n### 4.6 前端：自己接管重连\n\n浏览器的原生重连**用不了**：间隔由服务端 `retry:` 决定，是个**恒定值**，做不出降频，也无法计数\u002F封顶。所以直接把原生实例关掉，自己写重连循环：\n\n```ts\nconst SSE_RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000, 30000]  \u002F\u002F 6 档降频\nconst SSE_JITTER = 0.2                    \u002F\u002F ±20% 抖动，避免大量客户端齐步重试\nconst SSE_HEALTHY_MS = 30000              \u002F\u002F 连接存活超过此值才重置重试预算\nconst SSE_SILENCE_TIMEOUT = HEARTBEAT * 3 * 1000   \u002F\u002F 静默看门狗：60s\nconst SSE_REPROBE_MS = 60000              \u002F\u002F 降级到轮询后，每分钟探一次 SSE\n```\n\n四个关键决策：\n\n**（1）`onerror` 必须按 `readyState` 分支**\n\n- `CONNECTING(0)` = 可恢复（网络抖动、代理掐链、寿命到期正常收尾）→ 重连；\n- `CLOSED(2)` = 致命（HTTP 非 200、Content-Type 不对，如 404\u002F401\u002F5xx）→ 浏览器不会重连，**直接降级轮询，别白等 6 次退避**。\n\n**（2）必须在 `onerror` 里同步销毁原生实例**\n\n```ts\nsource.onerror = () => {\n  source.onerror = null\n  source.close()      \u002F\u002F 否则浏览器会在 retry: 毫秒后自行重连 → 双连接、双计数\n  scheduleReconnect()\n}\n```\n\n**（3）健康判定必须同时要求\"活够久\"和\"收到过帧\"**\n\n这是最容易写错的一处。只用\"连通时长\"判定时，\"服务端接受连接但永不发帧\"的连接会被 60s 静默看门狗判死后重连，而新连接存活又已过 30s → **重试计数被无限重置 → 永远停在第 1 档，永远降级不到轮询**。所以：\n\n```ts\nconst healthy = (Date.now() - openedAt) > SSE_HEALTHY_MS && lastFrameAt > 0\n```\n\n`lastFrameAt > 0` 表示\"至少收到过一帧\"（心跳也算）。**沉默的连接不算健康。**\n\n**（4）重连成功即用 `snapshot` 覆盖本地状态**。Pub\u002FSub 无重放，断线窗口内的进度只能靠服务端首帧补回，前端无需再补一次 HTTP 请求。\n\n另外，`document.hidden` 时**不判死**——后台标签页本就不该重连。\n\n### 4.7 事故：uvicorn 的优雅关闭被一条 SSE 流永久挂住\n\n这是本系统最\"反直觉\"的一次事故，值得完整记录。\n\n**现象**（五个特征同时出现）：\n\n1. 端口仍在 LISTENING，TCP 能握手；\n2. 任何请求（包括文档页）都无响应；\n3. 应用日志彻底静默；\n4. `netstat` 里服务端口的 ESTABLISHED **只增不减**；\n5. 进程 CPU 空转。\n\n极容易被误判成\"后端挂了\"或\"服务慢了\"，实际机制是：\n\n- uvicorn 的 `timeout_graceful_shutdown` **默认值是 `None`，即无限等待**；\n- 一条 SSE 流就是一条**永不完成的 in-flight 请求**；\n- 生成器只监听 `request.is_disconnected()`，而 uvicorn 在\"响应已开始\"时 `shutdown()` 会直接 return → `is_disconnected()` **永远为 `False`**；\n- 于是关闭流程永久停住 → 只能用强杀重启。\n\n**修法**：三处启动命令统一加上：\n\n```\nuvicorn ... --timeout-graceful-shutdown 5\n```\n\n取 5 秒的理由是收益\u002F代价比：SSE 客户端有重连阶梯、重连后首帧补齐状态，**优雅等待毫无收益**；而 5s 短于容器默认的 10s `stop_grace_period`，容器不会走到被 SIGKILL。\n\n**附带一条**：超时取消会在日志留下 `Application shutdown failed` + `CancelledError`——**这是预期内的**，进程正常退出，不要去\"修\"它。\n\n### 4.8 端点自身的三个硬约束\n\n**（1）SSE 端点绝不能注入常规的 DB 依赖**。长连接会把连接池占满（池子只有 10 条）。必须在生成器内部用**短生命周期 session**，用完即还。\n\n**（2）鉴权要在流建立之前完成**。这样未登录请求得到的是干净的 401，而不是一个 200 状态码然后流里发个错误帧——后者会让前端拿到一个\"成功建立但内容诡异\"的连接。\n\n**（3）一条容易被忽略的牵连**：`EventSource` **没有设置请求头的 API**（只能改 `withCredentials`），播放器用的原生 `\u003Caudio>` 元素同样不能自定义请求头。这意味着**会话凭证不能放在 Authorization 头里**，必须是浏览器自动携带的那种；否则音频播放与进度流这两块核心功能会双双 401，而\"接口用 curl 测着都正常\"——因为漏掉的恰是浏览器里那两个发不出头的消费者。另外，快照必须按调用方的归属范围过滤，否则进度流本身会变成一个\"探测别人任务状态\"的旁路。\n\n---\n\n## 5. 关键实现三：转写精度\n\nCPU-only 的现实决定了：**提升精度的杠杆是上下文与词表，不是更大的模型**。实测把 `small` 换成 `large-v3`，一首歌的 9 处同音错误只改对 2 处，却新造了一处幻觉，而耗时翻了好几倍。下面四件事**零额外算力**，优先级全部高于换模型。\n\n### 5.1 VAD 会\"饿死\"音乐类音频，必须留自适应回退\n\n`vad_filter=True` 用的是为**语音**训练的 Silero VAD。它在一首 272.4 秒的歌里只判出 **3.1 秒**语音（**1.1%**）→ 送进模型只剩 3 秒 → 只转出 9 个字。\n\n**症状特征**：任务 `SUCCESS`、进度 100%，但结果只有几个字符，时间轴大片空白。极易被误判成\"模型不行\"。\n\n回退判据用一个很省的办法——faster-whisper 自己已经算好了 `info.duration_after_vad`，**不需要额外跑一遍 VAD**：\n\n```python\ndef _vad_starved(info, segments) -> bool:\n    total = float(getattr(info, \"duration\", 0.0) or 0.0)\n    after_vad = getattr(info, \"duration_after_vad\", None)\n    if after_vad is None or total \u003C= 0:\n        return False\n    keep_ratio = float(after_vad) \u002F total\n    # 恰好为 0 时故意不回退：没有语音可捞，关掉 VAD 只会让模型对纯音乐\"编造文本\"\n    if keep_ratio \u003C= 0 or keep_ratio >= 0.15:\n        return False\n    chars = sum(len(seg[\"text\"]) for seg in segments)\n    return (chars \u002F total) \u003C 0.5     # 字\u002F秒\n```\n\n三个设计决策：\n\n- **必须两个条件同时成立**（保留比例 \u003C 0.15 **且** 字符密度 \u003C 0.5 字\u002F秒）。只看比例会误伤\"1 小时会议、大量静音\"（比例低但文本很多）；只看字符数会误伤\"短音频本来就没几句话\"。\n- **比例恰好为 0 时不回退**。没有语音可捞，关掉 VAD 重跑只会让模型对纯音乐\u002F噪声**编造文本**，比返回空更糟。\n- **不要图省事去调低 VAD 阈值**（比如 0.25）。那是全局放宽，会给已经调好的会议场景引入更多噪声与幻觉。\n\n回退那一遍还必须**丢掉 `initial_prompt` 和 hotwords，并关闭前文续接**——原因见下一条。\n\n### 5.2 `initial_prompt` 会\"泄漏\"进结果\n\n对 Whisper 来说，`initial_prompt` 是**已经说过的前文**，模型会顺着它继续解码。当先验与音频不匹配时（把\"这是一段包含多人对话的会议录音\"注入一首流行歌），模型**宁可续写 prompt 也不听音频**——实测输出里逐字出现了 prompt 自身的短语，还伴随\"身后的你身后的你…\"这种重复循环，置信度只有 0.19。\n\n三条规则：\n\n1. **回退分支（`vad_filter=False` 那一遍）必须去掉 prompt**——VAD 已经判定\"这不是语音\"，语音类先验同样失效；\n2. **文件名里的机器 ID 不要进 prompt**。手机／即时通讯工具导出的录音名往往是一串随机标识（长度 8 以上、数字占比偏高），它会被当成主题词注入并**泄漏到转写结果里**——实测在歌词输出中直接出现过文件名片段。用一条启发式规则过滤，同时保留正常短词：\n\n```python\ndef _looks_like_machine_id(token: str) -> bool:\n    \"\"\"长且数字占比高的 token 是标识符，不是主题词。\"\"\"\n    if len(token) \u003C 8:\n        return False\n    return sum(ch.isdigit() for ch in token) \u002F len(token) >= 1 \u002F 3\n```\n\n`RTX4090`、`GPT4All` 这类短名在长度门槛之下，不受影响。\n\n3. **拿不准就先不注入**。\"没有 prompt 的干净结果\"永远优于\"带着错误先验的结果\"。\n\n### 5.3 用 tokenizer 真实预算管 prompt\n\nWhisper 会按 **约 224 token 截断** `initial_prompt`。如果按字符数裁剪，很可能术语还没进去就被截掉了。所以改成**用模型自己的 tokenizer 真实计数**，总预算 200 token，优先级顺序为：**近期上下文 > 术语\u002Fhotwords > 主题词**。\n\n```python\nbudget = min(max(0, PROMPT_TOKEN_BUDGET), model.max_length \u002F\u002F 2 - 1)\n\ndef encode(text):  return model.hf_tokenizer.encode(text).ids\ndef trim(text, allowance, tail=False):\n    ids = encode(text)\n    if len(ids) \u003C= allowance: return text\n    return model.hf_tokenizer.decode(ids[-allowance:] if tail and allowance else ids[:allowance])\n\n# 最后必须校验\"拼接后的整体\"，而不是分别校验各分量\nwhile prompt and len(encode((hotwords + ' ' + prompt).strip())) > budget:\n    prompt = trim(prompt, len(encode(prompt)) - 1, tail=True)\n```\n\n最后那个 `while` 循环很重要：分别预算的几段拼起来会**超标**，必须对拼接结果做二次收敛。\n\n### 5.4 跨分片上下文续接\n\nWhisper 内部只有约 30 秒窗口，而分片是 90 秒。每个分片独立解码 ⇒ **分片边界处\"上文\"归零** ⇒ 同一术语被反复猜成同音词（\"Issue → ISO\"、\"Commit → Commute\"）。\n\n修法很直接：把上一分片的文本尾部（180 字符，只保留最近一片，累积会撑爆预算）作为下一片的 `initial_prompt` 续接段：\n\n```python\nprev_tail = \"\"\nfor idx, (chunk_start, chunk_end) in enumerate(ranges):\n    chunk_segments = transcribe(..., prev_text=prev_tail if (chunked and idx > 0) else None)\n    if chunked:\n        prev_tail = \" \".join(seg[\"text\"] for seg in chunk_segments).strip()\n```\n\n配合 `condition_on_previous_text=True`（负责片**内**分段之间的上下文），两者才能覆盖\"片内\"与\"跨片\"两级上下文。\n\n**副作用要管**：`condition_on_previous_text=True` 在长音频上偶发重复\u002F循环，靠温度回退 + `compression_ratio_threshold` + `log_prob_threshold` 兜住；而 **VAD 回退那一遍必须把它关回 `False`**——非语音没有\"上文\"可续，续写只会循环。\n\n### 5.5 局部二次解码：只重解\"值得重解\"的地方\n\n整段重跑太贵。所以只挑低置信的局部区域重解：\n\n```python\nASR_RERUN_MAX_REGIONS = 2          # 每分片最多 2 处\nASR_RERUN_MAX_AUDIO_RATIO = 0.2    # 额外音频不超过该分片时长的 20%\nWHISPER_LOW_CONF_RERUN_BEAM_SIZE = 10\nWHISPER_LOW_CONF_RERUN_MAX_AVG_LOGPROB = -1.0\n```\n\n候选是否采纳，用**解码分数、重复度、非语音占比、字数变化**四者综合判定；**未改善就保留第一遍**：\n\n```python\nif (candidate\n        and 0.6 * old_chars \u003C= new_chars \u003C= 1.5 * max(1, old_chars)     # 字数没暴走\n        and _candidate_score(candidate) > _candidate_score(originals) + 0.05):\n    segments = merge(segments, originals, candidate)\n```\n\n字数区间那道门是防幻觉的关键——二次解码在低置信区域很容易\"编得更长\"，只靠分数会被骗过。\n\n### 5.6 三档质量档位\n\n把精度\u002F速度的权衡交给用户，但给出有意义的默认值：\n\n| 档位         | beam | 词级时间戳    | 局部重试  |\n| ---------- | ----:| -------- | ----- |\n| 快速         | 2    | 仅开说话人分离时 | 关闭    |\n| **均衡（默认）** | 5    | 开启       | 有预算上限 |\n| 精细         | 8    | 开启       | 有预算上限 |\n\n**为什么默认是均衡而不是快速**：快速档的准确性尚未经过人工参考稿评估，而它省下的时间远小于\"结果需要重跑\"的代价。\n\n### 5.7 缓存键必须\"精确到请求\"\n\n缓存是纯计算加速，**MySQL 里的发布结果仍是页面与导出的唯一事实来源**。键里包含：音频内容哈希、模型权重文件指纹（大小 + 修改时间）、faster-whisper 版本、prompt\u002Fhotwords、语言、档位、全部解码参数、VAD 回退阈值、重试参数。\n\n两条容易漏的规则：\n\n- **不同请求不能互相替代**。少一个参数就可能让\"改了词表的请求\"命中\"老词表的结果\"；\n- **命中返回深拷贝**，且分片的时间偏移必须在写入缓存前处理干净，否则偏移会污染缓存。\n\n---\n\n## 6. 关键实现四：说话人分离\n\n### 6.1 它是独立阶段，不是 ASR 的一部分\n\n分离必须**独立于 ASR**，理由有三个：\n\n1. ASR 调优（prompt、VAD、重试）已经踩坑修好，换全家桶方案会把这些全丢掉；\n2. CPU 上分离很慢，必须做成**任务级开关、默认关**；\n3. 分离失败**绝不能影响文本**——文字是主交付物，说话人是增强信息。\n\n所以流程是：ASR 先出文字与词级时间戳 → 分离独立跑 → 按时间把说话人**贴回**分段。\n\n### 6.2 引擎选型：为什么不是 pyannote\n\n最直接的原因是**令牌**：主流方案（pyannote 系列）模型仓库是 gated 的，需要签署许可 + 令牌才能下载，而目标部署网络无法直连上游仓库。没有令牌 ⇒ 不可用。\n\n改用**公开仓库、免令牌**的 SpeechBrain ECAPA-TDNN 声纹模型（走可用的镜像站下载）。链路全部自建：\n\n```\n16k 单声道 WAV\n  └─ 独立 Silero VAD（多尺度窗口）\n       └─ 1.5s 短窗 + 3s 上下文融合，步长 1.5s\n            └─ 质量筛选（排除静音\u002F削波）→ 可靠窗口作聚类种子\n                 └─ ECAPA embedding（分批编码，每批 16 窗）\n                      └─ Agglomerative 聚类（cosine \u002F average linkage）\n                           └─ 簇原型 + Viterbi 换人惩罚重评分 + 拒判\n                                └─ 按段中点贴回 SPEAKER_XX\n```\n\n几个值得说的设计：\n\n- **ASR 分段不再决定声纹窗口**。早期做法是\"复用 Whisper 分段当语音区\"，但分段边界由文字决定、不等于声学边界。改成独立 VAD 后，声纹窗口的质量稳定得多。\n- **至少 1 秒的可靠窗口才有资格当聚类种子**；短应答可以归到已有原型，没有种子就保持\"未知\"。\n- **聚类最多抽样 2000 个种子**，其余按原型归属——把平方级成本按住。\n- **拒判（abstention）**：相似度不足或与次优候选差距不够时，宁可返回 `None`。**没有把握就不要编。**\n\n### 6.3 人数判定：一个阈值是不够的\n\n最初\"几个人\"只由**一个合并阈值**（cosine \u002F average linkage，0.75）决定，只有合并、没有拆分 ⇒ **同性别、音色接近的两个人必然被并成一个**。\n\n指纹很好认：某个簇的簇内平均余弦距离明显高于其他簇（实测一支 **0.233**，另两支 0.14~0.17）且占窗口大头；把它拆开，两半质心距离只有 **0.5288 \u003C 0.75**。\n\n修法是在聚类后加一道**分裂审计**，四道门全过才拆：\n\n| 门          | 阈值   | 含义             |\n| ---------- | ----:| -------------- |\n| 簇内散度下限     | 0.20 | 只审计\"松\"的簇，紧的簇免检 |\n| 二分处平均链接高度  | 0.55 | 拆的位置必须足够\"分得开\"  |\n| 拆后两半内部散度上限 | 0.20 | 两半都得是\"紧\"的      |\n| 两半最小窗口数    | 4    | 两半都要够大（防噪声切分）  |\n| 封顶         | 8 人  | 保护上限           |\n\n**关键：判据和基础聚类用同一把尺子**（average + cosine），所以\"合并高度 0.55\"可以和\"合并阈值 0.75\"直接对照。\n\n**阈值不是拍出来的**：把**全部 8 份真实声纹缓存**（`.npz`，不需要模型也不需要数据库）离线重跑对照——唯一发生分裂的就是那一条问题录音（2 → 3 人，无标签窗口 2 → 0），而两场 4 人长会议（478s \u002F 704s）**一条分裂记录都没产生**（被门 ③④ 挡住）。**要改判据，就再用这批缓存重跑一遍。**\n\n而且：**不要为了单个任务去全局调低合并阈值**——它同时是所有任务的合并尺度，调低必然过度分裂（同一个人被切成两个）。人数已知且素材敏感时，直接用任务参数强制指定人数，完全绕开这套判据。\n\n### 6.4 音乐\u002F纯音乐：自动跳过，而不是硬贴标签\n\n同一首 272.4 秒的歌，**分离侧的 VAD 只判出 2.304 秒语音**（两个窗口），全曲几乎无声纹 → 绝大部分分段拿不到说话人 → **满屏\"待确认\"**。\n\n**症状特征**：任务 `SUCCESS`、分离显示\"已完成\"，但列表满屏\"待确认\"。**这不是 bug，是素材类型不对**——对歌曲做说话人分离本身没有意义。\n\n修法是让系统自己识别并诚实地跳过：\n\n```python\nif diar_metrics.get('skipped_reason') == 'insufficient_speech':\n    task.diarization_status = 'review'\n    task.diarization_error = (\n        f\"未检测到有效语音（语音占比仅 {audio_coverage * 100:.1f}%，\"\n        \"音频可能为歌曲\u002F纯音乐），已跳过说话人分离；转录文本已保留\"\n    )\n    # 不抛异常、不改写任何 segment.speaker\n```\n\n四条不变量：\n\n1. **语音占比不足 = 跳过，不是失败**。阈值门**同时**下在两处：特征提取前（不达标连声纹模型都不加载，省掉最贵的一步）和主入口（**这一层是为了让命中旧缓存的\"修复前结果\"也走跳过路径**）。\n\n2. **\"已完成\"要有门槛**。原来 `done if speaker_count` 门槛过低——仅凭一个窗口聚出 1 人就报\"已完成\"，与满屏\"待确认\"自相矛盾。现在必须语音占比达标**且** turns 非空。\n\n3. **两个覆盖率口径并存，各有各的语义**：\n   \n   - `coverage` 分母是**语音秒数**，含义是\"进入声纹的语音里被拒判的比例\"——**这个语义没错，不要改**；\n   - `audio_coverage` 分母是**音频总时长**，才是\"全片覆盖率\"。\n   \n   这个区分是被一次误导救回来的：界面曾显示\"自动声纹拒判 41.0%\"，而真实情况是**全曲 99.2% 从未进入分离流程**。分母选错，指标就会撒一个比\"没数据\"更危险的谎。\n\n4. **两种\"待确认\"不能合并**。原来的\"待确认\"标记有两个来源：说话人归属待确认、以及**分片边界的文字冲突**。后者会让**没开分离的任务**也冒出说话人\"待确认\"。修法是给边界冲突的 span 打上 `review_kind='boundary'`，后端只认非 boundary 的标记，前端显示为\"待核对\"并使用独立文案。**别把这两处再合并回一个标记。**\n\n### 6.5 歌曲场景还暴露了置信度的局限\n\n歌词逐句核对出 9 处同音错误，而**置信度全部在 0.70~0.82，高于 0.6 的告警阈值** → \"待校对\"队列是 0。再次印证 §5 的结论：置信度是解码质量的度量，**不等于语义正确率**；对音乐类素材，`avg_logprob` 基本失去参考价值。\n\n### 6.6 展示层与数据层的分界：改名不等于改标签\n\n用户想把\"说话人 1\"改成\"张三\"。一个看起来很自然的做法是直接改写 `segment.speaker`。**这是错的，而且不可回退。**\n\n因为 `segment.speaker` 是**稳定机器标签**，它同时是：词级 spans 的说话人键、合并\u002F单段修正\u002F待确认队列的判定依据、四种导出格式的取值来源。把它改成真实姓名，会导致段级标签与词级 spans 分裂 → 同一个人出现两个标签 → 合并与队列全乱。\n\n正确做法是**任务级映射表**：`{\"SPEAKER_00\": \"张三\"}`，**替换式保存**（接口收到的即全量状态），不带某标签或值为空 ⇒ 回落\"说话人 N\"；合并说话人时原标签的姓名顺延到目标标签。**数据层一个字节都不改。**\n\n### 6.7 版本化：让\"重跑\"和\"人工修改\"共存\n\n分离结果、词级时间轴、派生句段、配置与耗时，整体存成一次 **analysis run** 的 JSON（不拆成四张关系表），任务上放一个\"当前发布版本\"指针，**完整 run 与指针在同一短事务提交**。\n\n由此得到几条保护：\n\n- 页面和四种导出**共同读同一个发布版本** → 三者永远一致；\n- **失败或回滚不切换指针** → 半成品不会上线；\n- 分离异常时**文字保留、界面显示失败**，未知说话人**不伪造编号**；\n- 人工修正生成新版本并标记 `manual`；**重跑保留人工标签与已合并句段**；\n- **文字被人工改过之后，不再套用旧的词级派生句段**（时间对不上了）。\n\n### 6.8 软删除：宁可留标记，不可删行\n\n分段删除必须是**软删除**（加一个 `deleted` 标记位）。原因很硬：词级 spans **以分段序号为键**，硬删一行就是不可修复的空洞——只能整场重跑才能补回来。而且误删必须可逆。\n\n避免\"改一处漏一处\"的关键是**排除点只能有一处**：\n\n```python\ndef visible_sources(...):\n    for segment in segments:\n        if getattr(segment, \"deleted\", False):\n            continue        # ← 唯一排除点\n        yield segment\n```\n\n四种导出、详情列表、说话人标签收集**全部从这里继承**；将来加第五种导出格式也自动正确。**绝对不要**在每个导出分支各写一次判空。\n\n配套两条：\n\n- 详情响应把已删分段单独放在 `deleted_segments`，**不要**塞回主列表加标记——主列表必须一直等价于\"用户手里还有什么\"，否则说话人计数、四个筛选队列、导出都要各自记得判一次。\n- 全文只在**仍是机器拼接**时才重算：`before = join(可见段)` → 翻标记 → `after = join(可见段)` → `if task.text == before: task.text = after`。用户手工整理过全文时**不许覆盖**。\n\n---\n\n## 7. 前端：几个\"看不出问题但用户会骂\"的点\n\n### 7.1 一屏一个滚动容器，禁止嵌套滚动\n\n布局是固定的：`html\u002Fbody → 布局容器(100vh) → 主区(overflow:hidden) → 页面根(overflow:hidden) → 卡片(overflow:hidden)`，**页面级永不出现滚动条**，滚动只发生在内容区。\n\n反面案例很典型：详情页左栏自带 `overflow-y:auto`，里面包着一个会涨到 30 行的自适应高度文本框。笔记本（约 768px 高）下可用高度只有约 400px，而文本框会长到约 720px → **外层滚动条 + 文本框内部滚动条同时出现**（用户报的\"2 个滚动条\"）。\n\n正确姿势：**内容区自身是唯一滚动体，内部元素必须\"只长不滚\"**；多视图用标签页切换而不是并排分栏；文本框用**无上限自适应高度**（只给最小行数，不给最大行数）让它长满。\n\n同理，表格页必须给表格**像素级高度**：默认不滚的表格行数一多会把分页一起挤出容器被 `overflow:hidden` **裁掉（无滚动条、内容直接消失）**。做法是包一层 `overflow:hidden` 的容器、用 `ResizeObserver` 量高再传给表格的 `max-height`。**只有表格主体一个滚动条，分页永远可见。**\n\n### 7.2 阅读字号三档：必须走 CSS 变量，且三件事一起缩放\n\n字号档位（小 13 \u002F 中 14 默认 \u002F 大 16）存在本地，是**纯前端阅读偏好：不进数据库、不影响导出**。\n\n传递只能靠 CSS 变量——因为字号写在子组件的 scoped 样式里，**props 够不到，只有 CSS 变量能穿透 scoped**。\n\n**而且三件事必须一起缩放**，只改字号一定翻车：\n\n1. **行高**（1.6 \u002F 1.7 \u002F 1.8）——不跟，大字会挤成一坨；\n2. **行首时间列宽**（50 \u002F 52 \u002F 58）——栅格列是固定像素不会自己长，13px 等宽时间串在 52px 列里会折行；\n3. **行内编辑框字号**——不同步会出现\"16px 的段落点开变成 14px 输入框\"的跳变。\n\n反过来，**界面那一层不跟字号放大**：工具栏、图例、按钮、图标固定 12px。它们是界面，放大只会让界面变笨重。\n\n### 7.3 后端时间是 naive UTC，前端必须按 UTC 解析\n\n模型用 `datetime.utcnow`（无时区），序列化出来是 `2026-09-14T01:31:08.920`（**没有 `Z`**）。`new Date()` 会把它当成**本地时间**解析 → 界面时间整体差一个时区偏移（本机实测差 8 小时）。\n\n统一走一个 `parseServerDate()`：无时区后缀就补 `Z`，带 `Z` \u002F `+08:00` 的原样通过。组件里**不要**直接 `new Date(...)`。\n\n### 7.4 局域网 http 下没有剪贴板 API\n\n`navigator.clipboard` 只在安全上下文（https \u002F localhost）可用。这类系统通常部署在内网、以 HTTP 明文访问，所以任何\"复制到剪贴板\"都必须保留降级分支：\n\n```ts\nif (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text) }\nelse { \u002F* 隐藏 textarea + document.execCommand('copy') *\u002F }\n```\n\n漏了这条，功能在真实部署环境里直接失效——而本地开发（localhost）永远测不出来。\n\n### 7.5 图标工具条要\"安静\"，但要能被触屏找到\n\n分段行里的动作（回听、校对、改说话人、删除）如果都写成文字，会和正文抢注意力——**正文是文字、动作也是文字，两层同质信息互相干扰**。\n\n改成：三列网格 `时间 | 正文 | 工具条`，工具条用 16px 自绘图标，`opacity:.5`，hover \u002F 聚焦时提到 1。\n\n**但必须写 `@media (hover: none) { opacity: 1 }`**——否则触屏设备上这些动作等于被藏起来。用 `opacity` 而不是 `display:none`，保留可点击、可聚焦。\n\n还有一条：徽标内联在正文里时**必须加 `@dblclick.stop`**。`@click.stop` 只挡 click，双击照样冒泡到正文行 → 一边弹改名弹窗一边进入文字编辑。\n\n### 7.6 破坏性操作：二次确认，且焦点不能落在确认键上\n\n两类\"删除\"代价完全不同，文案必须说实话：\n\n| 操作  | 代价               | 文案                    |\n| --- | ---------------- | --------------------- |\n| 删分段 | 软删除，可恢复          | \"内容仍保留在『已删除』队列，可随时恢复\" |\n| 删任务 | 真删（音频 + 结果），不可恢复 | \"永久删除，无法恢复\"           |\n\n确认框里要**回显时间区间 + 段序号 + 文字开头**（长文截到 3 行），让人核对\"删的正是这一行\"。只写一句\"确定吗\"等于没确认。\n\n**最关键的一条**：确认框的 `autofocus` 默认是 `true`，会把焦点给确认键 → **手还在键盘上时一个回车就删掉了**。置为 `false` 后，焦点陷阱落不到确认键上，**回车不会触发确认**（真实浏览器实测：弹窗打开瞬间 `document.activeElement` 是弹窗容器，按 Enter 时弹窗保持打开、数据不变）。取消路径交给 Esc 和\"取消\"按钮。\n\n另外，弹确认之前要有一个**同步闸门**（一个布尔 ref）：确认框的遮罩要下一个 tick 才渲染，不加闸门时\"极快双击\"会在遮罩出现前再弹出一个，两个框叠着。\n\n---\n\n## 8. 效果数据\n\n### 8.1 精度\u002F效率优化的实测（CPU int8，small 模型，中文，60 秒真实会议片段）\n\n| 请求    | ASR 耗时     | 端到端    | ASR 缓存 |\n| ----- | ----------:| ------:| ------ |\n| 均衡档首次 | 17.16s     | 19.49s | 未命中    |\n| 快速档首次 | 6.86s      | 6.86s  | 未命中    |\n| 均衡档重复 | **0.047s** | 0.047s | **命中** |\n\n跨进程冷启动场景：首次联合分析 26.03s；**新进程重复请求命中 ASR 与声纹双缓存为 6.17s**。\n\n> 诚实说明：首行包含模型冷加载，后两行模型已驻留，**不能把比值直接当成固定加速倍数**。基准为服务调用，不含上传、数据库、UI 和 FFmpeg。\n\n### 8.2 声纹特征复用\n\n一场全长录音首次声纹分析 **182.56 秒**（360 个窗口）；复用同一份声纹特征的两个后续任务分别 **2.44s \u002F 2.89s**。三者发布的时间轴哈希一致，原始转写全文哈希与备份一致。\n\n> **只改变人数可以复用声纹特征再聚类；改变 ASR 文字、模型或分段都不会改变声纹特征**——这个缓存边界划得清楚，是\"改个字不用重跑声纹\"的前提。\n\n### 8.3 端到端\n\n一条联合流程任务在 worker 重载后 SUCCESS，**两个缓存均命中，含 FFmpeg 与数据库的总耗时 8.42 秒**。四格式导出、发布回滚、同音频不同 ASR 时间轴一致性、文字哈希、重跑后人工修正保留——均已验证。\n\n---\n\n## 9. 踩坑速查表\n\n| #   | 坑                     | 症状                          | 根因 \u002F 修法                                           |\n| --- | --------------------- | --------------------------- | ------------------------------------------------- |\n| 1   | 分片进度分母用错              | 第一个分片内冲到 ~90%，之后不动          | 分母必须是**整段时长**，不是分片时长                              |\n| 2   | 进度节流吃掉上限              | 永远停在 90%                    | 区间上限值必须**无条件放行**                                  |\n| 3   | 重复投递                  | 同一任务被投递 5 次，后一次失败覆盖前一次成功    | 投递前查 broker + worker 入口查终态                        |\n| 4   | `idle in transaction` | 进度长时间不变，事务挂 350s+           | `commit()` 后**不要**复用同一 session 做只读查询              |\n| 5   | SSE 注释帧做心跳            | 界面无限冻结，不触发重连                | 注释帧被浏览器**完全丢弃**，改具名数据帧 + 静默看门狗                    |\n| 6   | 异步 Redis 客户端无超时       | 心跳停发、连接被掐                   | 每个 await 加 `wait_for`，客户端配 socket 超时              |\n| 7   | 优雅关闭被 SSE 挂死          | 端口在听、任何请求无响应、日志静默、日志 CPU 空转 | `--timeout-graceful-shutdown 5`（默认是 `None` = 无限等） |\n| 8   | VAD 饿死音乐              | SUCCESS 但只有几个字，大段时间轴空白      | `duration_after_vad` 双条件回退；**别调低全局阈值**            |\n| 9   | `initial_prompt` 泄漏   | 输出里出现 prompt 自身短语 + 重复循环    | 回退分支去 prompt；文件名机器 ID 过滤                          |\n| 10  | 字段名写错                 | 置信度恒为 NULL，低置信高亮成死代码        | `avg_logprob`（**中间没有下划线**）；改 `getattr` 前先核真实字段    |\n| 11  | 语言探测静默失败              | 中文 prompt 与词表**从未生效**       | `detect_language()` 不接受路径，必须传解码后的数组               |\n| 12  | 只给 ffmpeg 留 stderr 开头 | 报错只有构建 banner，无法诊断          | 留**末尾** 500 字符                                    |\n| 13  | 一个人数阈值                | 同性别两人必被并成一个                 | 合并阈值 + 聚类后分裂审计四道门                                 |\n| 14  | 指标分母错                 | 显示\"拒判 41%\"，实际 99.2% 未进入流程   | `coverage`（分母语音）与 `audio_coverage`（分母总时长）分开       |\n| 15  | 两类\"待确认\"合并             | 没开分离的任务也冒出说话人待确认            | span 打 `review_kind='boundary'`，前后端都只认非 boundary  |\n| 16  | 硬删分段                  | 词级 spans 出现不可修复的空洞          | 软删除 + **唯一排除点**                                   |\n| 17  | naive UTC 前端解析        | 界面时间差 8 小时                  | 无时区后缀补 `Z` 再解析                                    |\n| 18  | 局域网剪贴板                | 功能在真实环境直接失效                 | 保留 `execCommand('copy')` 降级                       |\n| 19  | 确认框焦点默认在确认键           | 手在键盘上，一个回车就删了               | `autofocus: false`                                |\n| 20  | 嵌套滚动                  | 同一屏两个滚动条                    | 一屏一个滚动容器，内部元素\"只长不滚\"                               |\n\n---\n\n## 10. 边界与后续\n\n诚实地列出当前没做\u002F做不到的：\n\n- **人工 CER \u002F DER 盲测尚未完成**。目前的聚类数与标签覆盖率都是**算法结果**，不等于人工准确率。\n- **重叠语音（抢话）没有真正分离**。现在的 turns 是\"独占\"的，不代表恢复了两条人声。\n- **CPU 首次推理、大录音整段 PCM 内存、进程常驻模型内存**都还需要在目标部署机上做压力验收。\n- **单次模型调用不可中断**，取消的响应时间上界由分片粒度决定。\n- **大表加外键\u002F改列类型会触发整表重建并持锁**。小表无感，表一大就是停机风险——写迁移前先用 `information_schema` 估体量，并优先\"加列 \u002F 加索引\"这类 in-place 操作。\n- 存储层已抽象出后端接口（预留对象存储），当前只有本地后端。\n\n下一步方向（按性价比排序）：\n\n1. 先做**人工参考稿评测**，把 CER\u002FDER 变成可回归的指标，再谈任何阈值调整；\n2. 补**真实重叠语音检测**，把\"抢话\"从\"未知区间\"里区分出来；\n3. 在目标部署机做全面压力验收，确定并发与内存上界；\n4. 再评估 GPU \u002F 更大模型 \u002F 新声纹模型的收益——**在 1、2 完成之前，换模型只是把不确定性换个地方**。\n\n---\n\n## 11. 小结\n\n回头看，这套系统里真正的功夫不在\"调通一个语音模型\"，而在四件事：\n\n1. **把进程边界当成一等公民**。API 与 Worker 分开的那一刻，\"实时进度\"就从 UI 问题变成了跨进程通信问题，Pub\u002FSub 无重放、心跳可观测性、Redis 挂掉不能影响主流程，全都由这条边界推导出来。\n2. **区分\"数据\"和\"展示\"**。说话人改名只做展示层映射、软删除只加标记位、阅读字号不进数据库、缓存只加速计算而 MySQL 是唯一事实源——这几条划清楚之后，系统才敢被人改。\n3. **指标的口径比指标本身重要**。进度分母、覆盖率分母、健康判定条件，每一个都是\"看起来对但会在极端情况下撒一个比没数据更危险的谎\"的地方。\n4. **精度杠杆在上下文与词表，不在模型大小**。分片上下文续接、tokenizer 预算、术语词表、VAD 回退——四个零算力手段，优先级全部高于换模型。\n","\u003Cp data-line=\"0\">经过半个月的时间不断学习和模型的训练，调试，整个语音识别这块的技术栈进行研究，本次主要是端到端，不调用互联网的API接口，自己通过小模型的学习，实现一套支持语音文件识别文字、识别声纹、杂波过滤、支持在线校对等于一体的语音识别文字，整个效果可以实现技术自主可控，成本低廉和可以正式使用的工具；\u003C\u002Fp>\n\u003Cp data-line=\"2\">下面直接先看效果：\u003C\u002Fp>\n\u003Cp data-line=\"4\">\u003Cimg src=\"https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862133727817728.png\" alt=\"\">\u003Cbr>\n\u003Cimg src=\"https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862219169984512.png\" alt=\"\">\u003Cbr>\n\u003Cimg src=\"https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101862248702078976.png\" alt=\"\">\u003Cbr>\n\u003Cimg src=\"https:\u002F\u002Fseabrid.cn\u002Ffile\u002F2026\u002F2101863368358948864.png\" alt=\"\">\u003C\u002Fp>\n\u003Cblockquote data-line=\"10\">\n\u003Cp data-line=\"10\">只有 CPU、没有 GPU 的本地化音视频转写系统：上传音视频 → 异步转写 → 带时间戳的分段文字 → 在线播放与时间轴跳转 → 人工校对 → 四格式导出。\u003Cbr>\n本文讲的是\u003Cstrong>怎么做到的\u003C\u002Fstrong>，以及\u003Cstrong>哪些地方真机上才会疼\u003C\u002Fstrong>。全文不含任何内部标识、凭据与目录信息。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Chr data-line=\"13\">\n\u003Ch2 data-line=\"15\" id=\"1. 先说清楚约束：这套系统的难点不在&quot;能转写&quot;\">1. 先说清楚约束：这套系统的难点不在&quot;能转写&quot;\u003C\u002Fh2>\n\u003Cp data-line=\"17\">调一个 \u003Ccode>whisper\u003C\u002Fcode> 把音频变成文字，是个下午的工作量。真正的难点来自下面这些约束，它们决定了架构长什么样：\u003C\u002Fp>\n\u003Ctable data-line=\"19\">\n\u003Cthead data-line=\"19\">\n\u003Ctr data-line=\"19\">\n\u003Cth>约束\u003C\u002Fth>\n\u003Cth>直接后果\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"21\">\n\u003Ctr data-line=\"21\">\n\u003Ctd>数据不出内网\u003C\u002Ftd>\n\u003Ctd>不能用云端 ASR API，模型必须本地跑、可离线加载\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"22\">\n\u003Ctd>只有 CPU（int8），无 GPU\u003C\u002Ftd>\n\u003Ctd>单条 19 分钟会议可能要跑十几分钟，\u003Cstrong>异步化是硬需求\u003C\u002Fstrong>，不是优化项\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"23\">\n\u003Ctd>用户要看到&quot;在动&quot;\u003C\u002Ftd>\n\u003Ctd>长任务必须有实时进度，否则用户会反复刷新、重复提交\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"24\">\n\u003Ctd>会议录音动辄 1 小时+\u003C\u002Ftd>\n\u003Ctd>必须分片，且分片不能丢上下文、不能重复、进度不能算错\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"25\">\n\u003Ctd>音频是&quot;任意来源&quot;\u003C\u002Ftd>\n\u003Ctd>手机录音、微信语音、歌曲、视频抽音轨，格式与信噪比全都不可控\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"26\">\n\u003Ctd>要区分多人\u003C\u002Ftd>\n\u003Ctd>说话人分离必须做，但\u003Cstrong>不能编造说话人\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"27\">\n\u003Ctd>结果要被人改\u003C\u002Ftd>\n\u003Ctd>人工校对后的结果不能被重跑覆盖，误删必须可逆\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"29\">一句话总结：\u003Cstrong>这是一个人机协作系统，不是一个转写 API。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Chr data-line=\"31\">\n\u003Ch2 data-line=\"33\" id=\"2. 技术选型总览\">2. 技术选型总览\u003C\u002Fh2>\n\u003Ctable data-line=\"35\">\n\u003Cthead data-line=\"35\">\n\u003Ctr data-line=\"35\">\n\u003Cth>层\u003C\u002Fth>\n\u003Cth>选型\u003C\u002Fth>\n\u003Cth>选它的理由\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"37\">\n\u003Ctr data-line=\"37\">\n\u003Ctd>Web 框架\u003C\u002Ftd>\n\u003Ctd>FastAPI + Uvicorn\u003C\u002Ftd>\n\u003Ctd>原生 async、Pydantic 校验、自带 OpenAPI 文档\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"38\">\n\u003Ctd>ORM\u003C\u002Ftd>\n\u003Ctd>SQLAlchemy 2.x（asyncio）\u003C\u002Ftd>\n\u003Ctd>API 侧全异步；同一套模型给同步 worker 复用\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"39\">\n\u003Ctd>数据库\u003C\u002Ftd>\n\u003Ctd>MySQL 8.4 LTS\u003C\u002Ftd>\n\u003Ctd>共享实例、运维熟悉、utf8mb4 稳定\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"40\">\n\u003Ctd>异步驱动\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>asyncmy\u003C\u002Fcode>（API） \u002F \u003Ccode>pymysql\u003C\u002Fcode>（worker、迁移）\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Celery 与 Alembic 不支持 asyncio\u003C\u002Fstrong>，两条驱动同库并存\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"41\">\n\u003Ctd>任务队列\u003C\u002Ftd>\n\u003Ctd>Celery 5.4 + Redis 7\u003C\u002Ftd>\n\u003Ctd>长任务、可取消、可重派；Redis 同时承担进度 Pub\u002FSub\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"42\">\n\u003Ctd>迁移\u003C\u002Ftd>\n\u003Ctd>Alembic\u003C\u002Ftd>\n\u003Ctd>结构变更有版本、可回退；另出幂等离线 SQL 兜底\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"43\">\n\u003Ctd>配置\u003C\u002Ftd>\n\u003Ctd>Pydantic v2 + pydantic-settings\u003C\u002Ftd>\n\u003Ctd>环境变量优先，配置即类型\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"44\">\n\u003Ctd>语音识别\u003C\u002Ftd>\n\u003Ctd>faster-whisper（CTranslate2）+ FFmpeg\u003C\u002Ftd>\n\u003Ctd>CPU int8 可用，无需 GPU；多档模型可切\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"45\">\n\u003Ctd>声纹\u003C\u002Ftd>\n\u003Ctd>SpeechBrain ECAPA-TDNN\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>公开仓库、免令牌\u003C\u002Fstrong>，见 §7.2\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"46\">\n\u003Ctd>前端\u003C\u002Ftd>\n\u003Ctd>Vue 3 + TypeScript + Vite + Pinia + Element Plus\u003C\u002Ftd>\n\u003Ctd>组合式 API + 强类型 + 轻量状态管理\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"47\">\n\u003Ctd>HTTP\u003C\u002Ftd>\n\u003Ctd>Axios（响应拦截器统一解包）\u003C\u002Ftd>\n\u003Ctd>后端统一信封 \u003Ccode>{code, message, data}\u003C\u002Fcode>，前端只处理 \u003Ccode>data\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"48\">\n\u003Ctd>部署\u003C\u002Ftd>\n\u003Ctd>Docker Compose\u003C\u002Ftd>\n\u003Ctd>数据库\u002F缓存可选 profile，默认连外部共享实例\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3 data-line=\"50\" id=\"2.1 为什么 API 与 Worker 必须是两个进程\">2.1 为什么 API 与 Worker 必须是两个进程\u003C\u002Fh3>\n\u003Cp data-line=\"52\">这是整套架构的\u003Cstrong>分水岭\u003C\u002Fstrong>。CPU 推理会占满核心几十秒到几分钟，如果放在 API 进程里，一个转写任务就能把整个 Web 服务卡死。\u003C\u002Fp>\n\u003Cp data-line=\"54\">代价是：\u003Cstrong>API 进程无法直接观测 Worker 的进度\u003C\u002Fstrong>。所以&quot;实时进度&quot;这个看似前端的问题，本质上是一个跨进程通信问题——它只能通过 Redis 解决。后面 §5 会看到，这条约束推导出的一连串设计决策，占了这套系统一半的坑。\u003C\u002Fp>\n\u003Ch3 data-line=\"56\" id=\"2.2 状态机与进度分段\">2.2 状态机与进度分段\u003C\u002Fh3>\n\n        \u003Cdetails  data-line=\"58\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-\" language=>\u003Cspan class=\"md-editor-code-block\">PENDING ──▶ PROCESSING ──▶ SUCCESS\n\u003Cspan class=\"hljs-code\">                       └──▶ FAILED (+ error_message)\n\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"63\">进度条被切成四段，每段含义明确：\u003C\u002Fp>\n\u003Ctable data-line=\"65\">\n\u003Cthead data-line=\"65\">\n\u003Ctr data-line=\"65\">\n\u003Cth>区间\u003C\u002Fth>\n\u003Cth>阶段\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"67\">\n\u003Ctr data-line=\"67\">\n\u003Ctd>0 → 5%\u003C\u002Ftd>\n\u003Ctd>取任务、置 PROCESSING\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"68\">\n\u003Ctd>5 → 15%\u003C\u002Ftd>\n\u003Ctd>FFmpeg 转码（或复用已有 WAV）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"69\">\n\u003Ctd>15 → 90%\u003C\u002Ftd>\n\u003Ctd>Whisper 解码（分片模式下按\u003Cstrong>音频绝对时间\u003C\u002Fstrong>线性映射）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"70\">\n\u003Ctd>90 → 100%\u003C\u002Ftd>\n\u003Ctd>落库分段、终态收尾\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"72\">划分依据只有一个：\u003Cstrong>让用户知道现在卡在哪一类事情上\u003C\u002Fstrong>。转码慢是 IO，解码慢是模型，两者对用户的暗示完全不同。\u003C\u002Fp>\n\u003Chr data-line=\"74\">\n\u003Ch2 data-line=\"76\" id=\"3. 关键实现一：异步转写流水线\">3. 关键实现一：异步转写流水线\u003C\u002Fh2>\n\u003Ch3 data-line=\"78\" id=\"3.1 统一音频规整：先消灭格式差异\">3.1 统一音频规整：先消灭格式差异\u003C\u002Fh3>\n\u003Cp data-line=\"80\">任意输入先由 FFmpeg 统一转成 \u003Ccode>WAV 16kHz \u002F 单声道 \u002F PCM S16LE\u003C\u002Fcode>，再喂给模型。这一步看着笨，但它把&quot;格式差异&quot;这个无穷问题压缩成了一个常量——\u003Cstrong>你不用去猜某种容器 + 某种编码在某个版本的解码器里会出什么岔子\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"82\">一个容易踩的点：\u003Cstrong>切片时 \u003Ccode>-ss\u003C\u002Fcode> 必须放在 \u003Ccode>-i\u003C\u002Fcode> 之后\u003C\u002Fstrong>。放在前面按关键帧对齐，全片时间戳会整体偏移；放在后面才是采样级精确。\u003C\u002Fp>\n\u003Ch3 data-line=\"84\" id=\"3.2 WAV 复用的安全条件\">3.2 WAV 复用的安全条件\u003C\u002Fh3>\n\u003Cp data-line=\"86\">已转码的 WAV 可以复用，省一次 FFmpeg。但复用有个前提：\u003Cstrong>开过音频增强的任务不能复用未增强的 WAV\u003C\u002Fstrong>。\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"88\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">reusable = (\n    \u003Cspan class=\"hljs-keyword\">not\u003C\u002Fspan> task.enhance_audio          \u003Cspan class=\"hljs-comment\"># 增强任务必须重新转码\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">and\u003C\u002Fspan> os.path.isfile(wav_path)\n    \u003Cspan class=\"hljs-keyword\">and\u003C\u002Fspan> os.path.getsize(wav_path) &gt; \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>\n)\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"96\">这行代码的价值在于它拦住的是一类\u003Cstrong>静默错误\u003C\u002Fstrong>：如果复用错了，任务照样 SUCCESS、进度照样 100%，只是转写质量悄悄变差——这种 bug 靠验收清单是抓不到的，只能靠不变量。\u003C\u002Fp>\n\u003Ch3 data-line=\"98\" id=\"3.3 长音频分片：目标 90s \u002F 上限 120s \u002F 重叠 1.5s\">3.3 长音频分片：目标 90s \u002F 上限 120s \u002F 重叠 1.5s\u003C\u002Fh3>\n\n        \u003Cdetails  data-line=\"100\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">step = \u003Cspan class=\"hljs-built_in\">min\u003C\u002Fspan>(\u003Cspan class=\"hljs-built_in\">max\u003C\u002Fspan>(TARGET_SEC, \u003Cspan class=\"hljs-number\">30\u003C\u002Fspan>), MAX_SEC)          \u003Cspan class=\"hljs-comment\"># 90s，夹在 [30, 120]\u003C\u002Fspan>\noverlap = \u003Cspan class=\"hljs-built_in\">max\u003C\u002Fspan>(\u003Cspan class=\"hljs-number\">0.0\u003C\u002Fspan>, \u003Cspan class=\"hljs-built_in\">min\u003C\u002Fspan>(OVERLAP_SEC, \u003Cspan class=\"hljs-number\">5.0\u003C\u002Fspan>))         \u003Cspan class=\"hljs-comment\"># 1.5s\u003C\u002Fspan>\n\nranges, start = [], \u003Cspan class=\"hljs-number\">0.0\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">while\u003C\u002Fspan> start &lt; total - \u003Cspan class=\"hljs-number\">1.0\u003C\u002Fspan>:\n    end = \u003Cspan class=\"hljs-built_in\">min\u003C\u002Fspan>(start + step, total)\n    ranges.append((start, end))\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> end &gt;= total:\n        \u003Cspan class=\"hljs-keyword\">break\u003C\u002Fspan>\n    start = \u003Cspan class=\"hljs-built_in\">max\u003C\u002Fspan>(start + \u003Cspan class=\"hljs-number\">1.0\u003C\u002Fspan>, end - overlap)       \u003Cspan class=\"hljs-comment\"># 保证严格前进，不会死循环\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"113\">三个数字各有理由：\u003C\u002Fp>\n\u003Cul data-line=\"115\">\n\u003Cli data-line=\"115\">\u003Cstrong>90s 目标 \u002F 120s 上限\u003C\u002Fstrong>：单次解码的内存与延迟可控，同时不至于把分片切得太碎导致边界问题变多；\u003C\u002Fli>\n\u003Cli data-line=\"116\">\u003Cstrong>1.5s 重叠\u003C\u002Fstrong>：跨边界的词至少在一个分片里是完整的；\u003C\u002Fli>\n\u003Cli data-line=\"117\">\u003Cstrong>\u003Ccode>max(start + 1.0, ...)\u003C\u002Fcode>\u003C\u002Fstrong>：\u003Ccode>while\u003C\u002Fcode> 循环的安全阀，避免重叠参数配错时步长退化为 0 而死循环。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"119\">\u003Cstrong>关键优化：切片在内存里做，不落盘。\u003C\u002Fstrong> WAV 只读一次到 PCM 数组，之后按 \u003Ccode>[start*16000 : end*16000]\u003C\u002Fcode> 直接切片。老做法是逐片调用 FFmpeg 输出临时文件、再解码、再删除——一圈下来纯属浪费，还多了临时文件清理的责任。\u003C\u002Fp>\n\u003Ch3 data-line=\"121\" id=\"3.4 分片重叠去重\">3.4 分片重叠去重\u003C\u002Fh3>\n\u003Cp data-line=\"123\">重叠带来重复。去重不能只看时间（那会把&quot;跨边界的后半句&quot;一起裁掉），要\u003Cstrong>文字与词级时间同时确认\u003C\u002Fstrong>：完全落在已覆盖区间内的段直接丢弃；跨边界的段保留但把起点夹到游标之后（它仍带着前一片没覆盖到的内容）；文字冲突则保留并标记为&quot;待核对&quot;，而不是悄悄只裁时间。\u003C\u002Fp>\n\u003Ch3 data-line=\"125\" id=\"3.5 进度映射：一个害死过人的&quot;分母陷阱&quot;\">3.5 进度映射：一个害死过人的&quot;分母陷阱&quot;\u003C\u002Fh3>\n\u003Cp data-line=\"127\">这是本系统最典型的一个 bug，值得单独讲。\u003C\u002Fp>\n\u003Cp data-line=\"129\">\u003Ccode>progress_callback(processed_seconds, total_seconds)\u003C\u002Fcode> 里的 \u003Ccode>total_seconds\u003C\u002Fcode>，是\u003Cstrong>传给 Whisper 的那个文件\u003C\u002Fstrong>的时长——分片模式下就是分片自己（90s）。如果拿它当分母：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"131\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-comment\"># ❌ 错误：分母是分片时长\u003C\u002Fspan>\nfrac = processed \u002F total_seconds\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"136\">第 1 个分片很快跑到 100%，映射到 90%；\u003Cstrong>从第 2 个分片开始，\u003Ccode>min(chunk_offset + processed, ...)\u003C\u002Fcode> 被夹到分片时长，\u003Ccode>frac\u003C\u002Fcode> 恒为 1.0\u003C\u002Fstrong> → 进度在整个转写过程中\u003Cstrong>再也不会更新一次\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"138\">正确写法必须显式把整段时长传进来：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"140\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">absolute = \u003Cspan class=\"hljs-built_in\">min\u003C\u002Fspan>(chunk_offset + \u003Cspan class=\"hljs-built_in\">float\u003C\u002Fspan>(processed_seconds), total)   \u003Cspan class=\"hljs-comment\"># total = 整段时长\u003C\u002Fspan>\nfrac = \u003Cspan class=\"hljs-built_in\">min\u003C\u002Fspan>(\u003Cspan class=\"hljs-built_in\">max\u003C\u002Fspan>(absolute \u002F total, \u003Cspan class=\"hljs-number\">0.0\u003C\u002Fspan>), \u003Cspan class=\"hljs-number\">1.0\u003C\u002Fspan>)\nmapped = \u003Cspan class=\"hljs-number\">15\u003C\u002Fspan> + \u003Cspan class=\"hljs-built_in\">int\u003C\u002Fspan>(frac * \u003Cspan class=\"hljs-number\">75\u003C\u002Fspan>)                                     \u003Cspan class=\"hljs-comment\"># 15 -&gt; 90\u003C\u002Fspan>\n\u003Cspan class=\"hljs-comment\"># 节流不能吃掉区间上限：90 这个&quot;毕业值&quot;必须永远放行\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> mapped &gt;= \u003Cspan class=\"hljs-number\">90\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">or\u003C\u002Fspan> mapped - state[\u003Cspan class=\"hljs-string\">&quot;value&quot;\u003C\u002Fspan>] &gt;= \u003Cspan class=\"hljs-number\">3\u003C\u002Fspan>:\n    state[\u003Cspan class=\"hljs-string\">&quot;value&quot;\u003C\u002Fspan>] = mapped\n    _update_task_progress(session, task_id, progress=mapped)\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"150\">第二个陷阱在同两行里：\u003Ccode>if mapped - state[&quot;value&quot;] &gt;= 3\u003C\u002Fcode> 的节流会把最后一步（88\u002F89 → 90）挡掉，因为差值只有 1~2。\u003Cstrong>区间上限必须无条件放行\u003C\u002Fstrong>，否则界面永远停在 90% 不动。\u003C\u002Fp>\n\u003Cp data-line=\"152\">\u003Cstrong>症状识别\u003C\u002Fstrong>：任务在第一个分片内冲到约 90%，之后一动不动，但日志显示解码仍在继续。\u003C\u002Fp>\n\u003Ch3 data-line=\"154\" id=\"3.6 幂等：投递可以重复，执行必须安全\">3.6 幂等：投递可以重复，执行必须安全\u003C\u002Fh3>\n\u003Cp data-line=\"156\">Celery 配了 \u003Ccode>acks_late\u003C\u002Fcode>，崩溃\u002F重启后未确认的消息会被重新投回队列；而 API 侧还有一套&quot;启动恢复未完成任务&quot;的逻辑。两者叠加的后果是\u003Cstrong>同一个任务被投递 N 次\u003C\u002Fstrong>（实测一次事故累积 5 份副本，全部指向同一 task_id）。后果比&quot;浪费 CPU&quot;严重得多：\u003Cstrong>重复副本一旦失败，会把前一次的成功结果覆盖成 FAILED\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"158\">两道守卫：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"160\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-comment\"># 守卫 1：投递前先看 broker 里是否还有该任务的消息\u003C\u002Fspan>\nis_pending = is_task_message_pending(task_id)   \u003Cspan class=\"hljs-comment\"># 查 celery 列表 + unacked 哈希\u003C\u002Fspan>\n\n\u003Cspan class=\"hljs-comment\"># 守卫 2：worker 入口检查终态\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> task.status \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> (SUCCESS, FAILED):\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan>   \u003Cspan class=\"hljs-comment\"># 重复投递，直接退出\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"169\">还有一个坑：\u003Cstrong>\u003Ccode>PROCESSING → PENDING\u003C\u002Fcode> 的复位只能作用于&quot;确实要重派的&quot;任务\u003C\u002Fstrong>。把正在运行的任务复位成 PENDING，会让它的状态与进度长期错乱——因为运行中的写入只更新 progress，不会把 status 改回 PROCESSING。\u003C\u002Fp>\n\u003Ch3 data-line=\"171\" id=\"3.7 可取消\">3.7 可取消\u003C\u002Fh3>\n\u003Cp data-line=\"173\">CPU 任务必须能取消，否则用户点错一次就得等十几分钟。取消检查被埋在两个粒度上：ASR 解码的每个分段之间、embedding 的每一批之间，且加了 2 秒节流（避免每次分段都查一次库）。\u003C\u002Fp>\n\u003Cp data-line=\"175\">诚实的边界：\u003Cstrong>单次模型调用是不可中断的\u003C\u002Fstrong>。分片粒度决定了取消的响应时间上界。\u003C\u002Fp>\n\u003Chr data-line=\"177\">\n\u003Ch2 data-line=\"179\" id=\"4. 关键实现二：实时进度推送（SSE）\">4. 关键实现二：实时进度推送（SSE）\u003C\u002Fh2>\n\u003Ch3 data-line=\"181\" id=\"4.1 为什么是 SSE 而不是 WebSocket\">4.1 为什么是 SSE 而不是 WebSocket\u003C\u002Fh3>\n\u003Ctable data-line=\"183\">\n\u003Cthead data-line=\"183\">\n\u003Ctr data-line=\"183\">\n\u003Cth>维度\u003C\u002Fth>\n\u003Cth>SSE\u003C\u002Fth>\n\u003Cth>WebSocket\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"185\">\n\u003Ctr data-line=\"185\">\n\u003Ctd>数据方向\u003C\u002Ftd>\n\u003Ctd>单向（服务端→客户端），\u003Cstrong>正好匹配进度推送\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>双向，能力过剩\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"186\">\n\u003Ctd>协议\u003C\u002Ftd>\n\u003Ctd>纯 HTTP，代理\u002F网关零改造\u003C\u002Ftd>\n\u003Ctd>需要 Upgrade 握手，中间件容易拦\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"187\">\n\u003Ctd>实现成本\u003C\u002Ftd>\n\u003Ctd>一个 \u003Ccode>StreamingResponse\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>独立协议栈 + 心跳 + 状态管理\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"188\">\n\u003Ctd>断线恢复\u003C\u002Ftd>\n\u003Ctd>浏览器原生有重连语义\u003C\u002Ftd>\n\u003Ctd>全自己写\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"190\">进度推送是\u003Cstrong>单向、低频、可丢弃\u003C\u002Fstrong>的：丢一帧进度不影响正确性，下一帧会覆盖。SSE 是这类场景的最优解。\u003C\u002Fp>\n\u003Cp data-line=\"192\">但 SSE 有三个必须自己填的坑，每个都踩过。\u003C\u002Fp>\n\u003Ch3 data-line=\"194\" id=\"4.2 Pub\u002FSub 没有重放：必须&quot;先订阅、后读快照&quot;\">4.2 Pub\u002FSub 没有重放：必须&quot;先订阅、后读快照&quot;\u003C\u002Fh3>\n\u003Cp data-line=\"196\">Redis Pub\u002FSub 是 fire-and-forget，\u003Cstrong>离线期间的消息不存在\u003C\u002Fstrong>。所以订阅顺序是硬要求：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"198\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-\" language=>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-bullet\">1.\u003C\u002Fspan> SUBSCRIBE 该任务的进度频道    ← 必须先做\n\u003Cspan class=\"hljs-bullet\">2.\u003C\u002Fspan> 从 MySQL 读当前行，作为 snapshot 首帧发出\n\u003Cspan class=\"hljs-bullet\">3.\u003C\u002Fspan> 转发后续 publish 的消息，直到终态\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"204\">顺序反过来的话，\u003Cstrong>订阅建立前发生的进度更新会永久丢失\u003C\u002Fstrong>，客户端于是能看到一个&quot;跳变&quot;甚至卡住不动。\u003C\u002Fp>\n\u003Cp data-line=\"206\">这引出第二条铁律：\u003Cstrong>MySQL 永远是唯一事实源，Redis 只是镜像\u003C\u002Fstrong>。Redis 挂了，转写必须照常跑完，只让进度降级——推送是 fire-and-forget，所有 Redis 异常都被吞掉并只记一条 debug 日志。\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"208\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">def\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">_publish_progress\u003C\u002Fspan>(\u003Cspan class=\"hljs-params\">task_id: \u003Cspan class=\"hljs-built_in\">int\u003C\u002Fspan>\u003C\u002Fspan>) -&gt; \u003Cspan class=\"hljs-literal\">None\u003C\u002Fspan>:\n    \u003Cspan class=\"hljs-string\">&quot;&quot;&quot;读回刚刚提交的行再发布 —— 保证订阅者收到的值与库里完全一致。&quot;&quot;&quot;\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">try\u003C\u002Fspan>:\n        \u003Cspan class=\"hljs-keyword\">with\u003C\u002Fspan> SyncSessionFactory() \u003Cspan class=\"hljs-keyword\">as\u003C\u002Fspan> read_session:        \u003Cspan class=\"hljs-comment\"># 一次性 session\u003C\u002Fspan>\n            row = read_session.execute(select(...).where(...)).first()\n        \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> row \u003Cspan class=\"hljs-keyword\">is\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">None\u003C\u002Fspan>:\n            \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan>\n        publish_task_progress(task_id, {...})\n    \u003Cspan class=\"hljs-keyword\">except\u003C\u002Fspan> Exception \u003Cspan class=\"hljs-keyword\">as\u003C\u002Fspan> exc:\n        logger.debug(\u003Cspan class=\"hljs-string\">&quot;progress publish skipped: %s&quot;\u003C\u002Fspan>, exc)  \u003Cspan class=\"hljs-comment\"># 绝不冒泡\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"221\">注意那个 \u003Ccode>read_session\u003C\u002Fcode>：\u003Cstrong>不要在 \u003Ccode>commit()\u003C\u002Fcode> 之后复用同一个 session 做只读查询\u003C\u002Fstrong>。SQLAlchemy 的 autobegin 会立刻开一个新事务，而它要等\u003Cstrong>下一次 \u003Ccode>commit()\u003C\u002Fcode> 才结束\u003C\u002Fstrong>。实测出现过 350+ 秒的 \u003Ccode>idle in transaction\u003C\u002Fcode>，持有 REPEATABLE READ 快照、阻碍 undo purge（\u003Ccode>History list length\u003C\u002Fcode> 只增不减）。只读读回一律用一次性 session。\u003C\u002Fp>\n\u003Ch3 data-line=\"223\" id=\"4.3 心跳必须是&quot;具名数据帧&quot;，不能是注释帧\">4.3 心跳必须是&quot;具名数据帧&quot;，不能是注释帧\u003C\u002Fh3>\n\u003Cp data-line=\"225\">SSE 规范里，以 \u003Ccode>:\u003C\u002Fcode> 开头的是注释帧。它有个致命特性：\u003Cstrong>浏览器的 EventSource 解析器会完整丢弃注释行\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"227\">于是出现了一个极隐蔽的场景——\u003Cstrong>没有 FIN 的连接中断\u003C\u002Fstrong>（笔记本休眠、Wi-Fi 切换、NAT 表项老化）：\u003C\u002Fp>\n\u003Ctable data-line=\"229\">\n\u003Cthead data-line=\"229\">\n\u003Ctr data-line=\"229\">\n\u003Cth>\u003C\u002Fth>\n\u003Cth>用注释帧做心跳\u003C\u002Fth>\n\u003Cth>用具名数据帧做心跳\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"231\">\n\u003Ctr data-line=\"231\">\n\u003Ctd>中间设备\u003C\u002Ftd>\n\u003Ctd>保持连接不老化\u003C\u002Ftd>\n\u003Ctd>保持连接\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"232\">\n\u003Ctd>浏览器 \u003Ccode>onerror\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>不触发\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>不触发\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"233\">\n\u003Ctd>前端能否感知\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>完全不能\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>能（收到 \u003Ccode>ping\u003C\u002Fcode> 事件即可计时）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"234\">\n\u003Ctd>结果\u003C\u002Ftd>\n\u003Ctd>界面无限冻结，重连永不启动\u003C\u002Ftd>\n\u003Ctd>静默看门狗判死 → 走重连阶梯\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"236\">所以心跳改成：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"238\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">def\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">_sse\u003C\u002Fspan>(\u003Cspan class=\"hljs-params\">event: \u003Cspan class=\"hljs-built_in\">str\u003C\u002Fspan>, data: \u003Cspan class=\"hljs-built_in\">dict\u003C\u002Fspan>\u003C\u002Fspan>) -&gt; \u003Cspan class=\"hljs-built_in\">str\u003C\u002Fspan>:\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">f&quot;event: \u003Cspan class=\"hljs-subst\">{event}\u003C\u002Fspan>\\ndata: \u003Cspan class=\"hljs-subst\">{json.dumps(data, ensure_ascii=\u003Cspan class=\"hljs-literal\">False\u003C\u002Fspan>)}\u003C\u002Fspan>\\n\\n&quot;\u003C\u002Fspan>\n\n\u003Cspan class=\"hljs-comment\"># 空闲超过 HEARTBEAT_SECONDS 就发一帧具名事件\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">yield\u003C\u002Fspan> _sse(\u003Cspan class=\"hljs-string\">&quot;ping&quot;\u003C\u002Fspan>, {\u003Cspan class=\"hljs-string\">&quot;ts&quot;\u003C\u002Fspan>: \u003Cspan class=\"hljs-built_in\">int\u003C\u002Fspan>(time.time())})\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"246\">\u003Ccode>event: ping\u003C\u002Fcode> 是\u003Cstrong>具名事件\u003C\u002Fstrong>，不会触发 \u003Ccode>onmessage\u003C\u002Fcode>（只触发 \u003Ccode>addEventListener('ping')\u003C\u002Fcode>），因此与 \u003Ccode>snapshot\u003C\u002Fcode> \u002F \u003Ccode>progress\u003C\u002Fcode> \u002F \u003Ccode>done\u003C\u002Fcode> 三个业务事件互不干扰。这是个很干净的技巧：\u003Cstrong>用同一条流既保活又给客户端一个可观测的活性信号\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"248\">心跳周期取 \u003Cstrong>20 秒\u003C\u002Fstrong>：必须 ≤ 30s（见过的最短中间代理 idle timeout），又要远小于各跳的超时。\u003C\u002Fp>\n\u003Ch3 data-line=\"250\" id=\"4.4 每一个 Redis await 都必须有界\">4.4 每一个 Redis await 都必须有界\u003C\u002Fh3>\n\u003Cp data-line=\"252\">一条无界（或半开）的连接会\u003Cstrong>挂住整个生成器\u003C\u002Fstrong>——心跳停发、连接被中间设备掐断，而服务端毫不知情。所以：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"254\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">try\u003C\u002Fspan>:\n    message = \u003Cspan class=\"hljs-keyword\">await\u003C\u002Fspan> asyncio.wait_for(\n        pubsub.get_message(ignore_subscribe_messages=\u003Cspan class=\"hljs-literal\">True\u003C\u002Fspan>, timeout=\u003Cspan class=\"hljs-number\">1.0\u003C\u002Fspan>),  \u003Cspan class=\"hljs-comment\"># 内层：1s 空闲是正常的\u003C\u002Fspan>\n        timeout=\u003Cspan class=\"hljs-number\">2.0\u003C\u002Fspan>,                                                      \u003Cspan class=\"hljs-comment\"># 外层：真卡住的判据\u003C\u002Fspan>\n    )\n\u003Cspan class=\"hljs-keyword\">except\u003C\u002Fspan> asyncio.TimeoutError:\n    \u003Cspan class=\"hljs-comment\"># 真卡住 → 关掉 pubsub，转流内 DB 轮询\u003C\u002Fspan>\n    ...\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"265\">\u003Cstrong>两层的语义必须分清\u003C\u002Fstrong>：内层返回 \u003Ccode>None\u003C\u002Fcode> 是&quot;频道空闲没消息&quot;（完全正常）；外层 \u003Ccode>wait_for\u003C\u002Fcode> 超时才是&quot;Redis 真的挂了&quot;。混在一起会导致空闲频道被误判为故障，或者真故障时静默停住。\u003C\u002Fp>\n\u003Cp data-line=\"267\">顺带一提：异步 Redis 客户端也必须配 \u003Ccode>socket_connect_timeout\u003C\u002Fcode> \u002F \u003Ccode>socket_timeout\u003C\u002Fcode> \u002F \u003Ccode>health_check_interval\u003C\u002Fcode>——最初只有同步客户端配了，异步的裸奔，这就是上面那个坑的源头。\u003C\u002Fp>\n\u003Cp data-line=\"269\">同时提供\u003Cstrong>三层降级链\u003C\u002Fstrong>：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"271\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-\" language=>\u003Cspan class=\"md-editor-code-block\">Redis Pub\u002FSub 推送\n  └─ 失败 → 同一流内每秒轮询 MySQL\n\u003Cspan class=\"hljs-code\">       └─ 失败 → 前端降级到 HTTP 轮询接口\n\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"277\">生成器里还有连接寿命上限（约 1 小时，略小于任务执行上限）。到期\u003Cstrong>正常收尾关闭\u003C\u002Fstrong>，让客户端重连——重连后首帧 \u003Ccode>snapshot\u003C\u002Fcode> 会把状态补齐，所以\u003Cstrong>断连零损失\u003C\u002Fstrong>。这比留一条孤儿订阅好得多，尤其是经历了静默代理掉线之后。\u003C\u002Fp>\n\u003Ch3 data-line=\"279\" id=\"4.5 终态必须主动结束响应\">4.5 终态必须主动结束响应\u003C\u002Fh3>\n\u003Cp data-line=\"281\">如果任务已经结束但连接还开着，\u003Ccode>EventSource\u003C\u002Fcode> 会\u003Cstrong>对已完成的任务无限重连\u003C\u002Fstrong>。所以：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"283\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> initial[\u003Cspan class=\"hljs-string\">&quot;status&quot;\u003C\u002Fspan>] \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> TERMINAL_STATUSES:\n    \u003Cspan class=\"hljs-keyword\">yield\u003C\u002Fspan> _sse(\u003Cspan class=\"hljs-string\">&quot;done&quot;\u003C\u002Fspan>, initial)\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan>                                          \u003Cspan class=\"hljs-comment\"># 连接进来时就已经完成\u003C\u002Fspan>\n\n\u003Cspan class=\"hljs-keyword\">while\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">True\u003C\u002Fspan>:\n    ...\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> payload.get(\u003Cspan class=\"hljs-string\">&quot;status&quot;\u003C\u002Fspan>) \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> TERMINAL_STATUSES:\n        \u003Cspan class=\"hljs-keyword\">yield\u003C\u002Fspan> _sse(\u003Cspan class=\"hljs-string\">&quot;done&quot;\u003C\u002Fspan>, payload)\n        \u003Cspan class=\"hljs-keyword\">break\u003C\u002Fspan>                                       \u003Cspan class=\"hljs-comment\"># 运行中到达终态\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Ch3 data-line=\"295\" id=\"4.6 前端：自己接管重连\">4.6 前端：自己接管重连\u003C\u002Fh3>\n\u003Cp data-line=\"297\">浏览器的原生重连\u003Cstrong>用不了\u003C\u002Fstrong>：间隔由服务端 \u003Ccode>retry:\u003C\u002Fcode> 决定，是个\u003Cstrong>恒定值\u003C\u002Fstrong>，做不出降频，也无法计数\u002F封顶。所以直接把原生实例关掉，自己写重连循环：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"299\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">ts\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-ts\" language=ts>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-variable constant_\">SSE_RECONNECT_DELAYS\u003C\u002Fspan> = [\u003Cspan class=\"hljs-number\">1000\u003C\u002Fspan>, \u003Cspan class=\"hljs-number\">2000\u003C\u002Fspan>, \u003Cspan class=\"hljs-number\">4000\u003C\u002Fspan>, \u003Cspan class=\"hljs-number\">8000\u003C\u002Fspan>, \u003Cspan class=\"hljs-number\">15000\u003C\u002Fspan>, \u003Cspan class=\"hljs-number\">30000\u003C\u002Fspan>]  \u003Cspan class=\"hljs-comment\">\u002F\u002F 6 档降频\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-variable constant_\">SSE_JITTER\u003C\u002Fspan> = \u003Cspan class=\"hljs-number\">0.2\u003C\u002Fspan>                    \u003Cspan class=\"hljs-comment\">\u002F\u002F ±20% 抖动，避免大量客户端齐步重试\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-variable constant_\">SSE_HEALTHY_MS\u003C\u002Fspan> = \u003Cspan class=\"hljs-number\">30000\u003C\u002Fspan>              \u003Cspan class=\"hljs-comment\">\u002F\u002F 连接存活超过此值才重置重试预算\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-variable constant_\">SSE_SILENCE_TIMEOUT\u003C\u002Fspan> = \u003Cspan class=\"hljs-variable constant_\">HEARTBEAT\u003C\u002Fspan> * \u003Cspan class=\"hljs-number\">3\u003C\u002Fspan> * \u003Cspan class=\"hljs-number\">1000\u003C\u002Fspan>   \u003Cspan class=\"hljs-comment\">\u002F\u002F 静默看门狗：60s\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-variable constant_\">SSE_REPROBE_MS\u003C\u002Fspan> = \u003Cspan class=\"hljs-number\">60000\u003C\u002Fspan>              \u003Cspan class=\"hljs-comment\">\u002F\u002F 降级到轮询后，每分钟探一次 SSE\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"307\">四个关键决策：\u003C\u002Fp>\n\u003Cp data-line=\"309\">\u003Cstrong>（1）\u003Ccode>onerror\u003C\u002Fcode> 必须按 \u003Ccode>readyState\u003C\u002Fcode> 分支\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cul data-line=\"311\">\n\u003Cli data-line=\"311\">\u003Ccode>CONNECTING(0)\u003C\u002Fcode> = 可恢复（网络抖动、代理掐链、寿命到期正常收尾）→ 重连；\u003C\u002Fli>\n\u003Cli data-line=\"312\">\u003Ccode>CLOSED(2)\u003C\u002Fcode> = 致命（HTTP 非 200、Content-Type 不对，如 404\u002F401\u002F5xx）→ 浏览器不会重连，\u003Cstrong>直接降级轮询，别白等 6 次退避\u003C\u002Fstrong>。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"314\">\u003Cstrong>（2）必须在 \u003Ccode>onerror\u003C\u002Fcode> 里同步销毁原生实例\u003C\u002Fstrong>\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"316\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">ts\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-ts\" language=ts>\u003Cspan class=\"md-editor-code-block\">source.\u003Cspan class=\"hljs-property\">onerror\u003C\u002Fspan> = \u003Cspan class=\"hljs-function\">() =&gt;\u003C\u002Fspan> {\n  source.\u003Cspan class=\"hljs-property\">onerror\u003C\u002Fspan> = \u003Cspan class=\"hljs-literal\">null\u003C\u002Fspan>\n  source.\u003Cspan class=\"hljs-title function_\">close\u003C\u002Fspan>()      \u003Cspan class=\"hljs-comment\">\u002F\u002F 否则浏览器会在 retry: 毫秒后自行重连 → 双连接、双计数\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-title function_\">scheduleReconnect\u003C\u002Fspan>()\n}\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"324\">\u003Cstrong>（3）健康判定必须同时要求&quot;活够久&quot;和&quot;收到过帧&quot;\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp data-line=\"326\">这是最容易写错的一处。只用&quot;连通时长&quot;判定时，&quot;服务端接受连接但永不发帧&quot;的连接会被 60s 静默看门狗判死后重连，而新连接存活又已过 30s → \u003Cstrong>重试计数被无限重置 → 永远停在第 1 档，永远降级不到轮询\u003C\u002Fstrong>。所以：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"328\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">ts\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-ts\" language=ts>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">const\u003C\u002Fspan> healthy = (\u003Cspan class=\"hljs-title class_\">Date\u003C\u002Fspan>.\u003Cspan class=\"hljs-title function_\">now\u003C\u002Fspan>() - openedAt) &gt; \u003Cspan class=\"hljs-variable constant_\">SSE_HEALTHY_MS\u003C\u002Fspan> &amp;&amp; lastFrameAt &gt; \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"332\">\u003Ccode>lastFrameAt &gt; 0\u003C\u002Fcode> 表示&quot;至少收到过一帧&quot;（心跳也算）。\u003Cstrong>沉默的连接不算健康。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp data-line=\"334\">\u003Cstrong>（4）重连成功即用 \u003Ccode>snapshot\u003C\u002Fcode> 覆盖本地状态\u003C\u002Fstrong>。Pub\u002FSub 无重放，断线窗口内的进度只能靠服务端首帧补回，前端无需再补一次 HTTP 请求。\u003C\u002Fp>\n\u003Cp data-line=\"336\">另外，\u003Ccode>document.hidden\u003C\u002Fcode> 时\u003Cstrong>不判死\u003C\u002Fstrong>——后台标签页本就不该重连。\u003C\u002Fp>\n\u003Ch3 data-line=\"338\" id=\"4.7 事故：uvicorn 的优雅关闭被一条 SSE 流永久挂住\">4.7 事故：uvicorn 的优雅关闭被一条 SSE 流永久挂住\u003C\u002Fh3>\n\u003Cp data-line=\"340\">这是本系统最&quot;反直觉&quot;的一次事故，值得完整记录。\u003C\u002Fp>\n\u003Cp data-line=\"342\">\u003Cstrong>现象\u003C\u002Fstrong>（五个特征同时出现）：\u003C\u002Fp>\n\u003Col data-line=\"344\">\n\u003Cli data-line=\"344\">端口仍在 LISTENING，TCP 能握手；\u003C\u002Fli>\n\u003Cli data-line=\"345\">任何请求（包括文档页）都无响应；\u003C\u002Fli>\n\u003Cli data-line=\"346\">应用日志彻底静默；\u003C\u002Fli>\n\u003Cli data-line=\"347\">\u003Ccode>netstat\u003C\u002Fcode> 里服务端口的 ESTABLISHED \u003Cstrong>只增不减\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"348\">进程 CPU 空转。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp data-line=\"350\">极容易被误判成&quot;后端挂了&quot;或&quot;服务慢了&quot;，实际机制是：\u003C\u002Fp>\n\u003Cul data-line=\"352\">\n\u003Cli data-line=\"352\">uvicorn 的 \u003Ccode>timeout_graceful_shutdown\u003C\u002Fcode> \u003Cstrong>默认值是 \u003Ccode>None\u003C\u002Fcode>，即无限等待\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"353\">一条 SSE 流就是一条\u003Cstrong>永不完成的 in-flight 请求\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"354\">生成器只监听 \u003Ccode>request.is_disconnected()\u003C\u002Fcode>，而 uvicorn 在&quot;响应已开始&quot;时 \u003Ccode>shutdown()\u003C\u002Fcode> 会直接 return → \u003Ccode>is_disconnected()\u003C\u002Fcode> \u003Cstrong>永远为 \u003Ccode>False\u003C\u002Fcode>\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"355\">于是关闭流程永久停住 → 只能用强杀重启。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"357\">\u003Cstrong>修法\u003C\u002Fstrong>：三处启动命令统一加上：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"359\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-\" language=>\u003Cspan class=\"md-editor-code-block\">uvicorn ... \u003Cspan class=\"hljs-attr\">--timeout-graceful-shutdown\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">5\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"363\">取 5 秒的理由是收益\u002F代价比：SSE 客户端有重连阶梯、重连后首帧补齐状态，\u003Cstrong>优雅等待毫无收益\u003C\u002Fstrong>；而 5s 短于容器默认的 10s \u003Ccode>stop_grace_period\u003C\u002Fcode>，容器不会走到被 SIGKILL。\u003C\u002Fp>\n\u003Cp data-line=\"365\">\u003Cstrong>附带一条\u003C\u002Fstrong>：超时取消会在日志留下 \u003Ccode>Application shutdown failed\u003C\u002Fcode> + \u003Ccode>CancelledError\u003C\u002Fcode>——\u003Cstrong>这是预期内的\u003C\u002Fstrong>，进程正常退出，不要去&quot;修&quot;它。\u003C\u002Fp>\n\u003Ch3 data-line=\"367\" id=\"4.8 端点自身的三个硬约束\">4.8 端点自身的三个硬约束\u003C\u002Fh3>\n\u003Cp data-line=\"369\">\u003Cstrong>（1）SSE 端点绝不能注入常规的 DB 依赖\u003C\u002Fstrong>。长连接会把连接池占满（池子只有 10 条）。必须在生成器内部用\u003Cstrong>短生命周期 session\u003C\u002Fstrong>，用完即还。\u003C\u002Fp>\n\u003Cp data-line=\"371\">\u003Cstrong>（2）鉴权要在流建立之前完成\u003C\u002Fstrong>。这样未登录请求得到的是干净的 401，而不是一个 200 状态码然后流里发个错误帧——后者会让前端拿到一个&quot;成功建立但内容诡异&quot;的连接。\u003C\u002Fp>\n\u003Cp data-line=\"373\">\u003Cstrong>（3）一条容易被忽略的牵连\u003C\u002Fstrong>：\u003Ccode>EventSource\u003C\u002Fcode> \u003Cstrong>没有设置请求头的 API\u003C\u002Fstrong>（只能改 \u003Ccode>withCredentials\u003C\u002Fcode>），播放器用的原生 \u003Ccode>&lt;audio&gt;\u003C\u002Fcode> 元素同样不能自定义请求头。这意味着\u003Cstrong>会话凭证不能放在 Authorization 头里\u003C\u002Fstrong>，必须是浏览器自动携带的那种；否则音频播放与进度流这两块核心功能会双双 401，而&quot;接口用 curl 测着都正常&quot;——因为漏掉的恰是浏览器里那两个发不出头的消费者。另外，快照必须按调用方的归属范围过滤，否则进度流本身会变成一个&quot;探测别人任务状态&quot;的旁路。\u003C\u002Fp>\n\u003Chr data-line=\"375\">\n\u003Ch2 data-line=\"377\" id=\"5. 关键实现三：转写精度\">5. 关键实现三：转写精度\u003C\u002Fh2>\n\u003Cp data-line=\"379\">CPU-only 的现实决定了：\u003Cstrong>提升精度的杠杆是上下文与词表，不是更大的模型\u003C\u002Fstrong>。实测把 \u003Ccode>small\u003C\u002Fcode> 换成 \u003Ccode>large-v3\u003C\u002Fcode>，一首歌的 9 处同音错误只改对 2 处，却新造了一处幻觉，而耗时翻了好几倍。下面四件事\u003Cstrong>零额外算力\u003C\u002Fstrong>，优先级全部高于换模型。\u003C\u002Fp>\n\u003Ch3 data-line=\"381\" id=\"5.1 VAD 会&quot;饿死&quot;音乐类音频，必须留自适应回退\">5.1 VAD 会&quot;饿死&quot;音乐类音频，必须留自适应回退\u003C\u002Fh3>\n\u003Cp data-line=\"383\">\u003Ccode>vad_filter=True\u003C\u002Fcode> 用的是为\u003Cstrong>语音\u003C\u002Fstrong>训练的 Silero VAD。它在一首 272.4 秒的歌里只判出 \u003Cstrong>3.1 秒\u003C\u002Fstrong>语音（\u003Cstrong>1.1%\u003C\u002Fstrong>）→ 送进模型只剩 3 秒 → 只转出 9 个字。\u003C\u002Fp>\n\u003Cp data-line=\"385\">\u003Cstrong>症状特征\u003C\u002Fstrong>：任务 \u003Ccode>SUCCESS\u003C\u002Fcode>、进度 100%，但结果只有几个字符，时间轴大片空白。极易被误判成&quot;模型不行&quot;。\u003C\u002Fp>\n\u003Cp data-line=\"387\">回退判据用一个很省的办法——faster-whisper 自己已经算好了 \u003Ccode>info.duration_after_vad\u003C\u002Fcode>，\u003Cstrong>不需要额外跑一遍 VAD\u003C\u002Fstrong>：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"389\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">def\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">_vad_starved\u003C\u002Fspan>(\u003Cspan class=\"hljs-params\">info, segments\u003C\u002Fspan>) -&gt; \u003Cspan class=\"hljs-built_in\">bool\u003C\u002Fspan>:\n    total = \u003Cspan class=\"hljs-built_in\">float\u003C\u002Fspan>(\u003Cspan class=\"hljs-built_in\">getattr\u003C\u002Fspan>(info, \u003Cspan class=\"hljs-string\">&quot;duration&quot;\u003C\u002Fspan>, \u003Cspan class=\"hljs-number\">0.0\u003C\u002Fspan>) \u003Cspan class=\"hljs-keyword\">or\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">0.0\u003C\u002Fspan>)\n    after_vad = \u003Cspan class=\"hljs-built_in\">getattr\u003C\u002Fspan>(info, \u003Cspan class=\"hljs-string\">&quot;duration_after_vad&quot;\u003C\u002Fspan>, \u003Cspan class=\"hljs-literal\">None\u003C\u002Fspan>)\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> after_vad \u003Cspan class=\"hljs-keyword\">is\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">None\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">or\u003C\u002Fspan> total &lt;= \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>:\n        \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">False\u003C\u002Fspan>\n    keep_ratio = \u003Cspan class=\"hljs-built_in\">float\u003C\u002Fspan>(after_vad) \u002F total\n    \u003Cspan class=\"hljs-comment\"># 恰好为 0 时故意不回退：没有语音可捞，关掉 VAD 只会让模型对纯音乐&quot;编造文本&quot;\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> keep_ratio &lt;= \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">or\u003C\u002Fspan> keep_ratio &gt;= \u003Cspan class=\"hljs-number\">0.15\u003C\u002Fspan>:\n        \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">False\u003C\u002Fspan>\n    chars = \u003Cspan class=\"hljs-built_in\">sum\u003C\u002Fspan>(\u003Cspan class=\"hljs-built_in\">len\u003C\u002Fspan>(seg[\u003Cspan class=\"hljs-string\">&quot;text&quot;\u003C\u002Fspan>]) \u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> seg \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> segments)\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> (chars \u002F total) &lt; \u003Cspan class=\"hljs-number\">0.5\u003C\u002Fspan>     \u003Cspan class=\"hljs-comment\"># 字\u002F秒\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"403\">三个设计决策：\u003C\u002Fp>\n\u003Cul data-line=\"405\">\n\u003Cli data-line=\"405\">\u003Cstrong>必须两个条件同时成立\u003C\u002Fstrong>（保留比例 &lt; 0.15 \u003Cstrong>且\u003C\u002Fstrong> 字符密度 &lt; 0.5 字\u002F秒）。只看比例会误伤&quot;1 小时会议、大量静音&quot;（比例低但文本很多）；只看字符数会误伤&quot;短音频本来就没几句话&quot;。\u003C\u002Fli>\n\u003Cli data-line=\"406\">\u003Cstrong>比例恰好为 0 时不回退\u003C\u002Fstrong>。没有语音可捞，关掉 VAD 重跑只会让模型对纯音乐\u002F噪声\u003Cstrong>编造文本\u003C\u002Fstrong>，比返回空更糟。\u003C\u002Fli>\n\u003Cli data-line=\"407\">\u003Cstrong>不要图省事去调低 VAD 阈值\u003C\u002Fstrong>（比如 0.25）。那是全局放宽，会给已经调好的会议场景引入更多噪声与幻觉。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"409\">回退那一遍还必须\u003Cstrong>丢掉 \u003Ccode>initial_prompt\u003C\u002Fcode> 和 hotwords，并关闭前文续接\u003C\u002Fstrong>——原因见下一条。\u003C\u002Fp>\n\u003Ch3 data-line=\"411\" id=\"5.2 initial_prompt 会&quot;泄漏&quot;进结果\">5.2 \u003Ccode>initial_prompt\u003C\u002Fcode> 会&quot;泄漏&quot;进结果\u003C\u002Fh3>\n\u003Cp data-line=\"413\">对 Whisper 来说，\u003Ccode>initial_prompt\u003C\u002Fcode> 是\u003Cstrong>已经说过的前文\u003C\u002Fstrong>，模型会顺着它继续解码。当先验与音频不匹配时（把&quot;这是一段包含多人对话的会议录音&quot;注入一首流行歌），模型\u003Cstrong>宁可续写 prompt 也不听音频\u003C\u002Fstrong>——实测输出里逐字出现了 prompt 自身的短语，还伴随&quot;身后的你身后的你…&quot;这种重复循环，置信度只有 0.19。\u003C\u002Fp>\n\u003Cp data-line=\"415\">三条规则：\u003C\u002Fp>\n\u003Col data-line=\"417\">\n\u003Cli data-line=\"417\">\u003Cstrong>回退分支（\u003Ccode>vad_filter=False\u003C\u002Fcode> 那一遍）必须去掉 prompt\u003C\u002Fstrong>——VAD 已经判定&quot;这不是语音&quot;，语音类先验同样失效；\u003C\u002Fli>\n\u003Cli data-line=\"418\">\u003Cstrong>文件名里的机器 ID 不要进 prompt\u003C\u002Fstrong>。手机／即时通讯工具导出的录音名往往是一串随机标识（长度 8 以上、数字占比偏高），它会被当成主题词注入并\u003Cstrong>泄漏到转写结果里\u003C\u002Fstrong>——实测在歌词输出中直接出现过文件名片段。用一条启发式规则过滤，同时保留正常短词：\u003C\u002Fli>\n\u003C\u002Fol>\n\n        \u003Cdetails  data-line=\"420\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">def\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">_looks_like_machine_id\u003C\u002Fspan>(\u003Cspan class=\"hljs-params\">token: \u003Cspan class=\"hljs-built_in\">str\u003C\u002Fspan>\u003C\u002Fspan>) -&gt; \u003Cspan class=\"hljs-built_in\">bool\u003C\u002Fspan>:\n    \u003Cspan class=\"hljs-string\">&quot;&quot;&quot;长且数字占比高的 token 是标识符，不是主题词。&quot;&quot;&quot;\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">len\u003C\u002Fspan>(token) &lt; \u003Cspan class=\"hljs-number\">8\u003C\u002Fspan>:\n        \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">False\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">sum\u003C\u002Fspan>(ch.isdigit() \u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> ch \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> token) \u002F \u003Cspan class=\"hljs-built_in\">len\u003C\u002Fspan>(token) &gt;= \u003Cspan class=\"hljs-number\">1\u003C\u002Fspan> \u002F \u003Cspan class=\"hljs-number\">3\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"428\">\u003Ccode>RTX4090\u003C\u002Fcode>、\u003Ccode>GPT4All\u003C\u002Fcode> 这类短名在长度门槛之下，不受影响。\u003C\u002Fp>\n\u003Col start=\"3\" data-line=\"430\">\n\u003Cli data-line=\"430\">\u003Cstrong>拿不准就先不注入\u003C\u002Fstrong>。&quot;没有 prompt 的干净结果&quot;永远优于&quot;带着错误先验的结果&quot;。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3 data-line=\"432\" id=\"5.3 用 tokenizer 真实预算管 prompt\">5.3 用 tokenizer 真实预算管 prompt\u003C\u002Fh3>\n\u003Cp data-line=\"434\">Whisper 会按 \u003Cstrong>约 224 token 截断\u003C\u002Fstrong> \u003Ccode>initial_prompt\u003C\u002Fcode>。如果按字符数裁剪，很可能术语还没进去就被截掉了。所以改成\u003Cstrong>用模型自己的 tokenizer 真实计数\u003C\u002Fstrong>，总预算 200 token，优先级顺序为：\u003Cstrong>近期上下文 &gt; 术语\u002Fhotwords &gt; 主题词\u003C\u002Fstrong>。\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"436\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">budget = \u003Cspan class=\"hljs-built_in\">min\u003C\u002Fspan>(\u003Cspan class=\"hljs-built_in\">max\u003C\u002Fspan>(\u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>, PROMPT_TOKEN_BUDGET), model.max_length \u002F\u002F \u003Cspan class=\"hljs-number\">2\u003C\u002Fspan> - \u003Cspan class=\"hljs-number\">1\u003C\u002Fspan>)\n\n\u003Cspan class=\"hljs-keyword\">def\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">encode\u003C\u002Fspan>(\u003Cspan class=\"hljs-params\">text\u003C\u002Fspan>):  \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> model.hf_tokenizer.encode(text).ids\n\u003Cspan class=\"hljs-keyword\">def\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">trim\u003C\u002Fspan>(\u003Cspan class=\"hljs-params\">text, allowance, tail=\u003Cspan class=\"hljs-literal\">False\u003C\u002Fspan>\u003C\u002Fspan>):\n    ids = encode(text)\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">len\u003C\u002Fspan>(ids) &lt;= allowance: \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> text\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> model.hf_tokenizer.decode(ids[-allowance:] \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> tail \u003Cspan class=\"hljs-keyword\">and\u003C\u002Fspan> allowance \u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan> ids[:allowance])\n\n\u003Cspan class=\"hljs-comment\"># 最后必须校验&quot;拼接后的整体&quot;，而不是分别校验各分量\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">while\u003C\u002Fspan> prompt \u003Cspan class=\"hljs-keyword\">and\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">len\u003C\u002Fspan>(encode((hotwords + \u003Cspan class=\"hljs-string\">&#x27; &#x27;\u003C\u002Fspan> + prompt).strip())) &gt; budget:\n    prompt = trim(prompt, \u003Cspan class=\"hljs-built_in\">len\u003C\u002Fspan>(encode(prompt)) - \u003Cspan class=\"hljs-number\">1\u003C\u002Fspan>, tail=\u003Cspan class=\"hljs-literal\">True\u003C\u002Fspan>)\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"450\">最后那个 \u003Ccode>while\u003C\u002Fcode> 循环很重要：分别预算的几段拼起来会\u003Cstrong>超标\u003C\u002Fstrong>，必须对拼接结果做二次收敛。\u003C\u002Fp>\n\u003Ch3 data-line=\"452\" id=\"5.4 跨分片上下文续接\">5.4 跨分片上下文续接\u003C\u002Fh3>\n\u003Cp data-line=\"454\">Whisper 内部只有约 30 秒窗口，而分片是 90 秒。每个分片独立解码 ⇒ \u003Cstrong>分片边界处&quot;上文&quot;归零\u003C\u002Fstrong> ⇒ 同一术语被反复猜成同音词（&quot;Issue → ISO&quot;、&quot;Commit → Commute&quot;）。\u003C\u002Fp>\n\u003Cp data-line=\"456\">修法很直接：把上一分片的文本尾部（180 字符，只保留最近一片，累积会撑爆预算）作为下一片的 \u003Ccode>initial_prompt\u003C\u002Fcode> 续接段：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"458\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">prev_tail = \u003Cspan class=\"hljs-string\">&quot;&quot;\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> idx, (chunk_start, chunk_end) \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">enumerate\u003C\u002Fspan>(ranges):\n    chunk_segments = transcribe(..., prev_text=prev_tail \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (chunked \u003Cspan class=\"hljs-keyword\">and\u003C\u002Fspan> idx &gt; \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>) \u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">None\u003C\u002Fspan>)\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> chunked:\n        prev_tail = \u003Cspan class=\"hljs-string\">&quot; &quot;\u003C\u002Fspan>.join(seg[\u003Cspan class=\"hljs-string\">&quot;text&quot;\u003C\u002Fspan>] \u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> seg \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> chunk_segments).strip()\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"466\">配合 \u003Ccode>condition_on_previous_text=True\u003C\u002Fcode>（负责片\u003Cstrong>内\u003C\u002Fstrong>分段之间的上下文），两者才能覆盖&quot;片内&quot;与&quot;跨片&quot;两级上下文。\u003C\u002Fp>\n\u003Cp data-line=\"468\">\u003Cstrong>副作用要管\u003C\u002Fstrong>：\u003Ccode>condition_on_previous_text=True\u003C\u002Fcode> 在长音频上偶发重复\u002F循环，靠温度回退 + \u003Ccode>compression_ratio_threshold\u003C\u002Fcode> + \u003Ccode>log_prob_threshold\u003C\u002Fcode> 兜住；而 \u003Cstrong>VAD 回退那一遍必须把它关回 \u003Ccode>False\u003C\u002Fcode>\u003C\u002Fstrong>——非语音没有&quot;上文&quot;可续，续写只会循环。\u003C\u002Fp>\n\u003Ch3 data-line=\"470\" id=\"5.5 局部二次解码：只重解&quot;值得重解&quot;的地方\">5.5 局部二次解码：只重解&quot;值得重解&quot;的地方\u003C\u002Fh3>\n\u003Cp data-line=\"472\">整段重跑太贵。所以只挑低置信的局部区域重解：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"474\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">ASR_RERUN_MAX_REGIONS = \u003Cspan class=\"hljs-number\">2\u003C\u002Fspan>          \u003Cspan class=\"hljs-comment\"># 每分片最多 2 处\u003C\u002Fspan>\nASR_RERUN_MAX_AUDIO_RATIO = \u003Cspan class=\"hljs-number\">0.2\u003C\u002Fspan>    \u003Cspan class=\"hljs-comment\"># 额外音频不超过该分片时长的 20%\u003C\u002Fspan>\nWHISPER_LOW_CONF_RERUN_BEAM_SIZE = \u003Cspan class=\"hljs-number\">10\u003C\u002Fspan>\nWHISPER_LOW_CONF_RERUN_MAX_AVG_LOGPROB = -\u003Cspan class=\"hljs-number\">1.0\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"481\">候选是否采纳，用\u003Cstrong>解码分数、重复度、非语音占比、字数变化\u003C\u002Fstrong>四者综合判定；\u003Cstrong>未改善就保留第一遍\u003C\u002Fstrong>：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"483\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (candidate\n        \u003Cspan class=\"hljs-keyword\">and\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">0.6\u003C\u002Fspan> * old_chars &lt;= new_chars &lt;= \u003Cspan class=\"hljs-number\">1.5\u003C\u002Fspan> * \u003Cspan class=\"hljs-built_in\">max\u003C\u002Fspan>(\u003Cspan class=\"hljs-number\">1\u003C\u002Fspan>, old_chars)     \u003Cspan class=\"hljs-comment\"># 字数没暴走\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-keyword\">and\u003C\u002Fspan> _candidate_score(candidate) &gt; _candidate_score(originals) + \u003Cspan class=\"hljs-number\">0.05\u003C\u002Fspan>):\n    segments = merge(segments, originals, candidate)\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"490\">字数区间那道门是防幻觉的关键——二次解码在低置信区域很容易&quot;编得更长&quot;，只靠分数会被骗过。\u003C\u002Fp>\n\u003Ch3 data-line=\"492\" id=\"5.6 三档质量档位\">5.6 三档质量档位\u003C\u002Fh3>\n\u003Cp data-line=\"494\">把精度\u002F速度的权衡交给用户，但给出有意义的默认值：\u003C\u002Fp>\n\u003Ctable data-line=\"496\">\n\u003Cthead data-line=\"496\">\n\u003Ctr data-line=\"496\">\n\u003Cth>档位\u003C\u002Fth>\n\u003Cth style=\"text-align:right\">beam\u003C\u002Fth>\n\u003Cth>词级时间戳\u003C\u002Fth>\n\u003Cth>局部重试\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"498\">\n\u003Ctr data-line=\"498\">\n\u003Ctd>快速\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">2\u003C\u002Ftd>\n\u003Ctd>仅开说话人分离时\u003C\u002Ftd>\n\u003Ctd>关闭\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"499\">\n\u003Ctd>\u003Cstrong>均衡（默认）\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">5\u003C\u002Ftd>\n\u003Ctd>开启\u003C\u002Ftd>\n\u003Ctd>有预算上限\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"500\">\n\u003Ctd>精细\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">8\u003C\u002Ftd>\n\u003Ctd>开启\u003C\u002Ftd>\n\u003Ctd>有预算上限\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"502\">\u003Cstrong>为什么默认是均衡而不是快速\u003C\u002Fstrong>：快速档的准确性尚未经过人工参考稿评估，而它省下的时间远小于&quot;结果需要重跑&quot;的代价。\u003C\u002Fp>\n\u003Ch3 data-line=\"504\" id=\"5.7 缓存键必须&quot;精确到请求&quot;\">5.7 缓存键必须&quot;精确到请求&quot;\u003C\u002Fh3>\n\u003Cp data-line=\"506\">缓存是纯计算加速，\u003Cstrong>MySQL 里的发布结果仍是页面与导出的唯一事实来源\u003C\u002Fstrong>。键里包含：音频内容哈希、模型权重文件指纹（大小 + 修改时间）、faster-whisper 版本、prompt\u002Fhotwords、语言、档位、全部解码参数、VAD 回退阈值、重试参数。\u003C\u002Fp>\n\u003Cp data-line=\"508\">两条容易漏的规则：\u003C\u002Fp>\n\u003Cul data-line=\"510\">\n\u003Cli data-line=\"510\">\u003Cstrong>不同请求不能互相替代\u003C\u002Fstrong>。少一个参数就可能让&quot;改了词表的请求&quot;命中&quot;老词表的结果&quot;；\u003C\u002Fli>\n\u003Cli data-line=\"511\">\u003Cstrong>命中返回深拷贝\u003C\u002Fstrong>，且分片的时间偏移必须在写入缓存前处理干净，否则偏移会污染缓存。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Chr data-line=\"513\">\n\u003Ch2 data-line=\"515\" id=\"6. 关键实现四：说话人分离\">6. 关键实现四：说话人分离\u003C\u002Fh2>\n\u003Ch3 data-line=\"517\" id=\"6.1 它是独立阶段，不是 ASR 的一部分\">6.1 它是独立阶段，不是 ASR 的一部分\u003C\u002Fh3>\n\u003Cp data-line=\"519\">分离必须\u003Cstrong>独立于 ASR\u003C\u002Fstrong>，理由有三个：\u003C\u002Fp>\n\u003Col data-line=\"521\">\n\u003Cli data-line=\"521\">ASR 调优（prompt、VAD、重试）已经踩坑修好，换全家桶方案会把这些全丢掉；\u003C\u002Fli>\n\u003Cli data-line=\"522\">CPU 上分离很慢，必须做成\u003Cstrong>任务级开关、默认关\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"523\">分离失败\u003Cstrong>绝不能影响文本\u003C\u002Fstrong>——文字是主交付物，说话人是增强信息。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp data-line=\"525\">所以流程是：ASR 先出文字与词级时间戳 → 分离独立跑 → 按时间把说话人\u003Cstrong>贴回\u003C\u002Fstrong>分段。\u003C\u002Fp>\n\u003Ch3 data-line=\"527\" id=\"6.2 引擎选型：为什么不是 pyannote\">6.2 引擎选型：为什么不是 pyannote\u003C\u002Fh3>\n\u003Cp data-line=\"529\">最直接的原因是\u003Cstrong>令牌\u003C\u002Fstrong>：主流方案（pyannote 系列）模型仓库是 gated 的，需要签署许可 + 令牌才能下载，而目标部署网络无法直连上游仓库。没有令牌 ⇒ 不可用。\u003C\u002Fp>\n\u003Cp data-line=\"531\">改用\u003Cstrong>公开仓库、免令牌\u003C\u002Fstrong>的 SpeechBrain ECAPA-TDNN 声纹模型（走可用的镜像站下载）。链路全部自建：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"533\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-\" language=>\u003Cspan class=\"md-editor-code-block\">16k 单声道 WAV\n  └─ 独立 Silero VAD（多尺度窗口）\n\u003Cspan class=\"hljs-code\">       └─ 1.5s 短窗 + 3s 上下文融合，步长 1.5s\n            └─ 质量筛选（排除静音\u002F削波）→ 可靠窗口作聚类种子\n                 └─ ECAPA embedding（分批编码，每批 16 窗）\n                      └─ Agglomerative 聚类（cosine \u002F average linkage）\n                           └─ 簇原型 + Viterbi 换人惩罚重评分 + 拒判\n                                └─ 按段中点贴回 SPEAKER_XX\n\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"544\">几个值得说的设计：\u003C\u002Fp>\n\u003Cul data-line=\"546\">\n\u003Cli data-line=\"546\">\u003Cstrong>ASR 分段不再决定声纹窗口\u003C\u002Fstrong>。早期做法是&quot;复用 Whisper 分段当语音区&quot;，但分段边界由文字决定、不等于声学边界。改成独立 VAD 后，声纹窗口的质量稳定得多。\u003C\u002Fli>\n\u003Cli data-line=\"547\">\u003Cstrong>至少 1 秒的可靠窗口才有资格当聚类种子\u003C\u002Fstrong>；短应答可以归到已有原型，没有种子就保持&quot;未知&quot;。\u003C\u002Fli>\n\u003Cli data-line=\"548\">\u003Cstrong>聚类最多抽样 2000 个种子\u003C\u002Fstrong>，其余按原型归属——把平方级成本按住。\u003C\u002Fli>\n\u003Cli data-line=\"549\">\u003Cstrong>拒判（abstention）\u003C\u002Fstrong>：相似度不足或与次优候选差距不够时，宁可返回 \u003Ccode>None\u003C\u002Fcode>。\u003Cstrong>没有把握就不要编。\u003C\u002Fstrong>\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"551\" id=\"6.3 人数判定：一个阈值是不够的\">6.3 人数判定：一个阈值是不够的\u003C\u002Fh3>\n\u003Cp data-line=\"553\">最初&quot;几个人&quot;只由\u003Cstrong>一个合并阈值\u003C\u002Fstrong>（cosine \u002F average linkage，0.75）决定，只有合并、没有拆分 ⇒ \u003Cstrong>同性别、音色接近的两个人必然被并成一个\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"555\">指纹很好认：某个簇的簇内平均余弦距离明显高于其他簇（实测一支 \u003Cstrong>0.233\u003C\u002Fstrong>，另两支 0.14~0.17）且占窗口大头；把它拆开，两半质心距离只有 \u003Cstrong>0.5288 &lt; 0.75\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"557\">修法是在聚类后加一道\u003Cstrong>分裂审计\u003C\u002Fstrong>，四道门全过才拆：\u003C\u002Fp>\n\u003Ctable data-line=\"559\">\n\u003Cthead data-line=\"559\">\n\u003Ctr data-line=\"559\">\n\u003Cth>门\u003C\u002Fth>\n\u003Cth style=\"text-align:right\">阈值\u003C\u002Fth>\n\u003Cth>含义\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"561\">\n\u003Ctr data-line=\"561\">\n\u003Ctd>簇内散度下限\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">0.20\u003C\u002Ftd>\n\u003Ctd>只审计&quot;松&quot;的簇，紧的簇免检\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"562\">\n\u003Ctd>二分处平均链接高度\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">0.55\u003C\u002Ftd>\n\u003Ctd>拆的位置必须足够&quot;分得开&quot;\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"563\">\n\u003Ctd>拆后两半内部散度上限\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">0.20\u003C\u002Ftd>\n\u003Ctd>两半都得是&quot;紧&quot;的\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"564\">\n\u003Ctd>两半最小窗口数\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">4\u003C\u002Ftd>\n\u003Ctd>两半都要够大（防噪声切分）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"565\">\n\u003Ctd>封顶\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">8 人\u003C\u002Ftd>\n\u003Ctd>保护上限\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"567\">\u003Cstrong>关键：判据和基础聚类用同一把尺子\u003C\u002Fstrong>（average + cosine），所以&quot;合并高度 0.55&quot;可以和&quot;合并阈值 0.75&quot;直接对照。\u003C\u002Fp>\n\u003Cp data-line=\"569\">\u003Cstrong>阈值不是拍出来的\u003C\u002Fstrong>：把\u003Cstrong>全部 8 份真实声纹缓存\u003C\u002Fstrong>（\u003Ccode>.npz\u003C\u002Fcode>，不需要模型也不需要数据库）离线重跑对照——唯一发生分裂的就是那一条问题录音（2 → 3 人，无标签窗口 2 → 0），而两场 4 人长会议（478s \u002F 704s）\u003Cstrong>一条分裂记录都没产生\u003C\u002Fstrong>（被门 ③④ 挡住）。\u003Cstrong>要改判据，就再用这批缓存重跑一遍。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp data-line=\"571\">而且：\u003Cstrong>不要为了单个任务去全局调低合并阈值\u003C\u002Fstrong>——它同时是所有任务的合并尺度，调低必然过度分裂（同一个人被切成两个）。人数已知且素材敏感时，直接用任务参数强制指定人数，完全绕开这套判据。\u003C\u002Fp>\n\u003Ch3 data-line=\"573\" id=\"6.4 音乐\u002F纯音乐：自动跳过，而不是硬贴标签\">6.4 音乐\u002F纯音乐：自动跳过，而不是硬贴标签\u003C\u002Fh3>\n\u003Cp data-line=\"575\">同一首 272.4 秒的歌，\u003Cstrong>分离侧的 VAD 只判出 2.304 秒语音\u003C\u002Fstrong>（两个窗口），全曲几乎无声纹 → 绝大部分分段拿不到说话人 → \u003Cstrong>满屏&quot;待确认&quot;\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"577\">\u003Cstrong>症状特征\u003C\u002Fstrong>：任务 \u003Ccode>SUCCESS\u003C\u002Fcode>、分离显示&quot;已完成&quot;，但列表满屏&quot;待确认&quot;。\u003Cstrong>这不是 bug，是素材类型不对\u003C\u002Fstrong>——对歌曲做说话人分离本身没有意义。\u003C\u002Fp>\n\u003Cp data-line=\"579\">修法是让系统自己识别并诚实地跳过：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"581\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> diar_metrics.get(\u003Cspan class=\"hljs-string\">&#x27;skipped_reason&#x27;\u003C\u002Fspan>) == \u003Cspan class=\"hljs-string\">&#x27;insufficient_speech&#x27;\u003C\u002Fspan>:\n    task.diarization_status = \u003Cspan class=\"hljs-string\">&#x27;review&#x27;\u003C\u002Fspan>\n    task.diarization_error = (\n        \u003Cspan class=\"hljs-string\">f&quot;未检测到有效语音（语音占比仅 \u003Cspan class=\"hljs-subst\">{audio_coverage * \u003Cspan class=\"hljs-number\">100\u003C\u002Fspan>:\u003Cspan class=\"hljs-number\">.1\u003C\u002Fspan>f}\u003C\u002Fspan>%，&quot;\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-string\">&quot;音频可能为歌曲\u002F纯音乐），已跳过说话人分离；转录文本已保留&quot;\u003C\u002Fspan>\n    )\n    \u003Cspan class=\"hljs-comment\"># 不抛异常、不改写任何 segment.speaker\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"591\">四条不变量：\u003C\u002Fp>\n\u003Col data-line=\"593\">\n\u003Cli data-line=\"593\">\n\u003Cp data-line=\"593\">\u003Cstrong>语音占比不足 = 跳过，不是失败\u003C\u002Fstrong>。阈值门\u003Cstrong>同时\u003C\u002Fstrong>下在两处：特征提取前（不达标连声纹模型都不加载，省掉最贵的一步）和主入口（\u003Cstrong>这一层是为了让命中旧缓存的&quot;修复前结果&quot;也走跳过路径\u003C\u002Fstrong>）。\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli data-line=\"595\">\n\u003Cp data-line=\"595\">\u003Cstrong>&quot;已完成&quot;要有门槛\u003C\u002Fstrong>。原来 \u003Ccode>done if speaker_count\u003C\u002Fcode> 门槛过低——仅凭一个窗口聚出 1 人就报&quot;已完成&quot;，与满屏&quot;待确认&quot;自相矛盾。现在必须语音占比达标\u003Cstrong>且\u003C\u002Fstrong> turns 非空。\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli data-line=\"597\">\n\u003Cp data-line=\"597\">\u003Cstrong>两个覆盖率口径并存，各有各的语义\u003C\u002Fstrong>：\u003C\u002Fp>\n\u003Cul data-line=\"599\">\n\u003Cli data-line=\"599\">\u003Ccode>coverage\u003C\u002Fcode> 分母是\u003Cstrong>语音秒数\u003C\u002Fstrong>，含义是&quot;进入声纹的语音里被拒判的比例&quot;——\u003Cstrong>这个语义没错，不要改\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"600\">\u003Ccode>audio_coverage\u003C\u002Fcode> 分母是\u003Cstrong>音频总时长\u003C\u002Fstrong>，才是&quot;全片覆盖率&quot;。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"602\">这个区分是被一次误导救回来的：界面曾显示&quot;自动声纹拒判 41.0%&quot;，而真实情况是\u003Cstrong>全曲 99.2% 从未进入分离流程\u003C\u002Fstrong>。分母选错，指标就会撒一个比&quot;没数据&quot;更危险的谎。\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli data-line=\"604\">\n\u003Cp data-line=\"604\">\u003Cstrong>两种&quot;待确认&quot;不能合并\u003C\u002Fstrong>。原来的&quot;待确认&quot;标记有两个来源：说话人归属待确认、以及\u003Cstrong>分片边界的文字冲突\u003C\u002Fstrong>。后者会让\u003Cstrong>没开分离的任务\u003C\u002Fstrong>也冒出说话人&quot;待确认&quot;。修法是给边界冲突的 span 打上 \u003Ccode>review_kind='boundary'\u003C\u002Fcode>，后端只认非 boundary 的标记，前端显示为&quot;待核对&quot;并使用独立文案。\u003Cstrong>别把这两处再合并回一个标记。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3 data-line=\"606\" id=\"6.5 歌曲场景还暴露了置信度的局限\">6.5 歌曲场景还暴露了置信度的局限\u003C\u002Fh3>\n\u003Cp data-line=\"608\">歌词逐句核对出 9 处同音错误，而\u003Cstrong>置信度全部在 0.70~0.82，高于 0.6 的告警阈值\u003C\u002Fstrong> → &quot;待校对&quot;队列是 0。再次印证 §5 的结论：置信度是解码质量的度量，\u003Cstrong>不等于语义正确率\u003C\u002Fstrong>；对音乐类素材，\u003Ccode>avg_logprob\u003C\u002Fcode> 基本失去参考价值。\u003C\u002Fp>\n\u003Ch3 data-line=\"610\" id=\"6.6 展示层与数据层的分界：改名不等于改标签\">6.6 展示层与数据层的分界：改名不等于改标签\u003C\u002Fh3>\n\u003Cp data-line=\"612\">用户想把&quot;说话人 1&quot;改成&quot;张三&quot;。一个看起来很自然的做法是直接改写 \u003Ccode>segment.speaker\u003C\u002Fcode>。\u003Cstrong>这是错的，而且不可回退。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp data-line=\"614\">因为 \u003Ccode>segment.speaker\u003C\u002Fcode> 是\u003Cstrong>稳定机器标签\u003C\u002Fstrong>，它同时是：词级 spans 的说话人键、合并\u002F单段修正\u002F待确认队列的判定依据、四种导出格式的取值来源。把它改成真实姓名，会导致段级标签与词级 spans 分裂 → 同一个人出现两个标签 → 合并与队列全乱。\u003C\u002Fp>\n\u003Cp data-line=\"616\">正确做法是\u003Cstrong>任务级映射表\u003C\u002Fstrong>：\u003Ccode>{&quot;SPEAKER_00&quot;: &quot;张三&quot;}\u003C\u002Fcode>，\u003Cstrong>替换式保存\u003C\u002Fstrong>（接口收到的即全量状态），不带某标签或值为空 ⇒ 回落&quot;说话人 N&quot;；合并说话人时原标签的姓名顺延到目标标签。\u003Cstrong>数据层一个字节都不改。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch3 data-line=\"618\" id=\"6.7 版本化：让&quot;重跑&quot;和&quot;人工修改&quot;共存\">6.7 版本化：让&quot;重跑&quot;和&quot;人工修改&quot;共存\u003C\u002Fh3>\n\u003Cp data-line=\"620\">分离结果、词级时间轴、派生句段、配置与耗时，整体存成一次 \u003Cstrong>analysis run\u003C\u002Fstrong> 的 JSON（不拆成四张关系表），任务上放一个&quot;当前发布版本&quot;指针，\u003Cstrong>完整 run 与指针在同一短事务提交\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"622\">由此得到几条保护：\u003C\u002Fp>\n\u003Cul data-line=\"624\">\n\u003Cli data-line=\"624\">页面和四种导出\u003Cstrong>共同读同一个发布版本\u003C\u002Fstrong> → 三者永远一致；\u003C\u002Fli>\n\u003Cli data-line=\"625\">\u003Cstrong>失败或回滚不切换指针\u003C\u002Fstrong> → 半成品不会上线；\u003C\u002Fli>\n\u003Cli data-line=\"626\">分离异常时\u003Cstrong>文字保留、界面显示失败\u003C\u002Fstrong>，未知说话人\u003Cstrong>不伪造编号\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"627\">人工修正生成新版本并标记 \u003Ccode>manual\u003C\u002Fcode>；\u003Cstrong>重跑保留人工标签与已合并句段\u003C\u002Fstrong>；\u003C\u002Fli>\n\u003Cli data-line=\"628\">\u003Cstrong>文字被人工改过之后，不再套用旧的词级派生句段\u003C\u002Fstrong>（时间对不上了）。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"630\" id=\"6.8 软删除：宁可留标记，不可删行\">6.8 软删除：宁可留标记，不可删行\u003C\u002Fh3>\n\u003Cp data-line=\"632\">分段删除必须是\u003Cstrong>软删除\u003C\u002Fstrong>（加一个 \u003Ccode>deleted\u003C\u002Fcode> 标记位）。原因很硬：词级 spans \u003Cstrong>以分段序号为键\u003C\u002Fstrong>，硬删一行就是不可修复的空洞——只能整场重跑才能补回来。而且误删必须可逆。\u003C\u002Fp>\n\u003Cp data-line=\"634\">避免&quot;改一处漏一处&quot;的关键是\u003Cstrong>排除点只能有一处\u003C\u002Fstrong>：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"636\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">python\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-python\" language=python>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">def\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">visible_sources\u003C\u002Fspan>(\u003Cspan class=\"hljs-params\">...\u003C\u002Fspan>):\n    \u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> segment \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> segments:\n        \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">getattr\u003C\u002Fspan>(segment, \u003Cspan class=\"hljs-string\">&quot;deleted&quot;\u003C\u002Fspan>, \u003Cspan class=\"hljs-literal\">False\u003C\u002Fspan>):\n            \u003Cspan class=\"hljs-keyword\">continue\u003C\u002Fspan>        \u003Cspan class=\"hljs-comment\"># ← 唯一排除点\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-keyword\">yield\u003C\u002Fspan> segment\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"644\">四种导出、详情列表、说话人标签收集\u003Cstrong>全部从这里继承\u003C\u002Fstrong>；将来加第五种导出格式也自动正确。\u003Cstrong>绝对不要\u003C\u002Fstrong>在每个导出分支各写一次判空。\u003C\u002Fp>\n\u003Cp data-line=\"646\">配套两条：\u003C\u002Fp>\n\u003Cul data-line=\"648\">\n\u003Cli data-line=\"648\">详情响应把已删分段单独放在 \u003Ccode>deleted_segments\u003C\u002Fcode>，\u003Cstrong>不要\u003C\u002Fstrong>塞回主列表加标记——主列表必须一直等价于&quot;用户手里还有什么&quot;，否则说话人计数、四个筛选队列、导出都要各自记得判一次。\u003C\u002Fli>\n\u003Cli data-line=\"649\">全文只在\u003Cstrong>仍是机器拼接\u003C\u002Fstrong>时才重算：\u003Ccode>before = join(可见段)\u003C\u002Fcode> → 翻标记 → \u003Ccode>after = join(可见段)\u003C\u002Fcode> → \u003Ccode>if task.text == before: task.text = after\u003C\u002Fcode>。用户手工整理过全文时\u003Cstrong>不许覆盖\u003C\u002Fstrong>。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Chr data-line=\"651\">\n\u003Ch2 data-line=\"653\" id=\"7. 前端：几个&quot;看不出问题但用户会骂&quot;的点\">7. 前端：几个&quot;看不出问题但用户会骂&quot;的点\u003C\u002Fh2>\n\u003Ch3 data-line=\"655\" id=\"7.1 一屏一个滚动容器，禁止嵌套滚动\">7.1 一屏一个滚动容器，禁止嵌套滚动\u003C\u002Fh3>\n\u003Cp data-line=\"657\">布局是固定的：\u003Ccode>html\u002Fbody → 布局容器(100vh) → 主区(overflow:hidden) → 页面根(overflow:hidden) → 卡片(overflow:hidden)\u003C\u002Fcode>，\u003Cstrong>页面级永不出现滚动条\u003C\u002Fstrong>，滚动只发生在内容区。\u003C\u002Fp>\n\u003Cp data-line=\"659\">反面案例很典型：详情页左栏自带 \u003Ccode>overflow-y:auto\u003C\u002Fcode>，里面包着一个会涨到 30 行的自适应高度文本框。笔记本（约 768px 高）下可用高度只有约 400px，而文本框会长到约 720px → \u003Cstrong>外层滚动条 + 文本框内部滚动条同时出现\u003C\u002Fstrong>（用户报的&quot;2 个滚动条&quot;）。\u003C\u002Fp>\n\u003Cp data-line=\"661\">正确姿势：\u003Cstrong>内容区自身是唯一滚动体，内部元素必须&quot;只长不滚&quot;\u003C\u002Fstrong>；多视图用标签页切换而不是并排分栏；文本框用\u003Cstrong>无上限自适应高度\u003C\u002Fstrong>（只给最小行数，不给最大行数）让它长满。\u003C\u002Fp>\n\u003Cp data-line=\"663\">同理，表格页必须给表格\u003Cstrong>像素级高度\u003C\u002Fstrong>：默认不滚的表格行数一多会把分页一起挤出容器被 \u003Ccode>overflow:hidden\u003C\u002Fcode> \u003Cstrong>裁掉（无滚动条、内容直接消失）\u003C\u002Fstrong>。做法是包一层 \u003Ccode>overflow:hidden\u003C\u002Fcode> 的容器、用 \u003Ccode>ResizeObserver\u003C\u002Fcode> 量高再传给表格的 \u003Ccode>max-height\u003C\u002Fcode>。\u003Cstrong>只有表格主体一个滚动条，分页永远可见。\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Ch3 data-line=\"665\" id=\"7.2 阅读字号三档：必须走 CSS 变量，且三件事一起缩放\">7.2 阅读字号三档：必须走 CSS 变量，且三件事一起缩放\u003C\u002Fh3>\n\u003Cp data-line=\"667\">字号档位（小 13 \u002F 中 14 默认 \u002F 大 16）存在本地，是\u003Cstrong>纯前端阅读偏好：不进数据库、不影响导出\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"669\">传递只能靠 CSS 变量——因为字号写在子组件的 scoped 样式里，\u003Cstrong>props 够不到，只有 CSS 变量能穿透 scoped\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"671\">\u003Cstrong>而且三件事必须一起缩放\u003C\u002Fstrong>，只改字号一定翻车：\u003C\u002Fp>\n\u003Col data-line=\"673\">\n\u003Cli data-line=\"673\">\u003Cstrong>行高\u003C\u002Fstrong>（1.6 \u002F 1.7 \u002F 1.8）——不跟，大字会挤成一坨；\u003C\u002Fli>\n\u003Cli data-line=\"674\">\u003Cstrong>行首时间列宽\u003C\u002Fstrong>（50 \u002F 52 \u002F 58）——栅格列是固定像素不会自己长，13px 等宽时间串在 52px 列里会折行；\u003C\u002Fli>\n\u003Cli data-line=\"675\">\u003Cstrong>行内编辑框字号\u003C\u002Fstrong>——不同步会出现&quot;16px 的段落点开变成 14px 输入框&quot;的跳变。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp data-line=\"677\">反过来，\u003Cstrong>界面那一层不跟字号放大\u003C\u002Fstrong>：工具栏、图例、按钮、图标固定 12px。它们是界面，放大只会让界面变笨重。\u003C\u002Fp>\n\u003Ch3 data-line=\"679\" id=\"7.3 后端时间是 naive UTC，前端必须按 UTC 解析\">7.3 后端时间是 naive UTC，前端必须按 UTC 解析\u003C\u002Fh3>\n\u003Cp data-line=\"681\">模型用 \u003Ccode>datetime.utcnow\u003C\u002Fcode>（无时区），序列化出来是 \u003Ccode>2026-09-14T01:31:08.920\u003C\u002Fcode>（\u003Cstrong>没有 \u003Ccode>Z\u003C\u002Fcode>\u003C\u002Fstrong>）。\u003Ccode>new Date()\u003C\u002Fcode> 会把它当成\u003Cstrong>本地时间\u003C\u002Fstrong>解析 → 界面时间整体差一个时区偏移（本机实测差 8 小时）。\u003C\u002Fp>\n\u003Cp data-line=\"683\">统一走一个 \u003Ccode>parseServerDate()\u003C\u002Fcode>：无时区后缀就补 \u003Ccode>Z\u003C\u002Fcode>，带 \u003Ccode>Z\u003C\u002Fcode> \u002F \u003Ccode>+08:00\u003C\u002Fcode> 的原样通过。组件里\u003Cstrong>不要\u003C\u002Fstrong>直接 \u003Ccode>new Date(...)\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Ch3 data-line=\"685\" id=\"7.4 局域网 http 下没有剪贴板 API\">7.4 局域网 http 下没有剪贴板 API\u003C\u002Fh3>\n\u003Cp data-line=\"687\">\u003Ccode>navigator.clipboard\u003C\u002Fcode> 只在安全上下文（https \u002F localhost）可用。这类系统通常部署在内网、以 HTTP 明文访问，所以任何&quot;复制到剪贴板&quot;都必须保留降级分支：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"689\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">ts\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-ts\" language=ts>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (navigator.\u003Cspan class=\"hljs-property\">clipboard\u003C\u002Fspan>?.\u003Cspan class=\"hljs-property\">writeText\u003C\u002Fspan>) { \u003Cspan class=\"hljs-keyword\">await\u003C\u002Fspan> navigator.\u003Cspan class=\"hljs-property\">clipboard\u003C\u002Fspan>.\u003Cspan class=\"hljs-title function_\">writeText\u003C\u002Fspan>(text) }\n\u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan> { \u003Cspan class=\"hljs-comment\">\u002F* 隐藏 textarea + document.execCommand(&#x27;copy&#x27;) *\u002F\u003C\u002Fspan> }\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"694\">漏了这条，功能在真实部署环境里直接失效——而本地开发（localhost）永远测不出来。\u003C\u002Fp>\n\u003Ch3 data-line=\"696\" id=\"7.5 图标工具条要&quot;安静&quot;，但要能被触屏找到\">7.5 图标工具条要&quot;安静&quot;，但要能被触屏找到\u003C\u002Fh3>\n\u003Cp data-line=\"698\">分段行里的动作（回听、校对、改说话人、删除）如果都写成文字，会和正文抢注意力——\u003Cstrong>正文是文字、动作也是文字，两层同质信息互相干扰\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cp data-line=\"700\">改成：三列网格 \u003Ccode>时间 | 正文 | 工具条\u003C\u002Fcode>，工具条用 16px 自绘图标，\u003Ccode>opacity:.5\u003C\u002Fcode>，hover \u002F 聚焦时提到 1。\u003C\u002Fp>\n\u003Cp data-line=\"702\">\u003Cstrong>但必须写 \u003Ccode>@media (hover: none) { opacity: 1 }\u003C\u002Fcode>\u003C\u002Fstrong>——否则触屏设备上这些动作等于被藏起来。用 \u003Ccode>opacity\u003C\u002Fcode> 而不是 \u003Ccode>display:none\u003C\u002Fcode>，保留可点击、可聚焦。\u003C\u002Fp>\n\u003Cp data-line=\"704\">还有一条：徽标内联在正文里时\u003Cstrong>必须加 \u003Ccode>@dblclick.stop\u003C\u002Fcode>\u003C\u002Fstrong>。\u003Ccode>@click.stop\u003C\u002Fcode> 只挡 click，双击照样冒泡到正文行 → 一边弹改名弹窗一边进入文字编辑。\u003C\u002Fp>\n\u003Ch3 data-line=\"706\" id=\"7.6 破坏性操作：二次确认，且焦点不能落在确认键上\">7.6 破坏性操作：二次确认，且焦点不能落在确认键上\u003C\u002Fh3>\n\u003Cp data-line=\"708\">两类&quot;删除&quot;代价完全不同，文案必须说实话：\u003C\u002Fp>\n\u003Ctable data-line=\"710\">\n\u003Cthead data-line=\"710\">\n\u003Ctr data-line=\"710\">\n\u003Cth>操作\u003C\u002Fth>\n\u003Cth>代价\u003C\u002Fth>\n\u003Cth>文案\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"712\">\n\u003Ctr data-line=\"712\">\n\u003Ctd>删分段\u003C\u002Ftd>\n\u003Ctd>软删除，可恢复\u003C\u002Ftd>\n\u003Ctd>&quot;内容仍保留在『已删除』队列，可随时恢复&quot;\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"713\">\n\u003Ctd>删任务\u003C\u002Ftd>\n\u003Ctd>真删（音频 + 结果），不可恢复\u003C\u002Ftd>\n\u003Ctd>&quot;永久删除，无法恢复&quot;\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"715\">确认框里要\u003Cstrong>回显时间区间 + 段序号 + 文字开头\u003C\u002Fstrong>（长文截到 3 行），让人核对&quot;删的正是这一行&quot;。只写一句&quot;确定吗&quot;等于没确认。\u003C\u002Fp>\n\u003Cp data-line=\"717\">\u003Cstrong>最关键的一条\u003C\u002Fstrong>：确认框的 \u003Ccode>autofocus\u003C\u002Fcode> 默认是 \u003Ccode>true\u003C\u002Fcode>，会把焦点给确认键 → \u003Cstrong>手还在键盘上时一个回车就删掉了\u003C\u002Fstrong>。置为 \u003Ccode>false\u003C\u002Fcode> 后，焦点陷阱落不到确认键上，\u003Cstrong>回车不会触发确认\u003C\u002Fstrong>（真实浏览器实测：弹窗打开瞬间 \u003Ccode>document.activeElement\u003C\u002Fcode> 是弹窗容器，按 Enter 时弹窗保持打开、数据不变）。取消路径交给 Esc 和&quot;取消&quot;按钮。\u003C\u002Fp>\n\u003Cp data-line=\"719\">另外，弹确认之前要有一个\u003Cstrong>同步闸门\u003C\u002Fstrong>（一个布尔 ref）：确认框的遮罩要下一个 tick 才渲染，不加闸门时&quot;极快双击&quot;会在遮罩出现前再弹出一个，两个框叠着。\u003C\u002Fp>\n\u003Chr data-line=\"721\">\n\u003Ch2 data-line=\"723\" id=\"8. 效果数据\">8. 效果数据\u003C\u002Fh2>\n\u003Ch3 data-line=\"725\" id=\"8.1 精度\u002F效率优化的实测（CPU int8，small 模型，中文，60 秒真实会议片段）\">8.1 精度\u002F效率优化的实测（CPU int8，small 模型，中文，60 秒真实会议片段）\u003C\u002Fh3>\n\u003Ctable data-line=\"727\">\n\u003Cthead data-line=\"727\">\n\u003Ctr data-line=\"727\">\n\u003Cth>请求\u003C\u002Fth>\n\u003Cth style=\"text-align:right\">ASR 耗时\u003C\u002Fth>\n\u003Cth style=\"text-align:right\">端到端\u003C\u002Fth>\n\u003Cth>ASR 缓存\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"729\">\n\u003Ctr data-line=\"729\">\n\u003Ctd>均衡档首次\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">17.16s\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">19.49s\u003C\u002Ftd>\n\u003Ctd>未命中\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"730\">\n\u003Ctd>快速档首次\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">6.86s\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">6.86s\u003C\u002Ftd>\n\u003Ctd>未命中\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"731\">\n\u003Ctd>均衡档重复\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">\u003Cstrong>0.047s\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">0.047s\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>命中\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"733\">跨进程冷启动场景：首次联合分析 26.03s；\u003Cstrong>新进程重复请求命中 ASR 与声纹双缓存为 6.17s\u003C\u002Fstrong>。\u003C\u002Fp>\n\u003Cblockquote data-line=\"735\">\n\u003Cp data-line=\"735\">诚实说明：首行包含模型冷加载，后两行模型已驻留，\u003Cstrong>不能把比值直接当成固定加速倍数\u003C\u002Fstrong>。基准为服务调用，不含上传、数据库、UI 和 FFmpeg。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch3 data-line=\"737\" id=\"8.2 声纹特征复用\">8.2 声纹特征复用\u003C\u002Fh3>\n\u003Cp data-line=\"739\">一场全长录音首次声纹分析 \u003Cstrong>182.56 秒\u003C\u002Fstrong>（360 个窗口）；复用同一份声纹特征的两个后续任务分别 \u003Cstrong>2.44s \u002F 2.89s\u003C\u002Fstrong>。三者发布的时间轴哈希一致，原始转写全文哈希与备份一致。\u003C\u002Fp>\n\u003Cblockquote data-line=\"741\">\n\u003Cp data-line=\"741\">\u003Cstrong>只改变人数可以复用声纹特征再聚类；改变 ASR 文字、模型或分段都不会改变声纹特征\u003C\u002Fstrong>——这个缓存边界划得清楚，是&quot;改个字不用重跑声纹&quot;的前提。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch3 data-line=\"743\" id=\"8.3 端到端\">8.3 端到端\u003C\u002Fh3>\n\u003Cp data-line=\"745\">一条联合流程任务在 worker 重载后 SUCCESS，\u003Cstrong>两个缓存均命中，含 FFmpeg 与数据库的总耗时 8.42 秒\u003C\u002Fstrong>。四格式导出、发布回滚、同音频不同 ASR 时间轴一致性、文字哈希、重跑后人工修正保留——均已验证。\u003C\u002Fp>\n\u003Chr data-line=\"747\">\n\u003Ch2 data-line=\"749\" id=\"9. 踩坑速查表\">9. 踩坑速查表\u003C\u002Fh2>\n\u003Ctable data-line=\"751\">\n\u003Cthead data-line=\"751\">\n\u003Ctr data-line=\"751\">\n\u003Cth>#\u003C\u002Fth>\n\u003Cth>坑\u003C\u002Fth>\n\u003Cth>症状\u003C\u002Fth>\n\u003Cth>根因 \u002F 修法\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"753\">\n\u003Ctr data-line=\"753\">\n\u003Ctd>1\u003C\u002Ftd>\n\u003Ctd>分片进度分母用错\u003C\u002Ftd>\n\u003Ctd>第一个分片内冲到 ~90%，之后不动\u003C\u002Ftd>\n\u003Ctd>分母必须是\u003Cstrong>整段时长\u003C\u002Fstrong>，不是分片时长\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"754\">\n\u003Ctd>2\u003C\u002Ftd>\n\u003Ctd>进度节流吃掉上限\u003C\u002Ftd>\n\u003Ctd>永远停在 90%\u003C\u002Ftd>\n\u003Ctd>区间上限值必须\u003Cstrong>无条件放行\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"755\">\n\u003Ctd>3\u003C\u002Ftd>\n\u003Ctd>重复投递\u003C\u002Ftd>\n\u003Ctd>同一任务被投递 5 次，后一次失败覆盖前一次成功\u003C\u002Ftd>\n\u003Ctd>投递前查 broker + worker 入口查终态\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"756\">\n\u003Ctd>4\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>idle in transaction\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>进度长时间不变，事务挂 350s+\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>commit()\u003C\u002Fcode> 后\u003Cstrong>不要\u003C\u002Fstrong>复用同一 session 做只读查询\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"757\">\n\u003Ctd>5\u003C\u002Ftd>\n\u003Ctd>SSE 注释帧做心跳\u003C\u002Ftd>\n\u003Ctd>界面无限冻结，不触发重连\u003C\u002Ftd>\n\u003Ctd>注释帧被浏览器\u003Cstrong>完全丢弃\u003C\u002Fstrong>，改具名数据帧 + 静默看门狗\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"758\">\n\u003Ctd>6\u003C\u002Ftd>\n\u003Ctd>异步 Redis 客户端无超时\u003C\u002Ftd>\n\u003Ctd>心跳停发、连接被掐\u003C\u002Ftd>\n\u003Ctd>每个 await 加 \u003Ccode>wait_for\u003C\u002Fcode>，客户端配 socket 超时\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"759\">\n\u003Ctd>7\u003C\u002Ftd>\n\u003Ctd>优雅关闭被 SSE 挂死\u003C\u002Ftd>\n\u003Ctd>端口在听、任何请求无响应、日志静默、日志 CPU 空转\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>--timeout-graceful-shutdown 5\u003C\u002Fcode>（默认是 \u003Ccode>None\u003C\u002Fcode> = 无限等）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"760\">\n\u003Ctd>8\u003C\u002Ftd>\n\u003Ctd>VAD 饿死音乐\u003C\u002Ftd>\n\u003Ctd>SUCCESS 但只有几个字，大段时间轴空白\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>duration_after_vad\u003C\u002Fcode> 双条件回退；\u003Cstrong>别调低全局阈值\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"761\">\n\u003Ctd>9\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>initial_prompt\u003C\u002Fcode> 泄漏\u003C\u002Ftd>\n\u003Ctd>输出里出现 prompt 自身短语 + 重复循环\u003C\u002Ftd>\n\u003Ctd>回退分支去 prompt；文件名机器 ID 过滤\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"762\">\n\u003Ctd>10\u003C\u002Ftd>\n\u003Ctd>字段名写错\u003C\u002Ftd>\n\u003Ctd>置信度恒为 NULL，低置信高亮成死代码\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>avg_logprob\u003C\u002Fcode>（\u003Cstrong>中间没有下划线\u003C\u002Fstrong>）；改 \u003Ccode>getattr\u003C\u002Fcode> 前先核真实字段\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"763\">\n\u003Ctd>11\u003C\u002Ftd>\n\u003Ctd>语言探测静默失败\u003C\u002Ftd>\n\u003Ctd>中文 prompt 与词表\u003Cstrong>从未生效\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>detect_language()\u003C\u002Fcode> 不接受路径，必须传解码后的数组\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"764\">\n\u003Ctd>12\u003C\u002Ftd>\n\u003Ctd>只给 ffmpeg 留 stderr 开头\u003C\u002Ftd>\n\u003Ctd>报错只有构建 banner，无法诊断\u003C\u002Ftd>\n\u003Ctd>留\u003Cstrong>末尾\u003C\u002Fstrong> 500 字符\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"765\">\n\u003Ctd>13\u003C\u002Ftd>\n\u003Ctd>一个人数阈值\u003C\u002Ftd>\n\u003Ctd>同性别两人必被并成一个\u003C\u002Ftd>\n\u003Ctd>合并阈值 + 聚类后分裂审计四道门\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"766\">\n\u003Ctd>14\u003C\u002Ftd>\n\u003Ctd>指标分母错\u003C\u002Ftd>\n\u003Ctd>显示&quot;拒判 41%&quot;，实际 99.2% 未进入流程\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>coverage\u003C\u002Fcode>（分母语音）与 \u003Ccode>audio_coverage\u003C\u002Fcode>（分母总时长）分开\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"767\">\n\u003Ctd>15\u003C\u002Ftd>\n\u003Ctd>两类&quot;待确认&quot;合并\u003C\u002Ftd>\n\u003Ctd>没开分离的任务也冒出说话人待确认\u003C\u002Ftd>\n\u003Ctd>span 打 \u003Ccode>review_kind='boundary'\u003C\u002Fcode>，前后端都只认非 boundary\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"768\">\n\u003Ctd>16\u003C\u002Ftd>\n\u003Ctd>硬删分段\u003C\u002Ftd>\n\u003Ctd>词级 spans 出现不可修复的空洞\u003C\u002Ftd>\n\u003Ctd>软删除 + \u003Cstrong>唯一排除点\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"769\">\n\u003Ctd>17\u003C\u002Ftd>\n\u003Ctd>naive UTC 前端解析\u003C\u002Ftd>\n\u003Ctd>界面时间差 8 小时\u003C\u002Ftd>\n\u003Ctd>无时区后缀补 \u003Ccode>Z\u003C\u002Fcode> 再解析\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"770\">\n\u003Ctd>18\u003C\u002Ftd>\n\u003Ctd>局域网剪贴板\u003C\u002Ftd>\n\u003Ctd>功能在真实环境直接失效\u003C\u002Ftd>\n\u003Ctd>保留 \u003Ccode>execCommand('copy')\u003C\u002Fcode> 降级\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"771\">\n\u003Ctd>19\u003C\u002Ftd>\n\u003Ctd>确认框焦点默认在确认键\u003C\u002Ftd>\n\u003Ctd>手在键盘上，一个回车就删了\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>autofocus: false\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"772\">\n\u003Ctd>20\u003C\u002Ftd>\n\u003Ctd>嵌套滚动\u003C\u002Ftd>\n\u003Ctd>同一屏两个滚动条\u003C\u002Ftd>\n\u003Ctd>一屏一个滚动容器，内部元素&quot;只长不滚&quot;\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Chr data-line=\"774\">\n\u003Ch2 data-line=\"776\" id=\"10. 边界与后续\">10. 边界与后续\u003C\u002Fh2>\n\u003Cp data-line=\"778\">诚实地列出当前没做\u002F做不到的：\u003C\u002Fp>\n\u003Cul data-line=\"780\">\n\u003Cli data-line=\"780\">\u003Cstrong>人工 CER \u002F DER 盲测尚未完成\u003C\u002Fstrong>。目前的聚类数与标签覆盖率都是\u003Cstrong>算法结果\u003C\u002Fstrong>，不等于人工准确率。\u003C\u002Fli>\n\u003Cli data-line=\"781\">\u003Cstrong>重叠语音（抢话）没有真正分离\u003C\u002Fstrong>。现在的 turns 是&quot;独占&quot;的，不代表恢复了两条人声。\u003C\u002Fli>\n\u003Cli data-line=\"782\">\u003Cstrong>CPU 首次推理、大录音整段 PCM 内存、进程常驻模型内存\u003C\u002Fstrong>都还需要在目标部署机上做压力验收。\u003C\u002Fli>\n\u003Cli data-line=\"783\">\u003Cstrong>单次模型调用不可中断\u003C\u002Fstrong>，取消的响应时间上界由分片粒度决定。\u003C\u002Fli>\n\u003Cli data-line=\"784\">\u003Cstrong>大表加外键\u002F改列类型会触发整表重建并持锁\u003C\u002Fstrong>。小表无感，表一大就是停机风险——写迁移前先用 \u003Ccode>information_schema\u003C\u002Fcode> 估体量，并优先&quot;加列 \u002F 加索引&quot;这类 in-place 操作。\u003C\u002Fli>\n\u003Cli data-line=\"785\">存储层已抽象出后端接口（预留对象存储），当前只有本地后端。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"787\">下一步方向（按性价比排序）：\u003C\u002Fp>\n\u003Col data-line=\"789\">\n\u003Cli data-line=\"789\">先做\u003Cstrong>人工参考稿评测\u003C\u002Fstrong>，把 CER\u002FDER 变成可回归的指标，再谈任何阈值调整；\u003C\u002Fli>\n\u003Cli data-line=\"790\">补\u003Cstrong>真实重叠语音检测\u003C\u002Fstrong>，把&quot;抢话&quot;从&quot;未知区间&quot;里区分出来；\u003C\u002Fli>\n\u003Cli data-line=\"791\">在目标部署机做全面压力验收，确定并发与内存上界；\u003C\u002Fli>\n\u003Cli data-line=\"792\">再评估 GPU \u002F 更大模型 \u002F 新声纹模型的收益——\u003Cstrong>在 1、2 完成之前，换模型只是把不确定性换个地方\u003C\u002Fstrong>。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Chr data-line=\"794\">\n\u003Ch2 data-line=\"796\" id=\"11. 小结\">11. 小结\u003C\u002Fh2>\n\u003Cp data-line=\"798\">回头看，这套系统里真正的功夫不在&quot;调通一个语音模型&quot;，而在四件事：\u003C\u002Fp>\n\u003Col data-line=\"800\">\n\u003Cli data-line=\"800\">\u003Cstrong>把进程边界当成一等公民\u003C\u002Fstrong>。API 与 Worker 分开的那一刻，&quot;实时进度&quot;就从 UI 问题变成了跨进程通信问题，Pub\u002FSub 无重放、心跳可观测性、Redis 挂掉不能影响主流程，全都由这条边界推导出来。\u003C\u002Fli>\n\u003Cli data-line=\"801\">\u003Cstrong>区分&quot;数据&quot;和&quot;展示&quot;\u003C\u002Fstrong>。说话人改名只做展示层映射、软删除只加标记位、阅读字号不进数据库、缓存只加速计算而 MySQL 是唯一事实源——这几条划清楚之后，系统才敢被人改。\u003C\u002Fli>\n\u003Cli data-line=\"802\">\u003Cstrong>指标的口径比指标本身重要\u003C\u002Fstrong>。进度分母、覆盖率分母、健康判定条件，每一个都是&quot;看起来对但会在极端情况下撒一个比没数据更危险的谎&quot;的地方。\u003C\u002Fli>\n\u003Cli data-line=\"803\">\u003Cstrong>精度杠杆在上下文与词表，不在模型大小\u003C\u002Fstrong>。分片上下文续接、tokenizer 预算、术语词表、VAD 回退——四个零算力手段，优先级全部高于换模型。\u003C\u002Fli>\n\u003C\u002Fol>\n",21155,70,"0","",2,0,71,"&quot,会,必须,里,模型","经过半个月的时间不断学习和模型的训练，调试，整个语音识别这块的技术栈进行研究，本次主要是端到端，不调用互联网的API接口，自己通过小模型的学习，实现一套支持语音文件识别文字、识别声纹、杂波过滤、支持在线校对等于一体的语音识别文字，整个效果可以实现技术自主可控，成本低廉和可以正式使用的工具；\n下面直接先看效果：\n\n\n\n\n\n只有 CPU、没有 GPU 的本地化音视频转写系统：上传音视频 → 异步转写 ","2026-09-21 00:00:00"]