一条音频流水线如何生成整支视频:132 BPM 节拍网格从 MP3 到画面的完整旅程
【免费下载链接】pdoom-videoCode-rendered music video for "I'm Upping My P(doom)"项目地址: https://gitcode.com/gh_mirrors/pd/pdoom-video
把一首歌变成一支 4K60 的音乐视频,最难的不是渲染,而是「对齐」:每一次镜头切换要踩在节拍上,每一句歌词要精确到词的毫秒级起止,底鼓落下的一帧画面要正好跟着冲击波震动。pdoom-video 这个项目把整条链路做成了可复现的确定性管线——仓库里只有一份audio/pdoom.mp3,而渲染引擎唯一依赖的是两个 JSON 文件:data/audio.json(节拍网格、鼓点、段落、包络)与data/lyrics.json(词级时间戳)。这篇文章沿着这条管线走一遍:从 MP3 解码到 132.007 BPM 的恒定节拍网格,从底鼓/军鼓/踩镲的频带识别到 14 个音乐段落的切分,最后看毫秒级数据如何成为渲染引擎的唯一输入、驱动每一帧画面。
先校准时间轴:LAME 编码延迟这 23 毫秒
任何音频分析的第一个坑是「时间基准不一致」。MP3 在浏览器、ffmpeg、libsndfile 里做无间隙(gapless)解码时,会裁掉 LAME 编码器在文件头尾注入的 1105 个样本(48 kHz 下正好 23.0 ms)的延迟。而 Demucs 分离出来的 stems 是基于未裁减的原始解码生成的——两者之间差了 23 毫秒。
analysis/common.py用互相关测量出这个偏移并硬编码修正:
STEM_OFFSET_SAMPLES = 1015 # 1105 samples @48kHz = 23.0ms, measured by cross-correlation STEM_OFFSET_SEC = STEM_OFFSET_SAMPLES / 44100所有从 stems 计算出的时间戳都被统一平移回「无间隙 MP3 时间线」,这正是浏览器播放器和渲染引擎使用的唯一时间基准。这一步没做对,后面所有的节拍、鼓点、歌词对齐都会系统性偏斜一个不可接受的数量级。
三级 BPM 拟合:从粗搜到相位精修
有了对齐后的鼓组 stem 与混音,analysis/analyze.py的fit_grid()在固定节奏假设下搜索整首歌的节拍网格——先证明这首歌不存在变速。算法分三级:
第一级(粗):在 125.0–138.0 BPM 区间以 0.02 BPM 步长、每拍 0.004 秒相位步长暴力打分。打分函数把候选网格投影到鼓组与混音的 onset 强度包络上,取每个拍点前后 1 帧的局部最大值均值作为分数:
for bpm in np.arange(125.0, 138.0, 0.02): P = 60 / bpm for off in np.arange(0, P, 0.004): s = score(P, off)第二级(细):在粗最优值 ±0.02 BPM、±0.01 秒内以 0.001 步长重扫。注意这里拟合的是「鼓 + 混音」双路 onset 包络,因为前奏、第一段主歌和 pre3 段落里没有鼓,网格必须靠混音 onset 在无鼓区间外推。
第三级(相位精修):网格的相位(第一拍到底在哪)用底鼓攻击点修正——取<120 Hz频带对数能量的最陡上升沿作为 kick 时刻,计算每个 kick 与网格最近拍的残差,用中位数回填相位:
n = np.round((kick_t - off) / P) res = kick_t - (off + n * P) res = res[np.abs(res) < 0.06] off = off + float(np.median(res))最终结果写入data/audio.json:132.007 BPM,拍周期 0.45452 s,第一拍位于 0.238 s,且经检验全曲无漂移(每 15 秒相位偏差不超过 2 ms)。这意味着整首歌 344 拍可以只用两个参数(BPM + 相位)完全刻画,渲染引擎在任何时刻都能确定性地算出「现在处于第几拍、第几个 bar」。
小节相位(哪一拍是强拍)则由鼓的编排解决一半:four-on-the-floor 的底鼓、第 2/4 拍的军鼓、八分音符反拍踩镲把相位锁定到半小节以内;剩下的半小节歧义由和声解决——Eb-Bb-Cm-Ab 每两小节换一次和弦,换和弦点恰好落在 8 拍一循环的强拍索引上,而每个合唱段的 "DOOM" 都落在下拍(bars 13/33/53/69)。这也解释了为什么「切镜踩点」能做到如此精确。
底鼓 / 军鼓 / 踩镲:三套频带逻辑
drum_onsets()在鼓组 stem 上用频带能量包络识别三类打击乐,每条规则都针对一种乐器的物理特性:
- Kick(277 个):
<120 Hz频带的对数能量包络,取 12 ms 窗口内的最陡上升沿,min_gap=0.15s、rel_db=12抑制弱信号与串扰。 - Snare(132 个):候选为 1.5–5 kHz 的攻击,但真正区分军鼓的是它 40–120 ms 后的噪声尾(0.5–5 kHz 的持续噪声段)。代码检查候选的尾部能量是否落在 ±2.5 s 内最响尾部 8 dB 以内、且高于全曲水平的 25 dB 以下阈值,从而把 kick-only 或 hat 的瞬态排除掉。
- Hat(254 个):
>7 kHz、窗口更短(6 ms),且剔除与军鼓相距 40 ms、与 kick 相距 30 ms 的候选——军鼓噪声会窜到 7 kHz 以上,kick 的鼓槌 click 也能到 10 kHz。
band_onsets()的实现相当讲究:先对频带信号做零相位滤波(sosfiltfilt),用滑动窗口能量取 dB,再做 20 ms 的上升量检测;攻击时刻取上升窗口内的最陡斜率点,强度取峰值 dB 上升量。所有结果归一化到 0–1,与时间戳一起写入onsets字段,供渲染引擎做「衰减脉冲」驱动。
这些鼓点不只是数据,它们直接被场景消费:room.ts里au.events('kick', ...)生成镜头切帧时刻、au.events('hat', ...)让书架上的书按踩镲滑出,loom.ts用f.a.kick给镜头缩放加震动。
14 个音乐段落:规则写在注释里,落在 downbeat 上
段落切分在analyze.py的SECTION_BARS表里以 bar 为单位硬编码,共 14 段:intro → verse1/2/3 → pre1/2/3 → chorus1–4 → break1 → bridge → outro。切分规则记录在表头注释中:段落从该段第一句歌词所在小节的下拍开始,除非歌词以短于两拍的前缀音(pickup)起唱——此时前缀留在上一段。比如每个合唱段都以 "I'm upping my P-"(约 3 拍,压在 bass 停止的间隙上)进入,真正的 "DOOM" 落在下拍,因此合唱段从 pickup 小节起算。
这 14 段与歌曲结构互为印证:pre3(89.3–94.8 s)是鼓与贝斯全部退场的 breakdown;chorus3(94.8–109.3 s)是只有轻鼓、无贝斯的轻声合唱;第 61 bar(111.14 s)全乐队回归;chorus4 以停止小节(138.41–140.23 s)收尾;outro 的鼓声持续到 152.96 s 后渐隐至 ~155.5 s 的静默。每段的起止时间同样是downbeat 时间 + k × 4 × beat_period的解析解,而非独立检测——整份audio.json完全建立在一个恒定网格之上。
100 fps 包络与鼓点脉冲:渲染引擎的「触感」
data/audio.json还包含 100 fps 的归一化包络:全曲 RMS、低(<150 Hz)/中(150–2000 Hz)/高(>4 kHz)分频带能量,以及 vocal/drums/bass/other 四个 stem 各自的 RMS。每条包络都经过 46 ms RMS 窗、单极点平滑(10 ms attack / 90 ms release),再除以自身 99 分位数裁到 0–1。
渲染侧app/src/engine/audio.ts的AudioData类把这份 JSON 变成实时查询接口:env(name, t)做线性插值、envPeak做峰值保持(punchy 反应)、hit(kind, t, halfLife)把离散的 onset 事件转成半衰期约 90–140 ms 的指数衰减脉冲。于是场景代码只需写f.a.kick就能得到「这一帧离最近的底鼓过了多久、力度还剩多少」,驱动镜头震动、卡片弹出、火花的呼吸缩放。beatAt/timeOfBarAt/nearestBeat则提供任意时刻到节拍/小节网格的连续映射。
app/src/timeline.ts是这条管线在剪辑层的关键一环:每个场景的起点由cut()决定——取目标歌词行首词之前最后一个节拍,再用after()吸附到最近的下拍。整支视频 22 个场景的边界全部由「歌词词首 + 节拍网格」推导,而不是手敲秒数,保证了无论数据如何重算,剪辑永远踩在拍上。
词级对齐:让「逐词卡拉OK」成为可能
画面要跟唱词同步到词级,光有节拍网格不够。data/lyrics.json由analysis/align.py生成,方法论与节拍网格共享同一条时间线:
- CTC 发射概率:两个字符级模型(torchaudio MMS_FA 与 wav2vec2-large-lv60k-960h)分别在人声 stem 的单声道、左声道、右声道上输出 20 ms 帧的字母级发射概率——因为副歌是左右双轨叠录,各声道更接近单一声线。
fused6把六个发射集合做概率混合(ctcalign.py的emissions())。 - 全局 Viterbi 强制对齐:46 行歌词在整首歌上做一次约束对齐,行与行之间放一个「垃圾」star token,用「最优 token 对数概率减 margin」吸收 ad-lib 与和声。缩写词用发音改写(AGI="ay gee eye"、P(doom)="pee doom"、ChatGPT="chat gee pee tee"……),让 CTC 能真正对齐到字符。
- 信号精修:对每个音节单元,把 CTC 边界对齐到人声信号特征——休止后重新起声(rest-onset)、吸附到最近频谱通量峰(onset-snap)、擦音词把起点推回 4–10 kHz 噪声开始处(fricative);结尾则看声音是否比词级电平低 15 dB 持续 60 ms。
- 交叉验证:mlx-whisper large-v3-turbo 独立输出词级时间戳做模糊序列对齐,最终置信度由「独立对齐在 60 ms 内一致的比例 + CTC 后验 + Whisper 一致度」加权融合,手动修正点自带置信度。
产出物的精度自述相当诚实:大多数词首预期在 ±30–50 ms 内,个别被和声压住的词(如末段副歌的 "I'm upping my")标注 ±100 ms。lyrics.json里的词级数据(含syl音节级切分)经Lyrics.wordProgress()变成逐词/逐字的擦除进度,配合单笔画字体系统写出卡拉OK式高亮——这正是视频里「火花拖着细线书写歌词」的母题来源。
毫秒级 JSON:渲染引擎的唯一输入
回到开头那句话:渲染引擎的输入只有两个 JSON。Engine.init()里AudioData.load()与Lyrics.load()并行加载data/audio.json与data/lyrics.json,然后makeTimeline()据此生成 22 个场景的时间窗口。从此渲染是歌曲时间的纯函数——同一时刻永远得到同一帧,浏览器实时预览与离线 4K 导出逐帧一致。
而这份「唯一输入」背后的音频流水线,本质上是把一首歌压缩成三个层次的确定性描述:宏观是 14 个段落与恒定 132.007 BPM 网格;中观是 277 个 kick、132 个 snare、254 个 hat 与 300+ 个 vocal onset 事件;微观是 100 fps 的 8 维包络与词级(部分音节级)歌词时间戳。所有层次的共同前提,是那条从 LAME 编码延迟修正开始、由互相关校准锁定的统一时间线。可以说,这支视频的画面精度,在动笔渲染之前就已经由音频分析决定了。
从 MP3 到画面的旅程,最重的功夫不在 three.js 场景里,而在analysis/目录这几个 Python 文件里:校准、拟合、识别、对齐。当节拍、鼓点、段落、歌词都以毫秒级精度落盘时,剩下的渲染只是确定性执行而已。
【免费下载链接】pdoom-videoCode-rendered music video for "I'm Upping My P(doom)"项目地址: https://gitcode.com/gh_mirrors/pd/pdoom-video
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考