P14 · Codec 窗口规划:首块小、后块大、左上下文
TTFP 要低(首块只要 4 帧),吞吐要高(之后 25 帧一块),接缝要无损(左上下文重算后切片)——三个目标落在两个纯函数里。
mine/p14_codec_windows.py
验收 ./check.sh p14
提示 A-23 · 参考解 C-23 solutions/p14_codec_windows.py
题面速览
- 在系统中的位置:talker 每步产出 1 帧 codec,攒成窗口发给 code2wav;code2wav 每个窗口独立解码(无状态,便于 batch)。Qwen3-Omni 默认
initial_codec_chunk_frames=4, codec_chunk_frames=25, codec_left_context_frames=25。
next_window(available, emitted, finished) -> (ctx_start, new_start, new_end) | None
size = initial(第一块且 initial>0)否则 chunk
available - emitted ≥ size → new_end = emitted + size
finished 且还有剩余 → new_end = available (尾部 flush)
否则 → None(继续等)
new_start = emitted; ctx_start = max(0, new_start - left_ctx)
code2wav 解码 codes[ctx_start:new_end],得到 (new_end-ctx_start)·up 个样本,
只输出属于 [new_start, new_end) 的那一段(左上下文重算但不重复输出)
- 为什么左上下文能消除接缝:vocoder 是因果的、感受野有限:样本只依赖最近 r 帧。只要
left_ctx ≥ r,窗口内新帧看到的上下文就与整段解码完全相同——测试用一个感受野可控的玩具 vocoder 证明这一点(以及不足时确实出现接缝)。
验收要点:首块/稳态/尾部 flush 的精确窗口 · 无首块配置 · 随机到达模式下窗口恰好平铺、无重叠无空洞 · 左上下文 ≥ 感受野时流式 == 整段解码;不足时出现接缝。
直觉与类比
译员不会等演讲者讲完一整段才开口(TTFP 高),先听两三个词就翻第一句(小的首块),之后每次听一整句再翻(大块,更流畅)。每次开始翻新一句时,他会回想上一句的最后几个词,好让语气和指代接得上(左上下文)——这几个词不会再说一遍。演讲结束时,剩下的半句也要翻出来(finished 时 flush)。
每一集(窗口)开头都放一段“前情提要”([ctx_start, new_start)),让观众(vocoder)进入状态。剪成一部完整电影时必须把每集的提要剪掉,否则同一段剧情出现两次——slice_new_samples 就是那把剪刀。提要至少要覆盖观众需要的记忆长度(感受野),太短开场几秒就会“断片”——接缝。
框架视角 · 一个窗口的一生(以 (5, 9, 15)、upsample=4 为例)
| # | 量 | 坐标 / 形状 | 发生了什么 |
|---|---|---|---|
| 1 | available, emitted | 标量,全局帧数 | 17, 9:已产出 17 帧,前 9 帧已发过 |
| 2 | size | 帧数 | 不是首块 → 用 chunk = 6 |
| 3 | (ctx_start, new_start, new_end) | 全局帧下标 | (5, 9, 15):上下文 5..8,新帧 9..14;15、16 留给下一次 |
| 4 | codes[ctx_start:new_end] |
10 帧 | 发给 code2wav 的那段 |
| 5 | decoded | [(15-5)·4] = [40],局部样本坐标 |
下标 0 对应全局帧 5 的第一个样本 |
| 6 | 切片起点 | (9-5)·4 = 16 |
把“全局帧 9”换算成局部样本下标 |
| 7 | 返回值 | [24] = 6 帧 × 4 |
只输出新帧;所有窗口首尾相接 = 17×4 = 68 个样本 |
易错点
- 首块判断用“还没发过任何帧”(emitted == 0)且 initial > 0;别用 available 去猜——帧可能一次来很多。
- 判断顺序:先看“够不够一个完整窗口”,再看“是否 finished 且有剩余”;finished 不等于“把剩下的全部打包”。
- 不要返回空窗口:流结束且全部发完时返回 None,否则调用方的 while 循环永远停不下来。
- ctx_start 不能为负。
- 局部 vs 全局坐标:decoded 从 ctx_start 开始计数;用
new_start·upsample直接切是最常见的 bug(只在 ctx_start=0 时碰巧对)。 left_ctx ≥ 感受野才能逐样本无接缝;这依赖 vocoder “因果 + 局部”。
逐步图:initial=2, chunk=5, left_ctx=3
首块要 2 帧,只有 1 帧 → None
size = 2(首块)→ (0, 0, 2)
size = 5,只有 4 帧新帧,未结束 → None
→ (0, 2, 7),ctx_start = max(0, 2−3) = 0(左上下文被 0 截断)
→ (4, 7, 12):左上下文 3 帧 4,5,6
帧:01234567891011(橙 = 上下文,蓝 = 新帧)
finished,但完整的一块还够 → 仍按 5 帧切:(9, 12, 17)
size?差值?分支?结果?
size=5,差值 3,不够完整一块 → flush,new_end=20,ctx_start=14 → (14, 17, 20)
全部发完 → None(返回空窗口会让循环永不结束)
首块都凑不满,直接 flush → (0, 0, 1)
三级提示
L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)
例 1 见逐步图。没有首块配置:next_window(6, 0, False, initial=0, chunk=6, left_ctx=10) -> (0, 0, 6),next_window(12, 6, False, ...) -> (0, 6, 12)。
例 2:slice_new_samples
window = (4, 7, 12), upsample = 2
decoded = vocoder(codes[4:12]) # 8 帧 × 2 = 16 个样本: s0 .. s15
# s0..s5 属于上下文帧 4,5,6;s6..s15 属于新帧 7..11
slice_new_samples(decoded, (4, 7, 12), 2) -> [s6, ..., s15]
slice_new_samples([0, 1, 2, 3], (0, 0, 2), 2) -> [0, 1, 2, 3] # 没有左上下文时整段都是新的
例 3:一次完整的流(initial=2, chunk=5, left_ctx=3,共 14 帧分 6 批到达):
| 批次 | 本批帧数 | available | finished | 本批循环得到的窗口 | 之后 emitted |
|---|---|---|---|---|---|
| 0 | 1 | 1 | F | (无) | 0 |
| 1 | 2 | 3 | F | (0, 0, 2) | 2 |
| 2 | 4 | 7 | F | (0, 2, 7) | 7 |
| 3 | 3 | 10 | F | (无) | 7 |
| 4 | 1 | 11 | F | (无) | 7 |
| 5 | 3 | 14 | T | (4, 7, 12), (9, 12, 14) | 14 |
调用者每批循环调用 next_window 直到 None,每拿到一个窗口令 emitted = new_end;所有 [new_start, new_end) 恰好拼成 [0, 14)。
关键问题
- 函数没有内部状态,“第一个窗口”怎么从参数里判断?
- initial=0 表示什么?此时首块用多大?
- finished=True 但剩余 ≥ 一个完整窗口时,一次发出全部剩余还是仍按 chunk 切?
- finished 且 available == emitted 时返回什么?返回
(…, 20, 20)会怎样? - ctx_start 为什么不能为负?它和 emitted 之间的帧被解码几次、输出几次?
- decoded 的第 i 个样本属于窗口里第几帧?new_start 的第一个样本在 decoded 中的下标?
- left_ctx 要多大,流式结果才和整段解码逐样本相同?依赖 vocoder 的什么性质?
L2思路与常见坑思路拆分与常见坑(中度剧透)
- next_window 三步:① 算 size(首块判断:
emitted == 0 and initial > 0);② 决定 new_end:完整窗口 / flush / None;③ 由 emitted 和 left_ctx 算 new_start、ctx_start。 - 判断顺序:先完整窗口,再 flush。把 finished 放前面,
(20, 12, True)会返回(9, 12, 20)——不丢帧,但违反“完整窗口可用时返回完整窗口”。 - flush 条件要求
available > emitted。 - slice 公式:起点
(new_start − ctx_start) * upsample,终点(new_end − ctx_start) * upsample(即 len(decoded))。用全局new_start * upsample切局部 decoded,例 2 会得到decoded[14:24],只剩 2 个样本。 - 可加断言
len(decoded) == (new_end − ctx_start) * upsample。
L3详解:答案与接缝例子详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- 首块 ⇔
emitted == 0(还没发出过任何帧)且initial > 0。 - 没有特殊首块,首块也用 chunk。
- 仍然按 chunk 切;剩下不足一块时才 flush。
- None;返回空窗口会让 emitted 停在原地,循环永不结束。
- 流开头
new_start − left_ctx可能为负,帧下标没有负数。上下文帧会被解码不止一次(作为上一窗口新帧一次,作为下一窗口上下文再一次),但只输出一次。 - decoded[i] 属于局部帧
i // upsample,即全局帧ctx_start + i // upsample;new_start 的第一个样本在(new_start − ctx_start) * upsample。 - left_ctx ≥ vocoder 的(因果)感受野;依赖“因果 + 局部”。
左上下文 vs 感受野:一个能手算的例子。玩具 vocoder:帧 i 输出 2 个样本 [10·S, 10·S+1],S = 窗口内 codes[max(0,i−2) .. i] 之和(感受野 r=2,只看窗口内部)。codes = [3,1,4,1,5,9,2,6],initial=3, chunk=3,一次性全部可用。
| left_ctx | 窗口 | 本窗口 decoded | 切出的新样本 |
|---|---|---|---|
| 2 | (0,0,3) | 30,31,40,41,80,81 | 30,31,40,41,80,81 |
| 2 | (1,3,6) | 10,11,50,51,60,61,100,101,150,151 | 60,61,100,101,150,151 |
| 2 | (4,6,8) | 50,51,140,141,160,161,170,171 | 160,161,170,171 |
| 1 | (2,3,6) | 40,41,50,51,100,101,150,151 | 50,51,100,101,150,151 |
| 1 | (5,6,8) | 90,91,110,111,170,171 | 110,111,170,171 |
整段解码:30,31,40,41,80,81,60,61,100,101,150,151,160,161,170,171。left_ctx=2 时完全一致;left_ctx=1 时每个窗口第一帧(加粗)出现接缝:帧 3 本该看到 codes[1..3] = 1+4+1 = 6,却只看到 codes[2..3] = 4+1 = 5。
面试官追问 & 对照源码
- 做成 ramp [1,2,4,8,16,25] 时 TTFP/RTF 如何变化?
- 为什么无状态解码让 code2wav 可以自由 batch?(对比 M06)
对照源码:model_executor/stage_input_processors/qwen3_omni.py:765-842 · model_executor/models/qwen3_omni/qwen3_omni_code2wav.py:447-508