/~learn/s/2434ac08
> 📅 2026-10-25 建立
> 起因:车上通勤时想听《截面选品与择时信号的双时钟错配与持仓年龄》(learn/截面选基-理解手册/11-截面选品与择时信号的双时钟错配与持仓年龄.md),结果被四个问题全程打断——断断续续、读到一半没了、写着 15 分钟却几分钟就结束、声音小到听不清,而且不知道该给"车里"选哪个音色。
> 一句话结论:这次不是"今天网不好"的偶发,而是播放架构(逐段同步请求 + 只预取 2 段 + 服务端非流式)和参数设计(语速被应用两次 + 默认气声甜妹混音)共同决定的必然结果。
> 本文所有结论都标了代码位置,区分「代码已确认」与「无法确认(缺埋点)」,不做猜测式归因。
---
| # | 用户看到的现象 | 用户感受 | 归属 |
| --- | --------------- | --------- | ------ |
| 1 | 每句话之间有明显停顿,像"喘气" | 断断续续 | 架构(逐段同步请求 + 预取太浅) |
| 2 | 读到一半突然安静,进度条不动了 / 直接停在末尾 | 文章没读完就没了 | 失败处理(静默跳段) |
| 3 | 面板显示约 15 分钟,实际几分钟就结束 | 时间显示骗人 | 参数(语速双重生效 + 估算忽略语速) |
| 4 | 音量已开到很大仍听不清,尤其车噪下 | 声音小 | 参数(默认音色 + 无响度归一化) |
| 5 | 约 100 个音色,分不清哪个适合车里;换个场景又要重调一遍 | 不知道怎么选 | 产品(无场景预设、无推荐、无记忆) |
---
事实链(全部代码确认):
ttsExtractChunks() 把 h2,h3,h4,h5,p,blockquote,li 每个元素当成一个 chunk,各自发一次请求(unified_server.py:5661-5677)。按同规则静态统计这篇文档:84 段 / 5005 字 / 平均 59.6 字/段。resp.read() 把整包读完,再把所有分片 b"".join() 成完整 MP3 一次性返回(tts_service.py:382-425)。所以首字节时间 = 整段合成完成时间,无法边合成边播。logs/slow_api/2026-10-25.jsonl 里今天唯一一条 TTS 慢请求是 dur_ms: 5067.0(12:40:16)。慢请求阈值是 5000ms(web_perf.py:72),也就是说 5 秒以内的段根本不会留痕——真实分布比日志里看到的更差。
- 首次播放:并发预取前 3 段(unified_server.py:5823-5829);
- 稳态:第 idx 段下载完成后才开始预取 idx+1(:5878-5881),idx+1 下载完成后才开始预取 idx+2(:5882-5887);
- 也就是说预取要跟着"下载完成"排队,而不是跟着"播放进度"提前跑。一旦某段耗时 5s,后面两段只能干等。
visibilitychange 保活、也没有 Media Session(全文件 grep 无 mediaSession / wakeLock)。手机锁屏或切到别的 App 后,页面被降频调度,预取更容易掉队;而且锁屏上没有播放控制,只能回浏览器里点。结论:不是"拉取下一段时网络超时"这么简单,而是预取深度只有 2、且是串行的,加上服务端非流式,让每一段都必须独立扛住一次完整的网络往返 + 合成。
播放链路上有四条失败分支,行为完全一致:报错一声、tts_idx++、立刻播下一段,不重试、不暂停。
| 位置 | 触发条件 | 行为 |
| ------ | --------- | ------ |
unified_server.py:5874 | 后端返回 0 字节音频(blob.size===0) | tts_idx++ 直接跳段 |
:5890-5896 | fetch 失败 / HTTP 非 200 / 隧道被掐 | 弹错 → tts_idx++ → 继续 |
:5940-5947 | <audio> 解码/加载失败(onerror) | 弹错 → tts_idx++ → 继续 |
:5948-5953 | audio.play() 被拒(自动播放策略等) | 弹错 → tts_idx++ → 继续 |
而 ttsShowError() 的提示 5 秒后自动隐藏(:5721-5727)。
这就是"读着读着就没了"的机制:隧道抖动时如果连续十几段 fetch 同时失败,这十几段会在两三秒内被逐个 tts_idx++ 冲过去,一路冲到第 84 段,触发 ttsStop()——用户感知就是"突然停了",而且错误条早消失了,看起来像什么都没发生。
还有两个"合法停止"也会被误认为异常:
ttsStop() 并提示"本章播完,已自动停止"(:5935-5938);15/30/60 分钟到点也会停(:5774-5779)。ttsClearAudioCache(),把所有已下载段的 blob URL 全部 revokeObjectURL(:5701-5712、:5715-5719)。正在播的那段如果还没解码进内存,也会失败 → onerror → 跳段。车上手忙脚乱调音量/语速时,这条很容易踩中。
估算公式(unified_server.py:5958、:5961-5965):
t += tts_chunks[i].duration || (tts_chunks[i].text.length / 4.5);
完全不看语速。这篇文档按它是 1112 秒 ≈ 18.5 分钟(与用户记忆里的"15 分钟"同一量级,取决于当时正文版本)。
真正的元凶是语速被算了两次:
speed 转成上游 speech_rate(tts_service.py:459-460)→ 合成出来的音频本身就快了一档;tts_audio.playbackRate = tts_speed(unified_server.py:5904)→ 再快一档。于是实际耗时 ≈ 估算时长 ÷ 语速²:
| 面板语速 | 上游合成 | 前端播放 | 实际总耗时(按 18.5 分钟估算) |
| --------- | --------- | --------- | ------------------------------ |
| 1.0x | 1.0x | 1.0x | 18.5 分钟 |
| 1.2x | 1.2x | 1.2x | 12.9 分钟 |
| 1.5x | 1.5x | 1.5x | 8.2 分钟 |
| 2.0x | 2.0x | 2.0x | 4.6 分钟 |
用户"才听了几分钟"完全对得上 2.0x 档——显示的还是 1.0x 的时长,所以体感是"写了 15 分钟,几分钟就没了"。再叠加 2.2 节的静默跳段,还会更短。
audio.volume 赋值(全文件 grep 无 .volume)。浏览器默认就是 100%,也就是说:加一个音量滑块也救不了,上限已经被系统媒体音量锁死了。api_balance_config.json:15-22):custom_mix_bigtts = 灿灿 zh_female_cancan_mars_bigtts 0.6 + 奶音甜妹 zh_female_sajiaonvyou_moon_bigtts 0.1 + 魅力女友 zh_female_meilinvyou_moon_bigtts 0.3。三个都是偏高、偏气声的女声,中低频薄、齿音靠前。format / sample_rate / speech_rate(tts_service.py:352-360),没有任何响度/增益字段,服务端也没做响度归一化(例如 ffmpeg loudnorm)。
VOICE_PRESETS 约 100 条(tts_service.py:25-...),字段是 id / name / category / desc。category 是"通用""解说""温柔女声"这种描述性标签,不是"什么场景用"。面板里只有:女声/男声/另类三个 tab + 名字搜索(unified_server.py:4998-5005)——没有推荐、没有预设、没有"场景 → 配置"的记忆。
偏好里只存了 speed / pitch / mix(tts_prefs,:5389、:6097-6133),没有"场景"概念,也无法为"车里"和"睡前"各存一套。
> 混音补充:_saturn / _uranus 系音色不支持混音,混了会返回空音频(tts_service.py:415-419,logs/tts_errors.log 里 2026-06-27 那批就是此原因)。做预设时必须避开这三系混音。
---
结论先说:今天这次"断断续续 / 中途断"无法精确归因。 现有三个通道都照不到播放链路。
| 通道 | 覆盖范围 | 对 TTS 能看到什么 |
| ------ | --------- | ------------------ |
RUM(unified_server.py:692-724) | 仅页面加载的 DNS/TCP/TLS/TTFB/解析耗时 | 看不到,播放期间不上报 |
慢请求落盘(web_perf.py:90-116) | 耗时 ≥5000ms 或 5xx | 只有 1 条:2026-10-25 12:40:16 · /api/tts · 5067ms |
logs/tts_errors.log | 仅 EMPTY_AUDIO(上游返回空音频) | 572 行,最新 2026-06-28,10 月无记录 |
另外两个盲区:
/api/tts 的 TTSError 分支直接返回 500,不写任何日志(unified_server.py:20164-20166);超时、HTTP 非 200、上游 code 异常同理。ttsShowError() 只弹 toast,不上报(:5721-5727)。所有跳段事件在服务端和日志里都不存在。所以第 2.1/2.2 节是"机制上的必然",但这次具体是哪几段超时、跳了几段,数据已经丢了。
| 事件 | 字段 | 落点 |
| ------ | ------ | ------ |
tts_chunk_start | idx / total / chars / voice_sig / speed / cached | /api/perf/report(通道已有,:20692-20713,加 type:"tts") |
tts_chunk_done | idx / ms / bytes / retry | 同上(sendBeacon) |
tts_chunk_skip | idx / reason(empty_blob / fetch_fail / audio_error / play_rejected) | 同上 |
tts_session_end | expected_s / played_s / skip_count / end_reason(user_stop / ended / sleep / stuck) | 同上,一次会话一条 |
服务端 /api/tts | chars / cache_hit / upstream_ms / total_ms / status / error | logs/tts_api.jsonl(≥3000ms 或非 200 必落) |
有了这五项,才能回答用户原话里的问题:"到底哪一段断的、等了多久、为什么跳。"
---
| 优先级 | 问题 | 方案 | 验收标准 |
| -------- | ------ | ------ | --------- |
| P0-1 | 语速双重生效 | 上游 speech_rate 固定 0,语速只由 playbackRate 控制(或反之,二选一) | 1.5x 实测总耗时 = 估算 ÷ 1.5,误差 <10% |
| P0-2 | 估算忽略语速 | ttsEstimateTotal() 返回 raw / tts_speed | 面板总时长随语速实时变化 |
| P0-3 | 静默跳段 | 每段失败重试 1 次;仍失败则暂停并常驻提示"第 N 段合成失败 · 重试 / 跳过",不自动消失 | 断网 10s,播放停在第 N 段而不是冲到文末 |
| P0-4 | 预取太浅且串行 | 改成"按播放位置向前看 6–8 段"的滑动窗口,下载并发 ≤2,命中缓存时直接加深窗口 | 段间静音空洞 <0.3s 的占比 >95%(需 P0 埋点数据支撑) |
| P0-5 | 服务端非流式 | 边收上游分片边 chunked 下发,前端先播首片;短期折中:首段单请求、后续段后台并发合成 | 首段出声时间 <1.5s |
| P1-1 | 不会选音色 | 落地第五章的场景预设 + 一键应用 + 按场景记忆 | 车里打开听书,默认就是"车噪"预设 |
| P1-2 | 声音小 | 加音量滑块(audio.volume 0–1);可选服务端响度归一化(是否支持上游响度参数需先查文档确认,不臆造字段) | 嘈杂环境 A/B,主观可懂度明显提升 |
| P1-3 | 锁屏/后台失控 | 接 Media Session API(锁屏显示标题与播放控制)+ 页面隐藏时降低预取压力而非停摆 | 锁屏可暂停/续播,进度不丢 |
| P1-4 | 缓存无治理 | tts_cache 现 509 条 / 26MB、无 TTL 无上限(tts_service.py:272-312),加 LRU 上限(如 500MB / 90 天) | 表体积受控,命中率不降 |
| P2-1 | 想跳章节 | 按 h2 生成章节目录,支持"下一节 / 只听标题" | 听 84 段的长文可秒级定位 |
P0 的落地顺序建议:先做 P0-2(改一行,立刻消除"时间骗人")→ P0-1(消除双重加速)→ P0-3(把静默失败变成可见)→ 埋点 → P0-4 / P0-5(真正解决断断续续)。前三个改动小、体验收益大,可在一次迭代内完成。
---
设计原则:用户只选场景,不选参数。每个预设 = {voice, speed, pitch, volume} 四元组,一键应用,并记住"每个场景上次用的配置"。
| 场景 | 推荐音色 | 音色 ID | 语速 | 音高 | 为什么 |
| ------ | --------- | --------- | ------ | ------ | -------- |
| 🚗 车内 / 嘈杂(男声) | 解说小明 | zh_male_jieshuoxiaoming_moon_bigtts | 1.0 | 0 | 解说腔,咬字实、辅音清楚,最抗噪 |
| 🚗 车内 / 嘈杂(女声) | 爽快思思 / Skye | zh_female_shuangkuaisisi_moon_bigtts | 1.0 | 0 | 中频集中、不飘,不用气声 |
| 🚗 车内(偏沉稳) | 知性女声 | zh_female_zhixingnvsheng_mars_bigtts | 1.0 | 0 | 语流稳,长句不易糊 |
| 🎧 通勤耳机(信息密度优先) | 云舟 2.0 | zh_male_m191_uranus_bigtts | 1.2 | 0 | 沉稳清晰,适合 1.2x 快听 |
| 🌙 睡前 / 安静 | 温柔小雅 | zh_female_wenrouxiaoya_moon_bigtts | 0.9 | 0 | 柔和、少齿音,不刺激 |
| 🌙 睡前(男声) | 温暖阿虎 / Alvin | zh_male_wennuanahu_moon_bigtts | 0.9 | 0 | 温暖低音量友好 |
| 📖 边看边听(对照阅读) | 渊博小叔 | zh_male_yuanboxiaoshu_moon_bigtts | 1.0 | 0 | 叙述感强,句读清楚 |
| ⚠️ 不推荐车噪 | 默认混音(灿灿 + 奶音甜妹 + 魅力女友) | custom_mix_bigtts | — | — | 气声/高频为主,车噪下最先被淹没;桌面安静环境可用 |
落地形态(前端):
// 预设表:场景 → 参数
var TTS_SCENES = {
car_male: {label:'🚗 车内·男声', voice:'zh_male_jieshuoxiaoming_moon_bigtts', speed:1.0, pitch:0, volume:1.0},
car_female:{label:'🚗 车内·女声', voice:'zh_female_shuangkuaisisi_moon_bigtts', speed:1.0, pitch:0, volume:1.0},
commute: {label:'🎧 通勤耳机', voice:'zh_male_m191_uranus_bigtts', speed:1.2, pitch:0, volume:1.0},
bedtime: {label:'🌙 睡前', voice:'zh_female_wenrouxiaoya_moon_bigtts', speed:0.9, pitch:0, volume:0.8}
};
// 存储:当前场景 + 每个场景的覆盖值
localStorage['tts_scene'] = 'car_male';
localStorage['tts_scene_overrides'] = JSON.stringify({car_male:{speed:1.1}});
两个细节:
_saturn/_uranus 混音不支持的问题;若用户手动混音,仍按现有 mix_compatible 限制拦。---
用户的原话是:「产品经理要做的事情好像就是这些吧?不同体验产品,提出问题,然后反复去优化,还有什么思维?」以这次听书为例,能抽出 9 条可复用的:
这次的具体行动线(按顺序):
---
| 结论 | 位置 |
| ------ | ------ |
| 每段一次请求 | unified_server.py:5661-5677、:5869 |
| 预取仅 2 级且串行 | unified_server.py:5823-5829、:5878-5887 |
| 服务端非流式 | tts_service.py:382-425 |
| 单段实测 5067ms | logs/slow_api/2026-10-25.jsonl:16 |
| 四条静默跳段 | unified_server.py:5874、:5890-5896、:5940-5947、:5948-5953 |
| 错误提示 5 秒消失 | unified_server.py:5721-5727 |
| 语速双算 | tts_service.py:459-460 + unified_server.py:5904 |
| 时长估算忽略语速 | unified_server.py:5958、:5963 |
| 无音量赋值 / 无响度参数 | 全文件无 .volume;tts_service.py:352-360 |
| 默认气声混音 | api_balance_config.json:15-22 |
| 音色仅分类无场景 | tts_service.py:25-...、unified_server.py:4998-5005 |
| TTS 零埋点 | unified_server.py:5721-5727(只弹 toast)、:20164-20166(500 不写日志) |
| 日志最新只到 6 月 | logs/tts_errors.log(572 行,全部 EMPTY_AUDIO,2026-06-27/28) |
| saturn/uranus 不支持混音 | tts_service.py:415-419 |
| 缓存无 TTL | tts_service.py:272-312 |