用代码生成宣传片(三):numpy 离线合成空灵 BGM

BGM 离线合成(numpy)

第三关是配乐。本项目用 numpy 现场算一段 45 秒空灵 BGM,零版权风险、风格完全可控。

为什么不用素材库

素材库有三个问题:

  • 版权模糊:免费素材的授权条款各不相同,商用可能踩坑。
  • 风格匹配难:要找一段 45 秒、空灵、不抢人声、能切到任意时长的 BGM,几乎不可能。
  • 批量改不了:要调音量、改混响、换调式,素材是固定录音,没法改。

代码生成则完全反着来:参数调一调,重新跑一遍就有新版本。

设计原则(v4「苹果发布会空灵风」)

经过几轮迭代,v4 版确立了五条原则,每一条都对应一个具体的 numpy 实现:

  1. 零失谐(zero detune) — 所有正弦波精确对准十二平均律频率,不做任何 detune。这是消除拍频(beating)的根本手段,也是「听久了不头痛」的第一保证。
  2. 平滑低通(~4.2kHz) — 保留空气感光泽,但 4.2kHz 以上平滑滚降,避免高频刺耳。用 FFT 零相位低通实现。
  3. 噪声脉冲卷积混响 — 替掉等间隔延迟的梳状滤波(comb filtering)。用衰减白噪声作为脉冲响应(IR),干声做快速卷积,尾音自然不嗡嗡。
  4. 五声音阶(C-D-E-G-A)音乐盒铃音 — 纯正弦 + 指数衰减,音符之间永不冲突。干净、空灵、像八音盒。
  5. 极轻高八度「空气 pad」+ 温暖 sub 低频 + 缓慢呼吸 LFO — pad 用基频 + 0.35 倍低八度正弦叠厚度;sub 用 55–65Hz 正弦铺底;LFO 以 ~0.08Hz(12.5 秒周期)缓慢调制总音量,模拟呼吸感。

关键声学坑(头痛三大根因)

如果你听过某些「AI 生成的 BGM」觉得听一会儿就烦躁、头痛,大概率是踩了下面三个坑:

根因现象解法
失谐叠加(detune)持续拍频,振幅周期性涨落零失谐,纯正弦精确对准
等间隔延迟混响梳状滤波,金属嗡嗡声卷积混响,噪声脉冲 IR
无低通 + 谐波堆积高频刺耳,听觉疲劳FFT 低通,~4.2kHz 平滑滚降
mermaid
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 关键片段:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import numpy as np, wave

SR, DUR, N = 44100, 45.0, int(44100 * 45.0)
t = np.arange(N) / SR

def hz(semi):
    """十二平均律:A4=440Hz,semi 是距离 A4 的半音数"""
    return 440.0 * (2 ** (semi / 12.0))

# FFT 零相位低通(保留空气感但平滑滚降)+ 高通去次声
def lowpass_fft(sig, cutoff=4200.0, order=2):
    n = len(sig)
    freqs = np.fft.rfftfreq(n, 1.0 / SR)
    resp = 1.0 / np.sqrt(1.0 + (freqs / cutoff) ** (2 * order))
    hp = (freqs / 30.0) ** 2 / (1.0 + (freqs / 30.0) ** 2)
    return np.fft.irfft(np.fft.rfft(sig) * resp * hp, n)

# 五声音阶铃音(纯正弦 + 极轻二次谐波 + 指数衰减)
def bell(freq, start, amp=0.06, decay=1.4, pan=0.5):
    n = int(SR * (decay + 0.3)); s = int(SR * start)
    tt = np.arange(n) / SR
    env = np.exp(-tt / (decay * 0.5))
    sig = np.sin(2 * np.pi * freq * tt) + 0.18 * np.sin(2 * np.pi * freq * 2 * tt)
    sig *= env * amp
    # 按 pan 分到左右声道 ...
    add(left, s, sig * (...))
    add(right, s, sig * (...))

# C–Am–F–G 和弦进行 + 旋律 + 卷积混响 + 呼吸 LFO + 淡入淡出
# ...

输出:wave.open("bgm.wav","wb") 写 44.1k 立体声 WAV。

mermaid
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/τ) 用于音乐盒铃音,余音渐弱自然。

python
1
2
3
4
5
6
# 余弦斜坡 attack(从 0 平滑升到 1)
attack_len = int(0.01 * SR)  # 10ms
attack = 0.5 - 0.5 * np.cos(np.pi * np.arange(attack_len) / attack_len)

# 指数衰减
env = np.exp(-tt / (decay * 0.5))

FFT 零相位低通

时域的滤波(卷积)在频域变成逐频率相乘,计算量从 O(N²) 降到 O(N log N)。

python
1
2
3
4
5
6
def lowpass_fft(sig, cutoff=4200.0, order=2):
    n = len(sig)
    freqs = np.fft.rfftfreq(n, 1.0 / SR)
    resp = 1.0 / np.sqrt(1.0 + (freqs / cutoff) ** (2 * order))
    hp = (freqs / 30.0) ** 2 / (1.0 + (freqs / 30.0) ** 2)
    return np.fft.irfft(np.fft.rfft(sig) * resp * hp, 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)时,每对音高之间都有微小的差频,这些拍频叠加就变成了持续的、不规则的振幅抖动——大脑处理起来非常疲劳,这就是「听久了头痛」的声学根因。

两个频率接近的正弦波 → 拍频(Beating)振幅时间 →sin(2πf₁t)sin(2πf₂t)和波 = 拍频包络f₁ 与 f₂ 只差几赫兹,和波的振幅会按“差频”周期性涨落 — 这就是音频失谐时的“拍频”,是听久了头痛的根源。

上图 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 模块写入:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# peak normalize → 留 6dB 余量
peak = np.max(np.abs(waveform))
if peak > 0:
    waveform = waveform / peak * 0.5

# 量化 16bit
wav_data = (waveform * 32767).astype(np.int16)

# 写出
with wave.open("bgm.wav", "wb") as wf:
    wf.setnchannels(2)
    wf.setsampwidth(2)     # 16bit = 2 bytes
    wf.setframerate(44100)
    wf.writeframes(wav_data.tobytes())

系列导航

本系列:① 总览与 Remotionedge-tts 配音numpy BGMffmpeg 合成