跳到正文
4083 字
16 分钟
5
5
3

特效歌词如何随音乐律动

发布于
更新于
分类

特效歌词如何随音乐律动

使用信号和系统对音频模拟信号进行处理,阅读本文需要有一定信号与系统基础

前一阵子我给博客的播放器引入了一些花里胡哨的东西,比如AI特效歌词:顾名思义。就是让AI对歌词进行标注,比如这一个词语用蓝色,唱到这一个词语时要放大,抖动。AI可以根据歌曲背景和歌词内容,初步分析歌词情感表现,在合适的地方用合适的特效。

但是久而久之我体验下来,发现一个问题:这些特效在播放的时候都是孤立的,比如心跳和抖动特效,并没有和歌曲节奏『一起动』。显得很不协调。于是我想到了音频信号处理,也就是《信号与系统》和《自动控制原理》这门课程中学到的东西,这俩课程还是有一定难度,当年班上挂科不少,期初我认为没有什么用,现在感觉还是很有用。

歌词行上浮弹簧动画:二阶线性时不变系统(LTI)

歌词上浮动画

把一行歌词的上浮看成一个阶跃响应:输入 x(t)x(t) 是「该唱这一句了」,输出 y(t)y(t) 是这行字相对目标位置的位移,0 表示还没浮上来,1 表示刚好到位。设系统是二阶线性时不变的,那么它的微分方程是

d2ydt2+2ζωndydt+ωn2y=ωn2x(t)\frac{\mathrm{d}^2 y}{\mathrm{d}t^2} + 2\zeta\omega_n\frac{\mathrm{d}y}{\mathrm{d}t} + \omega_n^2 y = \omega_n^2 x(t)

在零初始条件下做拉普拉斯变换,得到传递函数

H(s)=Y(s)X(s)=ωn2s2+2ζωns+ωn2H(s) = \frac{Y(s)}{X(s)} = \frac{\omega_n^2}{s^2 + 2\zeta\omega_n s + \omega_n^2}

整个系统只剩两个参数:ωn\omega_n 是无阻尼自然频率,决定上浮有多快;ζ\zeta 是阻尼比,决定到位之后会不会回弹。当 ζ<1\zeta < 1 时是欠阻尼,阶跃响应为

y(t)=1−e−ζωnt1−ζ2sin⁡(ωn1−ζ2 t+arccos⁡ζ)y(t) = 1 - \frac{e^{-\zeta\omega_n t}}{\sqrt{1-\zeta^2}}\sin\left(\omega_n\sqrt{1-\zeta^2}\,t + \arccos\zeta\right)
二阶系统动画

它会先冲过目标位置,再以阻尼振荡频率 ωd=ωn1−ζ2\omega_d = \omega_n\sqrt{1-\zeta^2} 来回摆动,幅度按 e−ζωnte^{-\zeta\omega_n t} 衰减。冲过头多少由超调量 Mp=e−πζ/1−ζ2M_p = e^{-\pi\zeta/\sqrt{1-\zeta^2}} 决定,第一次冲到最高点的时间是 tp=π/ωdt_p = \pi/\omega_d。

ζ=1\zeta = 1 是临界阻尼,不超调而且最快到位;ζ>1\zeta > 1 是过阻尼,慢慢爬过去,看着发拖。想让歌词「弹」一下再落定,ζ\zeta 取 0.3 到 0.6 之间;只想稳稳浮上来,就取 1 附近。实际调的时候不用去改曲线,动这两个数就够了。

歌词节律动效:音频节奏信号和动效系统卷积

卷积是什么?

卷积是把两个函数卷成一个:一个当输入信号,一个当系统的冲激响应,输出在任意时刻的值,等于过去所有输入留下的痕迹叠加起来。

y(t)=∫−∞+∞x(τ) h(t−τ) dτy(t)=\int_{-\infty}^{+\infty}x(\tau)\,h(t-\tau)\,\mathrm{d}\tau

冲激响应 hh 可以理解成系统的记忆:给系统打一个 δ(t)\delta(t),它回一个 h(t)h(t),然后按自己的规律衰减,前一节的弹簧就是按 e−ζωnte^{-\zeta\omega_n t} 衰减的。输入信号能拆成一串不同时刻、不同强度的冲激,把每一下的响应叠在一起就是输出。连续形式是上面的积分,离散形式就是加权求和 y[n]=∑kx[k]h[n−k]y[n]=\sum_k x[k]h[n-k],代码里写成一次滑动加权的循环也不长。

