特效歌词如何随音乐律动
使用信号和系统对音频模拟信号进行处理,阅读本文需要有一定信号与系统基础
前一阵子我给博客的播放器引入了一些花里胡哨的东西,比如AI特效歌词:顾名思义。就是让AI对歌词进行标注,比如这一个词语用蓝色,唱到这一个词语时要放大,抖动。AI可以根据歌曲背景和歌词内容,初步分析歌词情感表现,在合适的地方用合适的特效。
但是久而久之我体验下来,发现一个问题:这些特效在播放的时候都是孤立的,比如心跳和抖动特效,并没有和歌曲节奏『一起动』。显得很不协调。于是我想到了音频信号处理,也就是《信号与系统》和《自动控制原理》这门课程中学到的东西,这俩课程还是有一定难度,当年班上挂科不少,期初我认为没有什么用,现在感觉还是很有用。
歌词行上浮弹簧动画:二阶线性时不变系统(LTI)
把一行歌词的上浮看成一个阶跃响应:输入 是「该唱这一句了」,输出 是这行字相对目标位置的位移,0 表示还没浮上来,1 表示刚好到位。设系统是二阶线性时不变的,那么它的微分方程是
在零初始条件下做拉普拉斯变换,得到传递函数
整个系统只剩两个参数: 是无阻尼自然频率,决定上浮有多快; 是阻尼比,决定到位之后会不会回弹。当 时是欠阻尼,阶跃响应为
它会先冲过目标位置,再以阻尼振荡频率 来回摆动,幅度按 衰减。冲过头多少由超调量 决定,第一次冲到最高点的时间是 。
是临界阻尼,不超调而且最快到位; 是过阻尼,慢慢爬过去,看着发拖。想让歌词「弹」一下再落定, 取 0.3 到 0.6 之间;只想稳稳浮上来,就取 1 附近。实际调的时候不用去改曲线,动这两个数就够了。
歌词节律动效:音频节奏信号和动效系统卷积
卷积是什么?
卷积是把两个函数卷成一个:一个当输入信号,一个当系统的冲激响应,输出在任意时刻的值,等于过去所有输入留下的痕迹叠加起来。
冲激响应 可以理解成系统的记忆:给系统打一个 ,它回一个 ,然后按自己的规律衰减,前一节的弹簧就是按 衰减的。输入信号能拆成一串不同时刻、不同强度的冲激,把每一下的响应叠在一起就是输出。连续形式是上面的积分,离散形式就是加权求和 ,代码里写成一次滑动加权的循环也不长。
放到动效上:某一个鼓点触发一次放大, 就是这次放大随时间回到平静的曲线,后面再接一拍就再叠一层,卷积出来的强度会随鼓点疏密自己变化。
三个信号:拍点、系统和输出
我们可以理解为最终输出呈现取决于节拍+特效描述
按上面的定义,动效这条链路上有三个信号,图 1 从上到下把它们画了出来:输入是拍点串 也就是音乐节拍脉冲,系统由冲激响应 刻画用来描述特效,输出是强度 ,也就是最终呈现的效果。时间单位统一取毫秒。
输入用加权冲激串表示:
是第 个拍的起始时刻,由节拍检测给出; 是这一拍的相对强弱。采用冲激建模的依据是时间尺度分离:起音的能量集中在几十毫秒内,而冲激响应的特征时间与它同量级,两者的差别不足以改变包络形状。
强弱不是一个 0 到 1 的主观评分,而是从响度算出来的:
是该拍的响度,、 是整首歌响度的 10% 与 95% 分位数。取分位数而非极值,是因为录音的峰值与静音段都不可靠:前者个别爆音就能改写标尺,后者会让动态范围被清音段压缩。 设成 0.55 而不是 0,保证弱拍也有可见响应,强弱之比控制在一个数量级以内。
输出就是这两者的卷积:
连续卷积的积分退化成了有限求和,原因有两个:输入本身是离散冲激串,积分只在 处有贡献; 因果且在 后按指数衰减,远端拍点的项可以忽略。取和的区间由窗口决定,具体的取法在后文「实现」一节,那里也给出了相应误差的量级。
叠加:强度由什么决定
图 2 把求和这件事拆开:虚线是每个拍单独的分量 ,实线是它们的和。
卷积描述的是记忆而不是周期。系统的输出只由近端的若干事件与它们各自的衰减决定,因此拍点疏密变化时不需要重新标定任何参数:稀疏时每一拍都完整落回零附近,密集时前一下尚未衰减完就叠上新的分量,强度自然抬高。
这个抬高可以定量估计。设拍点等间隔 、幅度恒为 ,在拍点时刻求和的稳态值为 。代入后文将导出的 ,并令 ,则
取 ms: ms(120 BPM)时该系数约 ,可见每拍基本独立,输出峰值约为 本身; ms(500 BPM)时系数升到 ,叠加已经过半; ms 时系数超过 ,线性叠加会把强度推到 以上。后文的饱和处理针对的正是这种情况。
作为对照,如果直接用 产生节律,输出与音频无关:音乐停止或鼓点消失后它仍按原频率振荡。前面提到的「孤立特效」问题,根源在这里。
冲激响应的选取
最基本的核取临界阻尼二阶系统的冲激响应。其传递函数有两个重极点 :
反变换得 ,峰值出现在 ,峰值为 。把峰值归一化到 1,并记 ,就得到代码里的形式:
这样 有了明确的物理含义:它既是到达峰值的时间,也是衰减的特征时间。选取它而不是带超调的阻尼正弦,是因为动效需要单调上升、单调回落的包络,出现过冲会读作抖动而非节奏。心跳取 ms,放大重音取 ms,两者只差在这一处。
心跳的「扑通-扑通」不是另起一拍,而是在同一个拍上串两个冲激:
与动效周期 成正比,是为了让两下的间隔随速度伸缩;上下限则保证快歌里两个峰不会合并、慢歌里间隔也不至于大到失去成对感。
柔光的核是另一类,,峰值在 , 之外为零。它的峰落在拍点上,不需要等待上升时间,代价是非因果—— 那半边需要知道尚未到达的拍。这在离线标注的场景里是允许的:整首歌的拍点在播放前已经是确定的数据,不需要实时因果性。
把同一串拍点分别过上面几种核,输出的差别只在包络:心跳是成对的起落,放大重音是每拍一个尖峰,抖动被拍点触发后按指数滑落(图 3)。各动效的核与默认参数汇总如下,其中 为该动效的响应周期, 为基准周期(750 ms):
动效 | 默认参数 | |
|---|---|---|
心跳 heartbeat | ms,, 见上 | |
放大重音 swell | ms,增益 0.6 | |
柔光 glow | 峰值在 , | |
波浪 wave | , | |
抖动 shake | ms,载波周期 | |
背景呼吸 background | ms, ms |
多尺度的拍点串
动效并不都按拍点的原始密度工作。拍点串按二进方式组织成多级:0 级是补齐空档后的全部拍点,周期为检测到的拍长 ;向上每级二分,取起音强度之和较大的那一半(通常是强拍);向下则在相邻两拍之间插入中点。各级严格嵌套,低级的拍点一定是高级的子集。
每种动效指定一个倍频程偏移 ,取第 级: 只负责把 0 级对齐到基准周期 (750 ms),于是响应周期为 。柔光取 ,两拍呼吸一次;抖动取 ,比拍点还密一倍;其余多为 。同一份拍点数据因此可以在不同时间尺度上复用,而不必为每种动效重跑一次节拍检测。
实现
引擎本身只是一次求和:
/** 卷积 y(t) = Σ a_k·h(t − t_k) */
export function convolve(pulses: RhythmPulse[], t: number, h: (dt: number) => number) {
let y = 0
for (const pulse of pulses)
y += pulse.a * h(t - pulse.t)
return y
}
调用方决定求和区间与核,例如心跳:
/** 心跳强度 y(0 ~ 1.2):短语 [开唱 − 60ms, 唱完] 内的拍经过心跳系统 */
export function heartbeatLevel(ctx: RhythmContext, start: number, end: number, timeMs: number) {
const p = ctx.params.heartbeat
const Te = periodOf(ctx, p.octave)
const list = windowPulses(ctx, p.octave, start, start - 60, end)
return Math.min(1.2, convolve(list, timeMs + ctx.params.leadMs, dt => kernels.heartbeat(dt, Te, p)))
}
计算量是 , 为窗口内的拍点数,实测在 1 到 10 之间;全曲拍点约 量级,即便逐帧全量遍历也只有千次乘加。这里不用 FFT:信号长度以秒计,且每帧都要按绝对时间对齐相位,分块变换的边界效应与延迟开销都比直接求和更贵。
实现中有几处不属于公式、但影响观感的处理:
窗口即加窗。 短语类动效取
[开唱 − 60 ms, 唱完]作为求和区间,等价于给输入乘一个矩形窗。窗外更早的拍(其响应尾部仍会落在行内)被有意丢弃,因为每一行的动效应当从行首独立起算,而不是继承上一行的残余。这类截断是设计的一部分,不是误差。全曲类动效则要求足够长的回看。 背景呼吸与粒子强度没有「行」的概念,只能用滑动窗口
[t − 1000 ms, t]。此处截断才是真误差: 在 ms 时约为 ,完全可以忽略;放大重音取8τ,对应残量 ,是有意留出的余量。提前量。 求和时刻用
timeMs + leadMs,leadMs = 20ms,视觉略早于声音。人耳对「画面滞后于声音」比对「画面略早」敏感得多,严格零延迟反而会被感知为滞后。饱和。 输出截到 1.2。由上一节的估计,密鼓点下线性叠加可以超过 2,继续按比例放大只会让画面过曝,截断反而保住了强弱关系。
空窗兜底。 窗内一个拍都没有时(长音、拖腔、句尾),取窗口之后的第一拍,保证这一行至少动一次;否则输出恒为零,动效等于没有。
节拍可靠性
节拍检测的置信度取自相关函数在 处的峰高与零延迟值的比值:
是按帧(20 ms 一格)取的起音强度, 是它在滞后 上的自相关。 的含义很直接:把起音序列和它自己错开一个拍长,还能对上多少。稳定鼓点能对上六到八成,散板对不上,只剩百分之几。
图 4 把两组序列的完整计算过了一遍。 从 0 扫到 1000 ms,虚线是错开 后的副本,它与实线重合成尖峰的位置就是候选拍长;下方是同一组数据的 ,鼓点那栏在 ms 处有峰,散板那栏整段都贴着零。
低于 0.12 的阈值时判定为不可信——自由节奏、朗诵、纯钢琴独奏经常如此——系统退回固定档位:慢 1000 ms、中 750 ms、快 520 ms 一拍。此时输入变成等间隔冲激串,仍然走同一套卷积,只是不再与音频同步。
前奏、间奏这类没有拍可跟的区间,按最后测得的 外推补拍,保持相位连续。补拍的强弱取 ,比真实弱拍更弱,避免静音段出现与正歌等量的动作。