特效歌词如何随音乐律动
拿 信号与系统 里的方法处理音频模拟信号,全文读懂需要有一定基础,但是0基础也可以看一下白话部分,读完了对信号与系统很感兴趣的,可以去看看奥本海姆的《信号与系统》,这是我觉得非常好的教程。
前一阵子我给博客的播放器塞了些花里胡哨的东西,其中之一是 AI 特效歌词:AI 读歌词,也看歌曲背景,按情感走向给每个词分特效——这一个词用蓝色,唱到它的时候放大、抖一下。
用久了发现一个问题:这些特效各自为政。心跳是心跳,抖动是抖动,都跟歌曲节奏没关系,看着很不协调甚至很鬼畜。于是我在想,能不能从音频信号处理入手,把《信号与系统》和《自动控制原理》学的东西用起来,这两门课难度还是不低,当年挂了不少人。
歌词行上浮弹簧动画:二阶线性时不变系统(LTI)
这东西你可以理解为弹簧模拟器,就是模拟弹簧物理性质的系统,我们的歌词就是一句一句弹出来的
把一行歌词的上浮看成一个阶跃响应:输入 是「该唱这一句了」,输出 是这行字相对目标位置的位移,0 表示还没浮上来,1 表示刚好到位。设系统是二阶线性时不变的,那么它的微分方程是
上面这个二阶系统在零初始条件下做拉普拉斯变换,得到传递函数(我们上课一般叫传函),微分方程算起来很麻烦,变换后直接用下面这坨做代数运算即可,算完再反变换回去就好了
回看这个,整个系统只剩两个参数: 是无阻尼自然频率,管上浮有多快; 是阻尼比,管到位之后回不回弹。当 时是欠阻尼,阶跃响应为
它会先冲过目标位置,再以阻尼振荡频率 来回摆动,幅度按 衰减。冲过头多少由超调量 决定,第一次冲到最高点的时间是 。
是临界阻尼,不超调而且最快到位; 是过阻尼,慢慢爬过去,看着发拖。想让歌词「弹」一下再落定, 取 0.3 到 0.6 之间;只想稳稳浮上来,就取 1 附近。实际调的时候不用去改曲线,动这两个数就够了。
番外:Apple在自家的iOS和macOS上大量使用二阶系统(阻尼谐振子)动画,来模拟真实世界的物理动效,这就是为什么iOS的系统给人一种真实的Q弹的感觉,这东西从iOS 7开始就有了。
歌词节律动效:音频节奏信号和动效系统卷积
卷积:把过去叠到当前
这个不是深度学习里面那个CNN卷积,但是两者有数学亲缘性。
卷积是把两个函数卷成一个:一个当输入信号,一个当系统的冲激响应,输出在任意时刻的值,等于过去所有输入留下的痕迹叠加起来。
冲激响应 可以理解成系统的记忆:给系统打一个 ,它回一个 ,然后按自己的规律衰减,前一节的弹簧就是按 衰减的。输入信号能拆成一串不同时刻、不同强度的冲激,把每一下的响应叠在一起就是输出。连续形式是上面的积分,离散形式就是加权求和 ,代码里写成一次滑动加权的循环也不长。
放到动效上:某一个鼓点触发一次放大, 就是这次放大随时间回到平静的曲线,后面再接一拍就再叠一层,卷积出来的强度会随鼓点疏密自己变化。
唉,说这么大一堆感觉也不能给阁下讲懂,其实放到复频域层面来看,卷积就是把两个系统代数相乘:
拿两个信号 、 举例,都是因果的,卷起来写成
两边做拉普拉斯变换:
交换积分次序,令 , 从 走到 就是 从 走到 :
最终可得
另外就是这俩乘出来的东西,拉氏反变换回去就是,很神奇吧。
当然这东西有个前提,前提是 、 的拉氏变换都得存在:各自要能找到使 收敛的 ,而且两边的收敛域得有交集。
其实有些东西,你不要看他很复杂,换一个角度来看就会发现非常简单,这简直是信号与系统的灵魂。
节拍特效是怎么算出来的:拍点、系统和输出
上面说到了卷积,其实画面强度 就是节拍串 和特效系统 卷积的结果。人话表述就是,你最终看到的有节奏的动效,其实是依据节拍和特效样式来的。
图 1 顺着这条链路从上往下画了三个信号:最上面是检测出来的拍点串 ,中间是心跳的核 ,最下面是卷出来的强度 。时间统一按毫秒算。
输入是一串加权冲激:
是第 个拍的起始时刻,由节拍检测给出; 是这一拍的相对强弱。把一拍写成冲激,依据是时间尺度分离:起音能量集中在几十毫秒内,跟冲激响应的特征时间同量级,这点差别改不了包络的形状。
每一拍的强弱直接从响度算,不打 0 到 1 的主观分:
是该拍的响度,、 是整首歌响度的 10% 与 95% 分位数。不用极值,是因为振幅的峰值和静音段都不可靠:个别爆音就能改写标尺,清音段又会把动态范围压扁。 取 0.55 而没取 0,为的是弱拍也有看得见的响应,代价是最强拍和最弱拍只差 倍。
输出就是这两者的卷积:
积分退化成有限求和:输入是离散冲激串,只在 处有贡献; 因果, 之后按指数衰减,远处拍点的项可以扔掉。求和区间由窗口截出来,怎么截、误差多大,后面「实现」里再说。写成代码就是一条 FIR 滤波, 当系数,拍点当输入,逐拍乘加。
叠加:强度由什么决定
图 2 把求和这件事拆开:虚线是每个拍单独的分量 ,实线是它们的和。
卷积描述的其实是记忆,跟周期没关系。输出只由近处几个事件和它们各自的衰减决定,所以拍点疏密变了也不用重新标定参数:稀疏时每一拍都完整落回零附近,密集时前一下尚未衰减完就叠上新的分量,强度自然抬高。
抬高多少能估出来。设拍点等间隔 、幅度恒为 ,拍点时刻的稳态和是 。代入下面要导出的 ,令 ,得
取 ms: ms(120 BPM)时该系数约 ,每拍基本独立,输出峰值约为 本身; ms(500 BPM)时系数升到 ,叠加已经过半; ms 时系数超过 ,线性叠加会把强度推到 以上。后面把输出截到 1.2,针对的就是这种情况。
反过来,直接拿 当节律,输出就跟音频脱钩了:音乐停下、鼓点消失,它照旧按原频率振荡,跟音乐各走各的。
冲激响应的选取
最基本的核取临界阻尼二阶系统的冲激响应。传递函数有两个重极点 ,忘了重极点是什么的自己去翻下高数吧:
反变换得 ,峰值出现在 ,大小是 。把峰值归一化到 1,并记 ,就得到代码里的形式:
于是有了明确的物理含义:既是到达峰值的时间,也是衰减的特征时间。没选带超调的阻尼正弦,是因为动效要单调上升、单调回落的包络,出现过冲会读成抖动,不像节奏。心跳取 ms,放大重音取 ms,差别只在这一处。
心跳的「扑通-扑通」在同一个拍上串两个冲激,不另起一拍:
与动效周期 成正比,是为了让两下的间隔随速度伸缩;上下限则保证快歌里两个峰不会合并、慢歌里间隔也不至于大到失去成对感。
柔光换了另一类核,,峰值在 , 之外为零。峰落在拍点上,不用等上升时间,代价是非因果: 那半边要用到还没到的拍。
把同一串拍点分别过上面几种核,输出的差别只在包络线:心跳是成对的起落,放大重音是每拍一个尖峰,抖动被拍点触发后按指数滑落(图 3)。各动效的核与默认参数如下, 是响应周期, 是基准周期,下面这个表格就是我们提前预设好的特效系统。
动效 | 默认参数 | |
|---|---|---|
心跳 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,再按比例放大只会让画面过曝,截断反而保住了强弱关系。
空窗。窗内一个拍都没有时(长音、拖腔、句尾),取窗口之后的第一拍,保证这一行至少动一次;否则输出恒为零,动效等于没有。
节拍可靠性
人话:不知道你们听歌的时候打拍子有没有发现,一拍打一下,两拍打一下也都跟得上节奏。我们要引入一个概念叫bpm,即时beats per minute(每分钟节拍数),这个也表示每分钟心跳次数。假设一首歌的bpm是N,那我们按照N/2的速度打拍子,也能精准卡在节拍上。想一想初中学的周期函数就懂了,的周期可以是,也可以是、等满足条件的。我们把歌曲抽象成一个一堆周期函数叠起来的东西,找到一个近似的最小周期,音频本身不是周期函数,假设有一个至始至终bpm不变的歌曲,我们把它的节拍提取出来做成离散函数,这个才是周期函数。但是由于一首歌本身噪音(人声,鼓点,乐器等)很多,这个难度非常高,假设一首歌的BPM是N,我们这个算法目前可以做到算出N/2、N、2N等结果,每个结果置信度不同罢了,目前还不算很完美,真正的完美就是让人来听。
节拍检测的置信度,就是自相关函数在 处的峰高,除以零延迟处的值:
是按帧取的起音强度(1024 点窗、128 点步长,11025 Hz 下约 11.6 ms 一格), 是它的自相关。 的含义很直接:把起音序列和自己错开一个拍长,还能对上多少。稳定鼓点能对上六到八成,散板只剩百分之几。
图 4 把两组序列的完整计算走了一遍。自相关算到 4 秒的滞后(下一节要用到 处),图里只画 0 ~ 1000 ms 这一段。虚线是错开 后的副本,跟实线叠成尖峰的地方就是候选拍长;下方是同一组数据的 ,鼓点那栏在 ms 处有峰,散板那栏整段贴着零。
低于 0.12 就当不可信(自由节奏、朗诵、纯钢琴独奏常见),系统退回固定档位,一拍取慢 1000 ms、中 750 ms、快 520 ms。输入改成等间隔冲激串,卷积还是那一套,只是不再跟音频对得上。
前奏、间奏这种没拍可跟的段落,按最后测得的 外推补拍,相位接着走。补拍强弱取 ,比真弱拍还弱,静音段才不至于跟正歌一样热闹。
拍速的八度歧义
自相关对拍长是倍数不变的。真实拍长 处有峰,、、 处也有,而且 比 高的情况很常见:底鼓每两拍一下,军鼓落在 2、4 拍,镲走八分音符,都是让两倍周期看起来更稳的因素。之前只比 的绝对值,再乘一个对数高斯先验说是往 120 BPM 收,其实那个先验的标准差就是 1 个八度,差一倍还剩 ,等于没写。面板上的 BPM 就在 和 之间来回摆。
判据换成只数整数倍谐波的梳状和:
音高检测里的谐波求和是同一个套路:把基频整数倍上的证据加起来再挑峰,这里只是把频率换成了时间长度。
真拍长把权重花在 、、、 四个整数拍上,四项都高。候选 的四项是 、、、:前两项还高,后面两项跑到几秒之外,自相关只算到 4 秒,更远的按零计。何况演奏里一点速度起伏,在这么长的滞后上就积累成失相。候选 更吃亏,四项里有一半压在拍与拍之间最安静的位置。哪个候选被选中,取决于它把这四个权重花在了哪里。只看单个滞后,三个候选可能都很高,分不出来。
权重取 而不是等权,是因为高次谐波的峰本来就矮一截,等权等于把最弱的证据当成同等置信。
于是拍长取
其中 是八度族 在 秒内的峰值位置。先验的标准差改成 0.7 个八度:差一倍衰减到 ,足够强的峰还能压过去,但白送的一倍不再接受。再往下收就不好办了,0.5 秒是大多数人跟着数拍最舒服的位置,60 BPM 的歌先天就落在一个八度之外,收到 0.4 只剩 ,等于要求所有歌都在 120 BPM 上下。放宽回 1 个八度就是改之前的样子。
追踪阶段还有两处跟打分无关,但会表现成面板上一个数、实际按另一个数在跳。
一处是步长:动态规划原来允许两拍间隔落在 ,序列能在歌曲中途漂到两倍或一半的网格上,前半段还跟得上,后半段就对不齐。收到 之后,漂移只能在局部发生。代价是对速度变化很大的现场录音迟钝一些,这类素材本来也会落进低置信度的回退分支。
另一处是静音门槛:低于「响度 5% 分位 + 6 dB」的拍点会被当成静音段丢掉,交给飞轮补拍。母带压得很狠、整首只有两三 dB 动态时,这个门槛比所有拍点都高,一个都留不下,动效全由外推的等间隔脉冲驱动,看着在动,跟音乐没关系。现在改成跟中位数减 3 dB 取小,动态够的曲子不受影响。
我让AI跑了一组合成的鼓点:三种鼓型(常规流行、half-time、只有镲和军鼓),13 个 BPM 从 62 到 180,共 39 例。完全正确的从 21 例(54%)升到 27 例(69%),88 到 160 这段全对。剩下的偏差都在两端,62、74 判成两倍,172、180 判成一半。这两段本来就是数拍还是数半拍的取舍,这东西我认为没有标准答案,比如下面这首歌
我正常打拍子算出来的bpm是170,官方给的信息也是170,但是算法判出来是85(除以了2),不过我们按照85的bpm来上特效,会发现特效看起来更舒服。170反而抖动太快了看着不太自然,所以我说没有标准答案,85是人体感觉舒服,170是数据正确。
你们可以边听边自己打拍子(手打,点头都可以)。2.83 Hz 已经超过舒服打拍子的上限,是不是都有点跟不上来了;85 BPM 是 1.42 Hz,落在偏好区间中间。half-time feel 的歌重音本来就落在半速网格上,底鼓每两拍一下,军鼓在 2、4 拍,镲走八分只是细分。耳朵抓重音层,自相关峰抓到的也是这一层。
动效的时间常数摆在那:心跳 ms,第二下「通」还要再等 140 ms 以上,一个完整的扑通-扑通接近 200 ms;抖动 ms;背景呼吸起落加起来 180 ms。拍长 706 ms 时这些动作占掉一拍的三成,剩下的时间够它落回零;拍长 353 ms 时占到五到七成,上一拍的尾巴还没落干净,下一拍就压上来,卷积出来是一段接不上的起伏,看不出拍子。固定档那张表也是这个尺度,慢 1000 ms、中 750 ms、快 520 ms,最快的档都比 170 BPM 的 353 ms 慢。整套参数当初就是按 0.5 ~ 1 秒的拍长调的,拍速估成一半,动效正好停在自己的舒适区里。
我让AI在播放器的调试面板里加了个基准拍速的手动档(×¼ / ×½ / ×1 / ×2 / ×4),可以切换一下节拍档数看看歌词特效,这首歌的 ×½ 就是 170 BPM。
算法即便正确,有些东西也需要用耳朵去听,用心去体会,而不是依靠算法去算
最后
本人不是专门搞通信领域的信号处理的,就是个搞编程的半吊子,不过借助AI还是能学到不少跨领域的东西的。