放到动效上:某一个鼓点触发一次放大,hh 就是这次放大随时间回到平静的曲线,后面再接一拍就再叠一层,卷积出来的强度会随鼓点疏密自己变化。

三个信号:拍点、系统和输出

我们可以理解为最终输出呈现y(t)y(t)取决于节拍x(t)x(t)+特效描述h(t)h(t)

按上面的定义,动效这条链路上有三个信号,图 1 从上到下把它们画了出来:输入是拍点串 x(t)x(t)也就是音乐节拍脉冲,系统由冲激响应 h(t)h(t) 刻画用来描述特效,输出是强度 y(t)y(t),也就是最终呈现的效果。时间单位统一取毫秒。

上:拍点冲激串;中:心跳核;下:卷积输出
图 1 输入冲激串、心跳核与卷积输出

输入用加权冲激串表示:

x(t)=∑kak δ(t−tk)x(t) = \sum_k a_k\,\delta(t - t_k)

tkt_k 是第 kk 个拍的起始时刻,由节拍检测给出;aka_k 是这一拍的相对强弱。采用冲激建模的依据是时间尺度分离:起音的能量集中在几十毫秒内,而冲激响应的特征时间与它同量级,两者的差别不足以改变包络形状。

强弱不是一个 0 到 1 的主观评分,而是从响度算出来的:

ak=amin⁡+(1−amin⁡)⋅clamp01⁡ ⁣(Lk−L10L95−L10),amin⁡=0.55a_k = a_{\min} + (1 - a_{\min})\cdot\operatorname{clamp01}\!\left(\frac{L_k - L_{10}}{L_{95} - L_{10}}\right),\qquad a_{\min} = 0.55

LkL_k 是该拍的响度,L10L_{10}、L95L_{95} 是整首歌响度的 10% 与 95% 分位数。取分位数而非极值,是因为录音的峰值与静音段都不可靠:前者个别爆音就能改写标尺,后者会让动态范围被清音段压缩。amin⁡a_{\min} 设成 0.55 而不是 0,保证弱拍也有可见响应,强弱之比控制在一个数量级以内。

输出就是这两者的卷积:

y(t)=∑kak h(t−tk)y(t) = \sum_k a_k\,h(t - t_k)

连续卷积的积分退化成了有限求和,原因有两个:输入本身是离散冲激串,积分只在 tkt_k 处有贡献;hh 因果且在 t≫Tat \gg T_a 后按指数衰减,远端拍点的项可以忽略。取和的区间由窗口决定,具体的取法在后文「实现」一节,那里也给出了相应误差的量级。

叠加:强度由什么决定

图 2 把求和这件事拆开:虚线是每个拍单独的分量 akh(t−tk)a_k h(t - t_k),实线是它们的和。

每一拍的回响单独画出,再叠加
图 2 逐拍响应与叠加结果

卷积描述的是记忆而不是周期。系统的输出只由近端的若干事件与它们各自的衰减决定,因此拍点疏密变化时不需要重新标定任何参数:稀疏时每一拍都完整落回零附近,密集时前一下尚未衰减完就叠上新的分量,强度自然抬高。

这个抬高可以定量估计。设拍点等间隔 TT、幅度恒为 aa,在拍点时刻求和的稳态值为 a∑n≥0h(nT)a\sum_{n\ge 0} h(nT)。代入后文将导出的 h(t)=(t/Ta)e1−t/Tau(t)h(t) = (t/T_a)e^{1 - t/T_a}u(t),并令 q=e−T/Taq = e^{-T/T_a},则

∑n≥0h(nT)=TTa e ∑n≥0nqn=TTa e q(1−q)2\sum_{n\ge 0} h(nT) = \frac{T}{T_a}\,e\,\sum_{n\ge 0} n q^{n} = \frac{T}{T_a}\,e\,\frac{q}{(1-q)^2}

取 Ta=55T_a = 55 ms:T=500T = 500 ms(120 BPM)时该系数约 3×10−33\times 10^{-3},可见每拍基本独立,输出峰值约为 aka_k 本身;T=120T = 120 ms(500 BPM)时系数升到 0.850.85,叠加已经过半;T=60T = 60 ms 时系数超过 22,线性叠加会把强度推到 2a2a 以上。后文的饱和处理针对的正是这种情况。

