白噪音素材课:短素材如何听出"无限"
给实验室做白噪音机的时候,我对素材的要求很贪心:要真实的自然声(雨、海浪、篝火),要"听不出循环",还不能大——我的服务器是 2 核 2G 的小水管,用户拖一条 50MB 的音源下来,带宽当场告急。
搜素材的时候顺手算了笔账,发现这两个诉求看起来矛盾,其实根本不冲突。这篇就当一堂小小的信号课。
关键认知:噪音不需要"长"
先破一个直觉:「循环素材要长,不然听得出循环」。
对音乐这是对的——旋律有结构,30 秒一循环谁都能听出来。但雨声、海浪、篝火这类自然白噪音属于稳态随机信号:它没有旋律、没有可预测的结构,任意两秒之间的统计特性(频谱、响度分布)都几乎一样。
这意味着什么?循环点两侧的样本虽然"不连续",但这种不连续和信号本身的随机性在统计上无法区分——耳朵根本没有参照物去发现"接缝"。一段 45 秒的雨声无缝循环,和一段 45 分钟的雨声,盲听是分不出来的。
NOTE
严格说白噪音、粉噪音、棕噪音是三种不同的频谱斜率:白噪音能量平坦,粉噪音每倍频程衰减 ,棕噪音衰减 。日常说的"雨声海浪"接近粉噪音与棕噪音之间。
体积的账
知道"短素材够用"之后,账就好算了。素材体积近似是:
环境音没有音乐的高频细节和立体声声场需求,mono + 48kbps 的 mp3 绰绰有余。代入:45 秒 × 48kbps ÷ 8 ≈ 270KB / 个场景。六个场景(细雨、篝火、夜虫、溪流、森林、海浪)总共 1.35MB——而且这还是最坏情况,实际是点开哪个场景才下载哪个,单次请求 270KB,3Mbps 小水管下一秒不到。
对比一下我从素材站拖下来的原始文件:最夸张的一条溪流 10MB。截短、转 mono、降码率三连之后,整体从 21MB 压到 1.35MB,压掉 93%。
接缝的最后一毫米:交叉淡化
"统计不可闻"是理论,工程上还补了一道保险:解码后对音频 buffer 的首尾做 20ms 交叉淡化——把最后 20ms 和开头 20ms 按位置加权混合,让循环点两侧的样本从"跳变"变成"渐变":
// 尾部 n 样本与头部按 t∈[0,1) 混合,循环接缝平滑过渡
for (let i = 0; i < n; i++) {
const t = i / n;
data[len - n + i] = data[len - n + i] * (1 - t) + data[i] * t;
}20ms 是有讲究的:太短(比如 2ms)压不住有色噪音的相位跳变,太长(比如 500ms)又会让循环点附近出现可闻的"呼吸感"。20ms 是刚好把任何残余咔嗒声平滑掉的窗口。
TIP
千万别用 HTML <audio> 标签的 loop 属性做无缝循环——它的循环间隙常有几十毫秒的停顿。要无缝就用 Web Audio 的 AudioBufferSourceNode,loop = true 是采样级精确的。
零体积的那一半
其实这套白噪音机有一半场景根本不用素材:白噪音、粉噪音、棕噪音是纯数学信号,直接用 Web Audio 实时合成——粉噪音用 Paul Kellet 的近似滤波,棕噪音对白噪做积分。合成的噪音可以无限播放、任意变速、零下载体积,质量甚至比录音更"纯"。
所以最终架构是双引擎:三个数学噪音实时生成,六个自然场景按需加载采样,两边都能单独调音量、随意叠加——雨声垫一层海浪,篝火配点夜虫,混出只属于你的背景音。
复盘
这次最大的收获是又一次验证了那条经验:需求里的矛盾,很多时候是提法造成的。"要高质量长素材"和"带宽太小"看似冲突,但把"长"这个伪需求拆掉(稳态信号不需要长),冲突就消失了。
素材来自 Mixkit(免商用许可),出处和规格都记在仓库的 CREDITS.md 里。循环到底有没有接缝,去 /lab/noise 开一段雨声自己验收 :)