用代码生成宣传片(三):numpy 离线合成空灵 BGM
BGM 离线合成(numpy)
第三关是配乐。本项目用 numpy 现场算一段 45 秒空灵 BGM,零版权风险、风格完全可控。
为什么不用素材库
素材库有三个问题:
- 版权模糊:免费素材的授权条款各不相同,商用可能踩坑。
- 风格匹配难:要找一段 45 秒、空灵、不抢人声、能切到任意时长的 BGM,几乎不可能。
- 批量改不了:要调音量、改混响、换调式,素材是固定录音,没法改。
代码生成则完全反着来:参数调一调,重新跑一遍就有新版本。
设计原则(v4「苹果发布会空灵风」)
经过几轮迭代,v4 版确立了五条原则,每一条都对应一个具体的 numpy 实现:
- 零失谐(zero detune) — 所有正弦波精确对准十二平均律频率,不做任何 detune。这是消除拍频(beating)的根本手段,也是「听久了不头痛」的第一保证。
- 平滑低通(~4.2kHz) — 保留空气感光泽,但 4.2kHz 以上平滑滚降,避免高频刺耳。用 FFT 零相位低通实现。
- 噪声脉冲卷积混响 — 替掉等间隔延迟的梳状滤波(comb filtering)。用衰减白噪声作为脉冲响应(IR),干声做快速卷积,尾音自然不嗡嗡。
- 五声音阶(C-D-E-G-A)音乐盒铃音 — 纯正弦 + 指数衰减,音符之间永不冲突。干净、空灵、像八音盒。
- 极轻高八度「空气 pad」+ 温暖 sub 低频 + 缓慢呼吸 LFO — pad 用基频 + 0.35 倍低八度正弦叠厚度;sub 用 55–65Hz 正弦铺底;LFO 以 ~0.08Hz(12.5 秒周期)缓慢调制总音量,模拟呼吸感。
关键声学坑(头痛三大根因)
如果你听过某些「AI 生成的 BGM」觉得听一会儿就烦躁、头痛,大概率是踩了下面三个坑:
| 根因 | 现象 | 解法 |
|---|---|---|
| 失谐叠加(detune) | 持续拍频,振幅周期性涨落 | 零失谐,纯正弦精确对准 |
| 等间隔延迟混响 | 梳状滤波,金属嗡嗡声 | 卷积混响,噪声脉冲 IR |
| 无低通 + 谐波堆积 | 高频刺耳,听觉疲劳 | FFT 低通,~4.2kHz 平滑滚降 |
flowchart TD
D["失谐叠加"] --> B["持续拍频"]
B --> Bfix["零失谐<br/>纯正弦"]
E["等间隔延迟"] --> C["梳状滤波<br/>金属嗡声"]
C --> Cfix["卷积混响<br/>噪声 IR"]
N["无低通"] --> H["高频刺耳<br/>谐波堆积"]
H --> Hfix["FFT 低通<br/>~4.2kHz"]上面这张图总结了三大根因各自的现象和对应的 numpy 解法。每个根因→现象→解法是一条独立链路,三条链路之间互不耦合。
上面这张图总结了三大根因各自的现象和对应的 numpy 解法。每个根因→现象→解法是一条独立链路,三条链路之间互不耦合。
gen_bgm.py 关键片段:
| |
输出:wave.open("bgm.wav","wb") 写 44.1k 立体声 WAV。
flowchart TD
Add["加性合成<br/>正弦波"] --> Env["包络<br/>余弦/指数"]
Env --> LP["FFT 零相位<br/>低通 4.2kHz"]
LP --> Rvb["卷积混响<br/>噪声脉冲 IR"]
Rvb --> WAV["量化写出<br/>16bit WAV"]从加性合成到写出 WAV 的完整流水线:每个阶段只做一件事,前一级的输出是后一级的输入。
理解了这套流程的实现,接下来深入每一项背后的声学原理。
numpy 原理:数组是怎么变成音乐的
前面讲「怎么用」,这里讲「为什么能工作」——理解之后的每一行 numpy 代码你都能自己改。
数字音频第一性原理
声音是空气压强随时间的振动。数字音频把它采样——每秒取 44100 个振幅值(44.1kHz,CD 标准),每个值是一个 -1~1 的浮点数。
一段 45 秒音频 = 一个长度 45 × 44100 ≈ 198 万 的数组。numpy 数组,就是这段波形本身。
为什么是 44.1kHz? 奈奎斯特采样定理说:要无失真还原最高频率为 f 的信号,采样率必须 ≥ 2f。人耳听觉上限约 20kHz,所以 44.1kHz(> 2 × 20kHz)成为 CD 标准。更高的 48kHz/96kHz 用于影视/专业音频,但对人耳听感提升已非常有限。
加性合成与音高-频率转换
加性合成(Additive Synthesis): 傅里叶说任何周期波都能拆成正弦波之和。反过来,用 np.sin(2π·f·t) 直接造纯正弦,叠几条就合成复杂音色。
十二平均律(Equal Temperament): 半音之间是 2^(1/12) 倍频关系。A4 = 440Hz,那么距离 A4 为 n 个半音的频率是:
$$f = 440 \times 2^{\frac{n}{12}}$$代码里 hz(semi) 就是这个公式。C4 距 A4 是 -9 个半音,所以 Hz(C4) = 440 × 2^(-9/12) ≈ 261.63Hz。
本项目 pad 用基频正弦 + 0.35 倍低八度正弦叠厚度;bell 用基频 + 0.18 倍二次谐波模拟玻璃质感。
包络:音量窗口
包络(Envelope) 是音量随时间变化的形状,用逐样本乘法应用到波形上。
余弦斜坡(cosine ramp): attack 和 release 用 0.5 - 0.5·cos(π·t/T) 做平滑过渡,避免瞬间开关产生「咔哒」爆音(click)。
指数衰减(exponential decay): exp(-t/τ) 用于音乐盒铃音,余音渐弱自然。
| |
FFT 零相位低通
时域的滤波(卷积)在频域变成逐频率相乘,计算量从 O(N²) 降到 O(N log N)。
| |
流程:rfft(波形) → 乘频域响应曲线 → irfft 恢复时域。
零相位(Zero-Phase): rfft → 相乘 → irfft 这种「正向 FFT → 频域处理 → 逆 FFT」的流程,不做任何相位偏移操作。相比之下,时域 IIR 滤波器(如 Butterworth)会引入频率相关的相位延迟——不同频率经过滤波器后延迟量不同,导致波形群延迟失真。零相位 FFT 滤波完全避免了这个问题,听感更干净、瞬态保留更好。
为什么本项目 cutoff 选 ~4.2kHz?因为人声主要能量在 300Hz–3.4kHz,4.2kHz 以上保留的是空气感(齿音的高频光泽),但不让尖锐谐波(5–8kHz 的堆积)刺激耳朵。
拍频(Beating)的数学
拍频是「听久了头痛」的第一元凶。它的数学很简洁:
两个频率接近的正弦波叠加:
$$ \sin(2\pi f_1 t) + \sin(2\pi f_2 t) $$用和差化积公式:
$$ = 2 \cos\left(2\pi \cdot \frac{f_1 - f_2}{2} \cdot t\right) \cdot \sin\left(2\pi \cdot \frac{f_1 + f_2}{2} \cdot t\right) $$关键: 和波 = 一个高频载波 sin(平均频率) × 一个低频包络 cos(差频一半)。
差频 Δf = |f₁ - f₂| 决定了包络的涨落速度。包络每半个周期过零一次(振幅归零),所以振幅的周期性涨落频率 = Δf。
举例:440Hz 与 441Hz 叠加 → Δf = 1Hz → 音量每秒涨落 1 次。这就是「拍频」。
多层乐器各做少量 detune(比如 ±2 cent)时,每对音高之间都有微小的差频,这些拍频叠加就变成了持续的、不规则的振幅抖动——大脑处理起来非常疲劳,这就是「听久了头痛」的声学根因。
上图 f₁ 和 f₂ 只差几赫兹,和波的振幅按差频周期性涨落——这就是拍频。本项目的 BGM 把所有正弦波精确对准十二平均律频率,不做任何 detune,从源头上消除了拍频。用纯正弦(无谐波)也避免了泛音列之间的交叉拍频。
梳状滤波:频域等距零点
梳状滤波(Comb Filtering) 是另一种常见的听感污染。
当信号与自身的延迟版本叠加时(比如用等间隔延迟做「假混响」):
$$ y(t) = x(t) + \alpha \cdot x(t - \tau) $$在频域,这会在频率 f = k / (2τ)(k 为奇数)处产生零点(amplitude = 0),在 f = k / τ 处产生峰值。零点间隔 = 1/τ,在频谱图上看起来像一把梳子。
这种「等间距的陷波」给声音带来金属感、空洞感、嗡嗡声——尤其是延迟时间短(τ 小)时,零点间隔宽,低频就有明显的凹陷,声音发闷。
本项目解法: 用卷积混响替代等间隔延迟。卷积混响没有周期性结构,频谱响应平滑,尾音自然。
卷积混响
真实房间的混响 =「干声」与脉冲响应(Impulse Response, IR) 的卷积。IR 是在该房间拍一下手后声音的衰减过程。
$$ \text{湿声}(t) = \int_0^t \text{干声}(\tau) \cdot \text{IR}(t - \tau) \, d\tau $$时域直接卷积是 O(N²),对 45 秒音频不可行。快速卷积利用 FFT:把信号和 IR 都变换到频域,逐频率相乘,再逆变换回来,复杂度 O(N log N)。
本项目用一个衰减的随机噪声作为 IR(不依赖真实房间采样),干声经过卷积后听起来像是「在一个空旷空间里演奏」,有自然的混响衰减,没有梳状滤波的金属味。
呼吸 LFO 与量化写出
呼吸 LFO(Low-Frequency Oscillator): sin(2π × 0.08 × t) 以 0.08Hz(约 12.5 秒一个完整周期)缓慢调制总音量,让静态铺底「活」起来。
量化写出(Quantization): 合成完的音频是 float64 数组(范围 -11),WAV 文件需要 16 位整数(-3276832767)。先做 peak normalize 压到 0.5 峰值(留混音余量),再 (x × 32767).astype(np.int16) 量化,用 wave 模块写入:
| |
系列导航
本系列:① 总览与 Remotion ② edge-tts 配音 ③ numpy BGM ④ ffmpeg 合成