作为对照,如果直接用 y(t)=sin⁡(2πt/T)y(t) = \sin(2\pi t / T) 产生节律,输出与音频无关:音乐停止或鼓点消失后它仍按原频率振荡。前面提到的「孤立特效」问题,根源在这里。

冲激响应的选取

最基本的核取临界阻尼二阶系统的冲激响应。其传递函数有两个重极点 s=−ωns = -\omega_n:

H(s)=ωn2(s+ωn)2H(s) = \frac{\omega_n^2}{(s+\omega_n)^2}

反变换得 h(t)=ωn2t e−ωntu(t)h(t) = \omega_n^2 t\,e^{-\omega_n t}u(t),峰值出现在 t=1/ωnt = 1/\omega_n,峰值为 ωn/e\omega_n/e。把峰值归一化到 1,并记 Ta=1/ωnT_a = 1/\omega_n,就得到代码里的形式:

g(t)=tTa e1−t/Ta u(t)g(t) = \frac{t}{T_a}\,e^{1 - t/T_a}\,u(t)

这样 TaT_a 有了明确的物理含义:它既是到达峰值的时间,也是衰减的特征时间。选取它而不是带超调的阻尼正弦,是因为动效需要单调上升、单调回落的包络,出现过冲会读作抖动而非节奏。心跳取 Ta=55T_a = 55 ms,放大重音取 7070 ms,两者只差在这一处。

心跳的「扑通-扑通」不是另起一拍,而是在同一个拍上串两个冲激:

h(t)=g(t)+β g(t−Td),β=0.65,Td=clamp⁡(0.3 Te, 140, 300) msh(t) = g(t) + \beta\,g(t - T_d),\qquad \beta = 0.65,\quad T_d = \operatorname{clamp}(0.3\,T_e,\ 140,\ 300)\ \text{ms}

TdT_d 与动效周期 TeT_e 成正比,是为了让两下的间隔随速度伸缩;上下限则保证快歌里两个峰不会合并、慢歌里间隔也不至于大到失去成对感。

柔光的核是另一类,cos⁡2(πt/Te)\cos^2(\pi t/T_e),峰值在 t=0t = 0,∣t∣<Te/2\lvert t\rvert < T_e/2 之外为零。它的峰落在拍点上,不需要等待上升时间,代价是非因果——t<0t<0 那半边需要知道尚未到达的拍。这在离线标注的场景里是允许的:整首歌的拍点在播放前已经是确定的数据,不需要实时因果性。

心跳、放大重音、抖动三种核的输出
图 3 同一输入下三种核的输出

把同一串拍点分别过上面几种核,输出的差别只在包络:心跳是成对的起落,放大重音是每拍一个尖峰,抖动被拍点触发后按指数滑落(图 3)。各动效的核与默认参数汇总如下,其中 TeT_e 为该动效的响应周期,TbT_b 为基准周期(750 ms):

动效

h(t)h(t)

默认参数

心跳 heartbeat

g(t)+βg(t−Td)g(t) + \beta g(t - T_d)

Ta=55T_a = 55 ms,β=0.65\beta = 0.65,TdT_d 见上

放大重音 swell

g(t)g(t)

Ta=70T_a = 70 ms,增益 0.6

柔光 glow

cos⁡2(πt/Te)\cos^2(\pi t / T_e)

峰值在 t=0t = 0,∣t∣<Te/2\lvert t\rvert < T_e/2

波浪 wave

sin⁡(πt/w)\sin(\pi t / w)

w=0.5Tew = 0.5T_e,0≤t<w0 \le t < w

抖动 shake

e−t/τe^{-t/\tau}

τ=120\tau = 120 ms,载波周期 Tb/16T_b/16

背景呼吸 background

min⁡(1,t/τa)e−t/τd\min(1, t/\tau_a)e^{-t/\tau_d}

τa=30\tau_a = 30 ms,τd=150\tau_d = 150 ms

多尺度的拍点串

动效并不都按拍点的原始密度工作。拍点串按二进方式组织成多级:0 级是补齐空档后的全部拍点,周期为检测到的拍长 T∗T^*;向上每级二分,取起音强度之和较大的那一半(通常是强拍);向下则在相邻两拍之间插入中点。各级严格嵌套,低级的拍点一定是高级的子集。

