# 自适应分块渐进播放方案(Adaptive Chunked Progressive Playback) > RVC 长文本朗读的"先全部转换、后整体播放"改造成"边转换、边播放"的设计与实现说明。 > 核心思想:**性能开销摊在用户本机**,不同机器自动校准块大小与预热数量,做到"即点即听、长文不卡顿"。 ## 1. 问题 RVC 是**变声**而非 TTS:输入音频长度 = 输出音频长度,所以"长文本朗读"必须 先把整段文本用 Edge TTS 合成底噪,再交给 RVC 转换。旧链路是: ``` 整段文本 → Edge 合成整段底噪 → RVC 整段转换 → 返回 → 播放 ``` 对短句(≤12 秒音频)这是最优解;但对长回复(数百字、几分钟音频)会变成: - 用户点击后**干等**:合成 + 转换整段(几十秒甚至几分钟)完成才出声; - 期间没有任何反馈,体验像"卡死"; - 内存峰值高(整段音频一次性进出 RVC)。 曾考虑"只读前 20 字"来缩短等待 —— 但这会**丢掉正文内容**,被否决。 正确方向是:内容一字不丢,但**让第一块尽快出声,其余在播放的同时后台合成**。 ## 2. 方案总览 ``` Edge 合成整段底噪? 不 —— 改成: 1) 文本按句切块(每块 ≈ 10-20 秒音频) 2) 先转换预热块(2-4 块)→ 立即返回给前端开始播放 3) 播放第 n 块的同时,后台转换第 n+1、n+2 块(转换/播放重叠) 4) 前端队列快见底时向 Host 拉取下一块,无缝续播 ``` 关键比率:**速度比 ratio = 转换耗时 / 音频时长**。 - GPU(RTX 5070 实测):15 秒音频 ≈ 3-4 秒转换 → ratio ≈ 0.25 → 转换永远追得上播放,队列不会饿死,**天然无缝**。 - CPU 用户:ratio 可能 > 1 → 必须缩小块、加大预热,把"卡顿点"压到最少。 ## 3. 自适应校准(probe + 分档) 首次使用长文本 RVC 时,Host 做一次 **5 秒探测**: 1. Edge 合成一段固定探测文本(≈3.6 秒音频); 2. 本机 RVC 转换,测出 `ratio = 转换耗时 / 音频秒数`(含每块 Edge 合成耗时, 与真实分块流水线口径一致,偏保守); 3. 按分档表决定 `chunkSec`(每块音频秒数)与 `prewarm`(先转换几块再开播); 4. 结果按配置指纹持久化到 `~/.dsh/tts-rvc/calibration.json`: - **7 天有效**,dsh 重启后直接复用,连那一次 ~7s 探测都省掉; - 同时记录 `device`(RVC 服务 `/health` 上报的 GPU 名);换显卡/切 CPU 时 自动重新探测,不会拿旧的 GPU 数据给 CPU 用; - 探测失败不覆盖磁盘上的旧有效条目(会话内 2 分钟用保守档兜底)。 | ratio | 块大小 | 预热块数 | 适用 | |---|---|---|---| | ≤ 0.4 | 20 秒 | 2 | 强 GPU,几乎无缝 | | 0.4 – 0.6 | 15 秒 | 2 | 中端 GPU | | 0.6 – 0.9 | 10 秒 | 3 | 入门 GPU / 快 CPU | | > 0.9 | 6 秒 | 4 | CPU,尽量平滑 | | 探测失败 | 10 秒 | 3 | 保守兜底(60 秒内不重复探测) | 块大小换算:中文 ≈ 3.6 字/秒 → `maxChars ≈ chunkSec × 3.6`(拉丁文本按 12 字/秒)。 ### 为什么分档而不是固定值? 同一套代码要在"4090 用户"和"核显用户"上都成立。固定大块在 CPU 上会频繁断流, 固定小块在 GPU 上是浪费。**按本机实测速度自适应**才是"即选即用"的根基。 ## 4. 文本切块 `splitText(text, maxChars)`: 1. 按句号/感叹/问号/分号(含中英文)切句; 2. 超长句再按逗号/顿号切段; 3. 仍超长的硬切(每段 ≤ maxChars); 4. 尽量让每块落在语义边界,避免在句中切断导致听感断裂。 ## 5. 协议:任务队列(Host 侧) `POST /dsh-tts-api/speak`(RVC + Edge 底噪 + 预估 > 12 秒时): ```jsonc // 请求不变;响应变为: { "jobId": "j1", "chunks": ["/dsh-tts-audio/c1", "/dsh-tts-audio/c2"], "total": 6, "ratio": 0.24, "chunkSec": 20 } // chunks 只含"预热块";总块数在 total ``` `GET /dsh-tts-api/rvc-next?job=j1`(前端逐块拉取): ```jsonc { "url": "/dsh-tts-audio/c3", "more": true } // 还有后续 { "done": true } // 已到末尾 { "error": "后续段落合成失败:..." } // 某块失败 ``` Host 内部: - 每 job 一个**串行转换链**(`job.tail`),并发拉取自动排队,RVC 服务不被并发打爆; - 每块 = Edge 合成该块文本 → RVC 转换 → 临时 wav → 音频路由 URL; - job 惰性回收:完成 2 分钟后 / 创建 10 分钟后清理,上限 50 个; - 单块失败不影响已缓冲的块,只把错误带给前端,前端提示后停止。 ## 6. 前端渐进播放(无感衔接) `playChunks(jobId, chunks, total, token)` 使用 **Web Audio 精确调度**: - 每个块 `fetch → decodeAudioData` 成 AudioBuffer(保持 2 块解码余量), 按采样时钟**首尾相接调度**:`src.start(prevEnd)`(prevEnd 由已调度缓冲时长累加), 块间零事件抖动、零重载延迟; - **服务端裁剪每块边缘填充静音**(rvc-server `/convert` 输出前 `trim_edges`): 实测每块头 138ms / 尾 538ms 纯静音(Edge TTS + RVC 填充),裁掉并各保留 20ms / 120ms 自然气息,块间不再有 ~680ms 死寂; - 停止/打断:`speakToken` 全局令牌 + 源列表 `stop()`(waCleanup),立即静音; - **进度可见**:`shared.chunkProgress = { index, total }` 随块播放更新——朗读按钮 tooltip 与试听面板显示「第 x/y 段 · 边播边合成」(绝不静默丢内容); - 失败:某块合成失败 → 红字提示并停止;无 Web Audio 环境降级为双 `