给任意一首歌做逐词卡拉OK高亮,误差控制在 5ms 级
【免费下载链接】pdoom-videoCode-rendered music video for "I'm Upping My P(doom)"项目地址: https://gitcode.com/gh_mirrors/pd/pdoom-video
卡拉OK逐词高亮看起来简单——词到了就亮、唱完就灭。但一旦把"任意一首歌"作为输入,问题立刻变成一场信号处理与语音识别模型的联合战役:唱片的伴奏混着人声、歌手会吞音和换气、副歌双轨叠唱、AI 合成的歌曲还带着不自然的延音……这恰好是 pdoom-video(一个用 TypeScript + three.js 代码渲染的音乐视频项目,为《I'm Upping My P(doom)》这首歌而生)已经趟平的路。它的最终产物是精确到毫秒的词级时间戳,前端用一行纯函数驱动高亮 wipe,且实时预览与离线 4K 导出逐帧一致。
这篇文章不聊渲染本身,只拆解"歌词怎么精确到每个词"这件事:人声分离与 CTC 强对齐如何产出词级时间戳,Whisper 独立输出如何交叉验证,5ms 级的信号特征精修规则长什么样,以及前端wordProgress纯函数如何与渲染对接。全程对照仓库真实代码。
一、人声分离与 CTC 强对齐:词级时间戳怎么来
第一步不是对齐,是先把人声从伴奏里抠出来
直接拿混音去对齐歌词,伴奏的打击乐会让声学模型的发射概率一团糟。pdoom-video 的做法是双层分离:
- 用 Demucs
htdemucs_ft把整首歌切成 vocals / drums / bass / other 四轨(analysis/common.py); - 再用 mel-band-roformer 的 karaoke 模型跑一版 lead vocal,专门应对"主唱被垫底和声埋住"的场景。
这里有个容易翻车的细节:MP3 的 LAME 编码器延迟(encoder delay)会让分轨结果整体偏移。仓库里通过互相关测量得到固定偏移1015 samples @ 44.1kHz ≈ 23ms,统一在加载时切除(STEM_OFFSET_SAMPLES),保证所有时间都以"无间隙 MP3 解码"为唯一时间基准。任何一步对齐工具的采样率、延迟不同,最终时间戳就会系统性错位——这正是"误差控制在毫秒级"的第一道关卡。
双 CTC 模型 + 多通道融合,20ms 帧发射概率
对齐的主体是字符级 CTC 强制对齐。analysis/ctc_emissions.py 用两个完全独立的声学模型分别跑:
- torchaudioMMS_FA(多语言、romanized 字符、专为对齐训练);
- wav2vec2large-lv60k-960h(英语字符)。
输入是 16kHz 的人声 stem,按HOP = 320 samples → 20ms/帧切帧,输出每帧每个字符的对数概率。为了让两条独立信号尽量"独立",除了单声道求和,还分别跑了副歌的双轨 L/R 通道——副歌是双轨叠唱的,左右声道各自更接近单一声线。最终主发射集fused6把两个模型 × 三个源(mono/L/R)共 6 份发射概率做logaddexp概率混合:
def emissions(kind): if kind == "fused6": es = [_common_logp(m + s) for m in ("mms", "lv60k") for s in ("", "_vocL", "_vocR")] return np.logaddexp.reduce(np.stack(es), axis=0) - np.log(len(es))全曲一次 Viterbi + 发音拼写适配 + "垃圾 token"吸收杂质
拿到发射概率后,analysis/ctcalign.py 做的是全曲单次约束 Viterbi 对齐:46 行歌词的全部字符拼成一条目标序列,一次性在整首歌的时间轴上解码,而不是逐行对齐。这么做有两个关键收益:
- 行与行之间插入一个 garbage "star" token,它的帧级分数是"当前帧最佳 token 概率减 margin",于是 ad-lib、和声、尾奏哼唱全部被 star 吸收,而真正匹配歌词的位置歌词字符依然胜出;
- 单词按发音拼写展开成子词单元。仓库的 analysis/pron.py 里有一张显式映射表:
PRON = { "AGI": "ay gee i", "P(doom)": "pee doom", "ChatGPT,": "chat gee pee tee", "NVDA": "en vee dee ay", "RLHF": "are el aitch eff", "Neumann's": "noymans", ... }没有这张表,CTC 模型面对 "P(doom)"、"NVDA"、"RLHF" 这种歌词里的 AI 黑话几乎必然错位。展开后的子词单元顺带成为音节级时间戳的基础——这也是为什么 data/lyrics.json 里像AGI这种词会带syl数组:
{ "w": "AGI", "start": 3.677, "end": 4.704, "conf": 0.84, "syl": [[3.677, 4.06], [4.06, 4.355], [4.355, 4.704]] }最后,Viterbi 支持对"人工验证过的硬骨头"注入锚点约束(lo/hi时间窗),仓库里保留了约 20 处手工校正(FIX表),例如副歌拾音 "I'm" 的起音、被垫底和声埋住的终曲 "P(doom)" 等,全部依据频谱/音高 QA 图人工确认。
二、Whisper 独立输出交叉验证 + 信号特征 5ms 精修
CTC 强对齐的精度上限受帧长(20ms)和声学模型制约,且对"长元音起音晚、擦音落点靠后"这类语音学现象有系统性偏差。pdoom-video 的第二个层次是把对齐结果拉到 5ms 级。
Whisper 作独立交叉验证
analysis/whisper_run.py 用mlx-whisper large-v3-turbo在时间校正后的人声 stem 上跑word_timestamps=True,得到一套完全独立于 CTC 的词级时间戳。由于歌曲满是 AI doom 黑话,还注入了一段初始 prompt(列举 P(doom)、FOOM、shoggoth、RLHF、NVDA 等词汇)压制幻觉。
analysis/align.py 里的map_whisper用difflib.SequenceMatcher对 Whisper 词序列与歌词 token 做模糊序列对齐,并对"时间差超过 1.5s"的匹配直接拒绝,防止乱配对。此后 Whisper 时间戳不再直接改边界,而是进入置信度公式:
c = 0.35 + 0.3 * agree + 0.2 * p + 0.15 * wagree其中agree是各独立对齐(mms、lv60k、双轨、lead)对词起点的共识度(±60ms 内算同意),p来自 CTC 后验,wagree是 Whisper 起点与最终起点的一致性(±150ms)。输出conf_final写进 lyrics.json,渲染端可以据此把低置信词做得更保守。
5ms 特征上的三条精修规则
交叉验证解决"对不对",信号精修解决"准不准"。 analysis/vocal_feats.py 在人声 stem 上以5ms hop(HOP=110 @ 22050Hz)提取一组特征:RMS 包络、pyin 基频 + 浊音概率、log-mel 频谱通量 onset 强度、4–10kHz 高频能量比(sib_ratio,专门抓擦音)。
analysis/align.py 的refine()对每个子词单元按序套三条规则:
- rest-onset:若词前存在 ≥50ms 静音且人声在 CTC 首个字符前 >40ms 重新进入,就把起点挪到人声重入点——解决 CTC 对长元音起音偏晚的问题(如开场长 "I");
- onset-snap:否则在前窗内吸附到最强的频谱通量 onset(元音起始词搜索窗放宽到 250ms),对应连读单词的声门/元音起音;
- fricative:对 s/sh/ch/z/f/th/j/h 开头且非浊音 th 的词,CTC 常把摩擦音落在摩擦结束处,规则在起音前窗内沿
sib_ratio回溯到 4–10kHz 噪声起始点。
词尾同样有明确逻辑:legato 连唱时词尾 = 下一词起点;否则取"RMS 低于本词水平 15dB 且持续 ≥60ms"的时刻。全部处理完再做单调性约束(相邻词起点至少间隔 20ms、杜绝重叠)。
特征帧是 5ms,所以这套流程产出的词边界精度就是 5ms 量级——仓库 NOTES 对全曲 400+ 词的自我评估是"词起点普遍在 30–50ms 内",个别被和声埋住的词标注 ±100ms 的不确定性并给出理由。这种"给出误差界"的态度,比任何"精确到帧"的宣传都诚实。
三、前端 wordProgress 纯函数与 GPU 渲染的对接
数据对齐完,前端要做的反而简单——因为整个渲染器被设计成"任意帧画面都是歌曲时间 t 的纯函数"(详见 docs/ENGINE.md),词级高亮只是其中一个查询接口。
一个纯函数吃遍所有高亮形态
app/src/engine/lyrics.ts 定义了两个核心查询:
static wordProgress(w: Word, t: number): number { if (t <= w.start) return 0; if (t >= w.end) return 1; if (w.syl && w.syl.length > 1) { const n = w.syl.length; for (let i = 0; i < n; i++) { const [a, b] = w.syl[i]!; if (t < a) return i / n; if (t < b) return (i + (t - a) / Math.max(1e-3, b - a)) / n; } return 1; } return (t - w.start) / Math.max(1e-3, w.end - w.start); } static lineCharProgress(l: Line, t: number): number { let chars = 0; for (const w of l.words) { const p = Lyrics.wordProgress(w, t); chars += p * w.w.length; if (p < 1) break; chars += 1; // the space } return Math.min(chars, l.text.length); }wordProgress返回 0→1 的演唱进度:词前为 0、词后为 1,词内线性插值;带syl的复合词(AGI、ChatGPT、P(doom))按音节分段推进,实现"字母逐个点亮"。lineCharProgress再把整行的字符级进度算出来,供逐字形 wipe 使用。它是纯函数:给定(word, t),结果唯一确定——这正是预览与导出帧级一致的前提。
从进度到画面:Canvas2D、GPU uniform 与线段批次
十六个场景模块(app/src/scenes/)把这两个函数用出了花:
- 逐字形点亮:
loss.ts里sparkX(t)按字符进度换算火花尖端横坐标,ascent.ts中lit = clamp(Lyrics.wordProgress(w, t) * w.w.length - charIdx[g.i])决定每个字形是否高亮; - 整词色块填充:
room.ts用wordProgress当矩形 clip 的宽度系数,把 "P(DOOM)" 随着演唱从左到右填充进轮廓;loom.ts反向用cover = 1 - p做色块擦除; - 填条进度:
leftturn-gantt.ts直接拿p画甘特条填充比例(p < 1 ? rgba('signal', 1) : rgba('bone', 0.9)); - 推进 GPU 着色器:
dense.ts把多个词的进度打包成vec4uniform 传入 GLSL——卡拉OK进度本身参与 GPU 端排布计算,说明这套时间戳可以一直下沉到渲染管线的数据面; - 机械书写同步:单笔画 plotter 字体场景(app/src/engine/stroke.ts)提供
writtenLength(st, charTimes, t),把"书写进度"与词级时间戳对齐,火花拖着细线"写"歌词的视觉隐喻由此而来。
时间戳还顺带驱动了剪辑:app/src/timeline.ts 的cut()取"目标歌词行首词所在节拍之前最近的一拍"作为切镜点,after()吸附到最近的重拍——切镜永远不切开正在唱的歌词,且落在节拍网格上。
用真实数据把闭环跑一遍
仓库 data/lyrics.json 里第一行 "I see sparks of AGI in your eyes" 的成品时间戳:I1.407–2.35、see2.35–2.739、sparks2.739–3.46、AGI3.677–4.704(含三个音节分段)、eyes5.263–5.88。配合 data/audio.json 的 132.007 BPM 节拍网格,开场那一句从 1.4s 亮起,逐词燃到 5.9s,正好呼应下面这张开场场景定格(TikZ 风格歌词随演唱逐词点燃):
整条链路是:Demucs/mel-roformer 分离人声 → 双 CTC 模型 20ms 帧发射 → 全曲约束 Viterbi → Whisper 交叉验证 → 5ms 特征精修与置信度融合 → JSON 时间戳 → 前端纯函数查询 → Canvas2D/GPU/线段批次渲染。每一层都在为"误差控制在 5ms 级"服务:分离层校正编码器延迟、对齐层用多源融合和发音映射兜住黑话、精修层用语音学规则修正声学模型的系统性偏差、置信度层公开每个词的可信区间。
如果你也想给自己的任意一首歌做逐词卡拉OK,可复用的部分恰好是这条流水线的前半段——analysis/ 目录下的脚本与模型选择、refine()的三条规则、置信度公式,它们与具体歌曲无关;而后半段的纯函数接口wordProgress则提醒你:高亮效果做得再花哨,其精度上限在进入前端之前就已经写死了。与其在渲染里堆帧率,不如回到音频分析里把边界校到位。
【免费下载链接】pdoom-videoCode-rendered music video for "I'm Upping My P(doom)"项目地址: https://gitcode.com/gh_mirrors/pd/pdoom-video
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考