每种动效指定一个倍频程偏移 nen_e,取第 Kbase+neK_{\text{base}} + n_e 级:KbaseK_{\text{base}} 只负责把 0 级对齐到基准周期 TbT_b(750 ms),于是响应周期为 Te=2neTbT_e = 2^{n_e} T_b。柔光取 ne=1n_e = 1,两拍呼吸一次;抖动取 ne=−1n_e = -1,比拍点还密一倍;其余多为 ne=0n_e = 0。同一份拍点数据因此可以在不同时间尺度上复用,而不必为每种动效重跑一次节拍检测。

实现

引擎本身只是一次求和:

/** 卷积 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)))
}

计算量是 O(Nw)O(N_w),NwN_w 为窗口内的拍点数,实测在 1 到 10 之间;全曲拍点约 10310^3 量级,即便逐帧全量遍历也只有千次乘加。这里不用 FFT:信号长度以秒计,且每帧都要按绝对时间对齐相位,分块变换的边界效应与延迟开销都比直接求和更贵。

实现中有几处不属于公式、但影响观感的处理:

  • 窗口即加窗。 短语类动效取 [开唱 − 60 ms, 唱完] 作为求和区间,等价于给输入乘一个矩形窗。窗外更早的拍(其响应尾部仍会落在行内)被有意丢弃,因为每一行的动效应当从行首独立起算,而不是继承上一行的残余。这类截断是设计的一部分,不是误差。

  • 全曲类动效则要求足够长的回看。 背景呼吸与粒子强度没有「行」的概念,只能用滑动窗口 [t − 1000 ms, t]。此处截断才是真误差:g(1000 ms)g(1000\,\text{ms}) 在 Ta=55T_a = 55 ms 时约为 6×10−76\times10^{-7},完全可以忽略;放大重音取 8τ,对应残量 8e−7≈0.7%8e^{-7} \approx 0.7\%,是有意留出的余量。

  • 提前量。 求和时刻用 timeMs + leadMs,leadMs = 20 ms,视觉略早于声音。人耳对「画面滞后于声音」比对「画面略早」敏感得多,严格零延迟反而会被感知为滞后。

  • 饱和。 输出截到 1.2。由上一节的估计,密鼓点下线性叠加可以超过 2,继续按比例放大只会让画面过曝,截断反而保住了强弱关系。

  • 空窗兜底。 窗内一个拍都没有时(长音、拖腔、句尾),取窗口之后的第一拍,保证这一行至少动一次;否则输出恒为零,动效等于没有。

节拍可靠性

节拍检测的置信度取自相关函数在 T∗T^* 处的峰高与零延迟值的比值:

c=R(T∗)R(0),R(τ)=1N−τ∑ne[n] e[n+τ]c = \frac{R(T^*)}{R(0)},\qquad R(\tau) = \frac{1}{N - \tau}\sum_{n} e[n]\,e[n + \tau]

e[n]e[n] 是按帧(20 ms 一格)取的起音强度,RR 是它在滞后 τ\tau 上的自相关。cc 的含义很直接:把起音序列和它自己错开一个拍长,还能对上多少。稳定鼓点能对上六到八成,散板对不上,只剩百分之几。

图 4 把两组序列的完整计算过了一遍。τ\tau 从 0 扫到 1000 ms,虚线是错开 τ\tau 后的副本,它与实线重合成尖峰的位置就是候选拍长;下方是同一组数据的 R(τ)/R(0)R(\tau)/R(0),鼓点那栏在 T∗=498T^* = 498 ms 处有峰,散板那栏整段都贴着零。

图 4 自相关与置信度:两组起音序列的完整计算过程

低于 0.12 的阈值时判定为不可信——自由节奏、朗诵、纯钢琴独奏经常如此——系统退回固定档位:慢 1000 ms、中 750 ms、快 520 ms 一拍。此时输入变成等间隔冲激串,仍然走同一套卷积,只是不再与音频同步。

前奏、间奏这类没有拍可跟的区间,按最后测得的 T∗T^* 外推补拍,保持相位连续。补拍的强弱取 0.8 amin⁡0.8\,a_{\min},比真实弱拍更弱,避免静音段出现与正歌等量的动作。

特效歌词如何随音乐律动
发布于
2026/11/07
许可协议
CC BY-NC-SA 4.0

除非另有说明,本站文章许可协议都是 CC BY-NC-SA 4.0,转载请注明出处。