Code-Generated Promo Videos (3): numpy Offline Ethereal BGM Synthesis
Offline BGM Synthesis with numpy
The third challenge is background music. This project uses numpy to synthesize a 45-second ethereal BGM on the fly — zero copyright risk, fully controllable style.
Why Not a Music Library
Stock music libraries have three problems:
- Copyright ambiguity: Free tracks come with varying licenses; commercial use may be risky.
- Style mismatch: Finding a 45-second ethereal track that doesn’t compete with voiceover and can be trimmed to any length is nearly impossible.
- No batch tweaking: Need to adjust volume, change reverb, or switch keys? A fixed recording gives you no control.
Code generation flips this: tweak a few parameters, re-run, and you get a new version instantly.
Design Principles (v4 “Apple Keynote Ethereal”)
After several iterations, v4 established five principles, each backed by a concrete numpy implementation:
- Zero detune — All sine waves are locked to exact equal-temperament frequencies, no detuning whatsoever. This is the fundamental remedy for beating and the #1 guarantee against ear fatigue.
- Smooth lowpass (~4.2kHz) — Preserve airy sheen while rolling off smoothly above 4.2kHz to avoid harshness. Implemented via FFT zero-phase lowpass.
- Noise-pulse convolution reverb — Replace equally-spaced delay (comb filtering) with convolution using decaying white noise as the impulse response. Natural-sounding tails, no metallic buzz.
- Pentatonic (C-D-E-G-A) music-box chimes — Pure sine + exponential decay, notes never clash. Clean, ethereal, like a music box.
- Light high-octave “air pad” + warm sub bass + slow breathing LFO — Pad layered with fundamental + 0.35× sub-octave sine for thickness; sub at 55–65Hz for warmth; LFO at ~0.08Hz (12.5s cycle) modulating overall volume for a breathing sensation.
Acoustic Pitfalls (Three Root Causes of Headache)
If you’ve heard “AI-generated BGM” that feels irritating after a while, it’s likely hitting one of these three pitfalls:
| Root Cause | Phenomenon | Fix |
|---|---|---|
| Detuned sine stacking | Sustained beating, periodic amplitude fluctuation | Zero detune, exact equal-temperament |
| Equally-spaced delay reverb | Comb filtering, metallic buzzing | Convolution reverb with noise IR |
| No lowpass + harmonic buildup | Harsh highs, listener fatigue | FFT lowpass, ~4.2kHz smooth rolloff |
flowchart TD
D["Detuned stacking"] --> B["Sustained beating"]
B --> Bfix["Zero detune<br/>Pure sines"]
E["Equal-spaced delay"] --> C["Comb filtering<br/>Metallic buzz"]
C --> Cfix["Convolution reverb<br/>Noise IR"]
N["No lowpass"] --> H["Harsh highs<br/>Harmonic pileup"]
H --> Hfix["FFT lowpass<br/>~4.2kHz"]Each root cause → phenomenon → fix forms an independent chain. The three chains are decoupled.
Key snippets from gen_bgm.py:
| |
Output: wave.open("bgm.wav","wb") writes 44.1kHz stereo WAV.
flowchart TD
Add["Additive synth<br/>Sine waves"] --> Env["Envelope<br/>Cosine/exp"]
Env --> LP["FFT zero-phase<br/>Lowpass 4.2kHz"]
LP --> Rvb["Convolution reverb<br/>Noise IR"]
Rvb --> WAV["Quantize<br/>16bit WAV"]The full BGM synthesis pipeline: each stage does one thing, and the output of one stage feeds the next.
Understanding how the pipeline works, let’s explore the acoustic principles behind each component.
numpy Under the Hood: How Arrays Become Music
The previous section covered what to do; this section explains why it works — understanding this lets you modify every line of the numpy code yourself.
Digital Audio First Principles
Sound is the vibration of air pressure over time. Digital audio captures it by sampling — taking 44100 amplitude values per second (44.1kHz, the CD standard), each a float between -1 and 1.
A 45-second audio clip = an array of 45 × 44100 ≈ 1.98 million elements. A numpy array is the waveform itself.
Why 44.1kHz? The Nyquist-Shannon sampling theorem states: to faithfully reconstruct a signal with maximum frequency f, the sample rate must be ≥ 2f. Human hearing tops out at about 20kHz, so 44.1kHz (> 2 × 20kHz) became the CD standard. Higher rates like 48kHz/96kHz exist for cinema/professional use, but offer negligible perceptual improvement.
Additive Synthesis and Pitch-to-Frequency Conversion
Additive Synthesis: Fourier showed that any periodic waveform can be decomposed into a sum of sine waves. Conversely, we can synthesize complex timbres by summing individual sine waves with np.sin(2π·f·t).
Equal Temperament: Each semitone is a factor of 2^(1/12). A4 = 440Hz, so the frequency n semitones away from A4 is:
$$f = 440 \times 2^{\frac{n}{12}}$$The hz(semi) function in code implements exactly this formula. C4 is -9 semitones from A4, so Hz(C4) = 440 × 2^(-9/12) ≈ 261.63Hz.
In this project, the pad layers fundamental + 0.35× sub-octave sine for thickness; the bell uses fundamental + 0.18× 2nd harmonic for a glassy texture.
Envelope: The Volume Window
An envelope shapes volume over time and is applied via per-sample multiplication.
Cosine ramp: Attack and release use 0.5 - 0.5·cos(π·t/T) for smooth transitions, avoiding “click” artifacts from abrupt on/off switching.
Exponential decay: exp(-t/τ) for music-box chimes, creating a natural, gradually fading tail.
| |
FFT Zero-Phase Lowpass
Filtering (convolution) in the time domain becomes per-frequency multiplication in the frequency domain, reducing complexity from O(N²) to O(N log N).
| |
Pipeline: rfft(waveform) → multiply by frequency response → irfft back to time domain.
Zero-phase: The “forward FFT → frequency-domain processing → inverse FFT” chain introduces no phase shift whatsoever. In contrast, time-domain IIR filters (e.g., Butterworth) cause frequency-dependent phase delay — different frequencies emerge with different delays, distorting the waveform’s group delay. Zero-phase FFT filtering completely avoids this, producing cleaner sound with better transient preservation.
Why ~4.2kHz cutoff? Voice energy concentrates in 300Hz–3.4kHz; everything above 4.2kHz contributes airiness (sibilant sheen) without letting harsh harmonics (5–8kHz buildup) irritate the ear.
The Mathematics of Beating
Beating is the #1 cause of “this BGM gives me a headache.” The math is beautifully simple:
Summing two sine waves with close frequencies:
$$ \sin(2\pi f_1 t) + \sin(2\pi f_2 t) $$Using the sum-to-product identity:
$$ = 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) $$Key insight: The sum = a high-frequency carrier at the average frequency × a low-frequency envelope oscillating at half the difference frequency.
The difference frequency Δf = |f₁ - f₂| determines how fast the envelope swells. The envelope crosses zero every half-cycle (amplitude goes to zero), so the perceived amplitude pulsation rate = Δf.
Example: 440Hz + 441Hz → Δf = 1Hz → volume swells once per second. This is beating.
When multiple instruments each apply slight detuning (e.g., ±2 cents), every pair of pitches creates a tiny difference frequency. These beating components superposition into continuous, irregular amplitude jitter — exhausting for the brain to process, hence the headache.
The diagram above shows f₁ and f₂ differing by only a few Hz; the sum’s amplitude swells and fades at the difference frequency — this is beating. Our BGM locks every sine wave to exact equal-temperament frequencies with zero detune, eliminating beating at the source. Using pure sines (no harmonics) also avoids cross-beating between overtone series.
Comb Filtering: Evenly-Spaced Zeros in the Frequency Domain
Comb filtering is another common source of auditory fatigue.
When a signal is summed with a delayed copy of itself (e.g., using equally-spaced delays for “fake reverb”):
$$ y(t) = x(t) + \alpha \cdot x(t - \tau) $$In the frequency domain, this creates nulls (amplitude = 0) at frequencies f = k / (2τ) for odd k, and peaks at f = k / τ. The null spacing = 1/τ, giving the spectrum a comb-like appearance.
These evenly-spaced notches impart a metallic, hollow, buzzing character — especially when the delay is short (small τ), causing wide null spacing and audible low-frequency dips that make the sound feel boxy.
Our solution: Replace equally-spaced delays with convolution reverb. Convolution has no periodic structure; the frequency response is smooth and natural.
Convolution Reverb
Real-room reverb = the convolution of dry signal with an Impulse Response (IR) — the sound of a handclap decaying in that room.
$$ \text{Wet}(t) = \int_0^t \text{Dry}(\tau) \cdot \text{IR}(t - \tau) \, d\tau $$Direct time-domain convolution is O(N²), impractical for 45 seconds of audio. Fast convolution uses FFT: transform both signal and IR to the frequency domain, multiply per frequency, then inverse-transform — complexity O(N log N).
This project uses decaying random noise as the IR (no real-room sampling needed). After convolution, the dry signal sounds like it was performed in an open space with natural reverb tail — no comb-filtering metallic artifacts.
Breathing LFO and Quantization
Breathing LFO: sin(2π × 0.08 × t) modulates the overall volume at 0.08Hz (≈12.5s per cycle), making the static pad feel alive.
Quantization: The synthesized audio is a float64 array (-11), but WAV files use 16-bit integers (-3276832767). First peak-normalize to 0.5 (headroom for mixing), then quantize with (x × 32767).astype(np.int16), and write via the wave module:
| |
Series Navigation
Series: ① Overview & Remotion ② edge-tts Voiceover ③ numpy BGM ④ ffmpeg Muxing