1. 项目概述与整体设计思路
1.1 为什么需要一台“白噪音调音台”
先说说我做这个项目的起因。有一次在群里和朋友聊到专注和助眠的话题,有人吐槽说市面上的白噪音App虽然多,但体验总差那么一口气:要么内置声音种类少,只有固定几种场景;要么可以混音但操作复杂,调节音量要靠滑来滑去的小滑块;最头疼的是很多App不支持定时停止,经常听着听着睡着了,手机放了一整夜的白噪音,第二天起来发现电量掉了不少。
这个抱怨点醒了我。市面上缺的不是白噪音本身,而是一个足够灵活、能在本地跑通、自由混音并且带可靠定时停止的“调音台”。就像DJ用调音台把不同音轨混在一起那样,我们可以把雨声、篝火声、咖啡馆环境音当作三路独立的“音轨”,分别控制音量、单独开关,还能叠加组合,最后设定一个播放时长,到点自动淡出停止。这就是这个项目名字的由来:白噪音调音台。
我把这个项目的目标定得很具体:第一,纯前端实现,不依赖服务器,打开网页就能用;第二,用Web Audio API在浏览器里实时合成声音,而不是简单播放几条音频文件,这样每一路声音都可以自由调节和组合;第三,定时停止必须可靠,不能依赖手机或者电脑系统的睡眠策略,到了设定时间必须自动静音并释放资源。做完之后,我自己的实际使用率很高,写代码的时候放雨声加咖啡馆,睡觉前只留篝火声。
1.2 技术选型:为什么用 Web Audio API 而不是直接播音频文件
在技术选型上,我其实做过两版对比。第一版方案最直接:准备三段音频文件,分别是雨声、篝火声、咖啡馆环境音,用HTML的audio标签播放,再用JavaScript控制音量混合。这个方案做起来很快,但有几个硬伤。
音频文件体积大,三段高质量素材动辄几十MB,加载慢,移动端流量费吃不消;想要把雨声调节得“大一点但别太亮”,文件方案就很难做到——本质上只能调整体音量,不能对频段做调整;更麻烦的是循环播放时接缝处容易有爆音或断点,听感很粗糙。
所以我最终选择了Web Audio API。它的核心优势是可以程序化生成和处理声音:我们可以用噪声源配合滤波器,在浏览器里实时合成出接近真实的雨声、篝火声和咖啡馆底噪。这个过程完全不依赖音频文件,代码量也不大,而且每一路声音的频段、音量、声像都能独立控制。这相当于把整个“调音台”搬进了浏览器,所有声音成分都是实时运算出来的,想怎么改就怎么改。
Web Audio API还有一个隐藏好处:它基于AudioContext.currentTime做时间调度,精度远高于JavaScript的setTimeout。对于“定时播完自动停”这个需求,我们可以把停止动作直接挂到音频时间轴上,到点了用gain节点的线性变化做平滑淡出,不会出现“啪”的一下突然断掉的感觉。这一点用普通的audio标签很难做到自然。
1.3 功能拆解与版本规划
我给自己画了一张功能清单,把项目拆成三个版本逐步推进。MVP版本只解决三件事:能出声音、能混音量、能定时停。后续再考虑增加音效预设、播放场景组合和移动端适配。这样做的好处是每一步都有一个可用的版本,不会被功能膨胀拖死。
下面是MVP版本的功能拆解表:
| 功能点 | 说明 | 优先级 |
|---|---|---|
| 三路混音 | 雨声、篝火、咖啡馆三路独立开关与音量控制 | P0 |
| 定时停止 | 支持15/30/60/90分钟和自定义时长,到点自动淡出停止 | P0 |
| 声音合成 | 全部用Web Audio API实时合成,不依赖外部音频文件 | P0 |
| 状态展示 | 当前剩余播放时间、每路音量的视觉反馈 | P1 |
| 场景预设 | 提供“小雨工作”“深夜篝火”“咖啡馆阅读”等一键组合 | P1 |
| 移动端适配 | 触屏操作友好,锁屏后台继续播放 | P2 |
Mvp版本我把全部精力放在P0项上,先跑通核心链路。结果证明这个节奏是对的,因为定时停止的可靠性问题远比想象中多,如果一开始就铺开做界面和预设,排查起来会很痛苦。后面的P1和P2功能是在主链路稳定之后慢慢加的,改动风险很低。
2. 核心细节解析:如何合成三种标志性声音
2.1 雨声:白噪声 + 带通滤波的听感操控
雨声是整个项目里最重要的声音。我一开始以为雨声就是简单放一段白噪声,但实际听下来完全不对——纯粹的白噪声听起来像电台没信号的沙沙声,刺耳、没有空间感,很容易让人烦躁。真实雨声的能量集中在一定的频段里,雨滴落在不同材质上还会产生细微差别。
Web Audio API里最基础的声音原料是白噪声,它包含所有频率成分且能量均匀。要让白噪声听起来像雨,核心手段是加滤波器。我用了BiquadFilterNode,类型设为bandpass,中心频率放在1000Hz左右,Q值调在0.5到1.0之间。这样做的效果是保留中频段的能量,同时削掉高频的刺耳感和低频的轰鸣感,出来的声音就有点像均匀的细雨打在树叶上的感觉。
但光有静态的滤波器还不够。真实雨声有起伏,风吹过来时雨声会一阵大一阵小。我加了一个低频振荡器LFO,频率设在0.1Hz左右,用它去缓慢调制滤波器的中心频率或者增益值。实际做下来,LFO调制增益值的效果更明显,雨声会从“均匀的沙沙”变成“有呼吸感的雨幕”,听感自然很多。
合成雨声的简化代码大概长这样:
// 创建白噪声 buffer function createWhiteNoiseBuffer(ctx, seconds = 2) { const bufferSize = Math.floor(ctx.sampleRate * seconds); const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { data[i] = Math.random() * 2 - 1; } return buffer; } // 雨声节点链:白噪声 -> 带通滤波 -> 音量 -> 主输出 function buildRainChain(ctx) { const source = ctx.createBufferSource(); source.buffer = createWhiteNoiseBuffer(ctx); source.loop = true; const filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 1000; filter.Q.value = 0.7; const gain = ctx.createGain(); gain.gain.value = 0.5; // 基准音量,后面会被用户调节覆盖 source.connect(filter); filter.connect(gain); // gain 最后接到调音台主总线 // 用 LFO 让雨声有起伏感 const lfo = ctx.createOscillator(); lfo.frequency.value = 0.1; const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.15; lfo.connect(lfoGain); lfoGain.connect(gain.gain); lfo.start(); source.start(); return { source, filter, gain, lfo }; }这里的缓冲区长度我故意设为2秒并开启loop。如果缓冲区太短,循环接缝处的波形不连续,容易产生周期性的“咔哒”声;2秒的缓冲足够长,随机噪声接缝处相位不匹配的感觉几乎听不出来。
2.2 篝火:低频底噪 + 随机爆裂脉冲
篝火声比雨声难做。雨声本质上是连续噪声,篝火则是“连续底噪+随机爆裂”的组合。真实篝火有持续的燃烧声,那是低频的嘶嘶声,同时会有木头爆裂时短促的“噼啪”声,两者叠加才有画面感。
底噪部分我用粉红噪声来打底,粉红噪声的低频能量比白噪声更强,更贴近燃烧时那种闷闷的声响。简单生成粉红噪声的方法是对白噪声做一阶低通滤波,我这个项目的实现里用一个近似公式:out[i] = 0.98 * lastOut + white * 0.02,然后把原始白噪声和滤波结果按比例混合,得到有厚度又不闷的底噪。
爆裂声是最有意思的部分。我的方案是准备一个极短的噪声脉冲buffer,大约0.02到0.05秒,每隔随机时间就触发一次播放,同时给每次播放不同的音量增益和播放速率。这样每个爆裂声的响度和音高都有细微差异,听起来就不是机械重复,而是像真实的柴火在噼啪作响。
触发爆裂的调度代码思路是这样的:
function scheduleCrackles(ctx, crackleBuffer, crackleGain, interval) { let lastPlayTime = ctx.currentTime + interval; function tick() { const now = ctx.currentTime; if (now >= lastPlayTime) { const source = ctx.createBufferSource(); source.buffer = crackleBuffer; source.playbackRate.value = 0.8 + Math.random() * 0.4; // 随机速率 const singleGain = ctx.createGain(); singleGain.gain.value = 0.2 + Math.random() * 0.6; // 随机音量 source.connect(singleGain); singleGain.connect(crackleGain); source.start(); // 随机 1.5~6 秒后再来一次 lastPlayTime = now + 1.5 + Math.random() * 4.5; } } setInterval(tick, 100); }注意这里的调度间隔是100毫秒的轮询,精度完全够用。如果追求更高精度的随机触发,可以用setTimeout递归,但实际听下来100毫秒的轮询误差根本感知不到。爆裂声的buffer需要做一个快速淡出处理,否则每个脉冲播放结束时会听到尖锐的爆音。我在填充脉冲数据时,对尾部乘以一个衰减系数,让波形平滑归零。
2.3 咖啡馆:粉红噪声底床 + 模糊人声采样
咖啡馆环境音是三路里面听感最具挑战性的。它不像雨声和篝火那样有鲜明的自然声特征,它的核心是“人声的模糊感”:有聊天的嗡嗡声,有杯碟碰撞的清脆声,有背景音乐的残响。处理不好就会变成一团浑浊的噪声,反而让人更紧张。
我的做法是分层堆叠。第一层是粉红噪声,作为咖啡馆的空间底床,音量压得很低,只在其他声音停顿时才能隐约感觉到。第二层是若干个极短的“人声样片段”,这些片段不是真实的人声录音,而是用带通滤波后的噪声模拟出来的模糊语音能量。每个片段的持续时间为0.3到0.8秒,频率范围集中在300Hz到2000Hz之间,随机间隔触发,听起来像远处有人在交谈的嗡嗡声,但听不清任何内容。
为了模拟人声的“韵律感”,我给每个片段设置了随机的起始频率和结束频率,让它们在播放过程中有一个轻微的频率扫动。这样听上去更像一句带语调的话,而不是一成不变的噪声。杯碟碰撞声则用非常短促的高频噪声脉冲来模拟,触发频率比人声片段低很多。
咖啡馆这一路的基准音量要特别控制。人耳的听觉对不同频段有不同敏感度,咖啡馆底床的高频成分不多,整体响度感觉会比同幅值的雨声低一大截。我在调试时发现一个实用技巧:先单独听每一路声音,把它们调到“单独播放时音量舒适”的水平,记录下各自的gain值,再以这个为基准组合播放。这样能避免“咖啡馆开得很小听不清,开大一点又盖住雨声”的尴尬。
3. 实操过程与核心环节实现
3.1 音频节点图与混音架构
整个混音台的核心是一张音频节点图,所有声音最终汇聚到一个MasterGainNode,再接到ctx.destination输出到扬声器。每一路声音链路都遵循“声源 -> 效果器 -> 音量控制 -> 主总线”的路径,这样结构清晰,以后要加新的声音类型也只需要复制一条链路改参数。
节点图结构如下:
雨声源 -> 带通滤波 -> rainGain ---+ 篝火底噪 -> 低通滤波 -> fireBaseGain -+--> masterGain -> ctx.destination 篝火爆裂声 -> 单次触发 -> fireCrackleGain + 咖啡馆底床 -> 带通滤波 -> cafeBaseGain --+ 咖啡馆人声 -> 随机触发 -> cafeMumbleGain +在代码里,我会创建一个Mixer类来管理这些节点。每个声音源组件返回自己链路末端的GainNode,外部只需要操作这个GainNode的gain.value就能控制音量,完全不关心内部细节。这样方便后续给每个轨道加可视化效果,比如音量条显示的是gain值的变化。
主总线的增益值我给了一个固定上限:masterGain.gain.value最大0.8。有人会问为什么不直接拉到1.0,原因很简单——多路声音叠加时,信号幅值会累加,如果每路音量都开到最大,主总线很容易削波,声音会变得失真且刺耳。保留20%的余量能保证即使所有轨道都开满,也不会触发削波。
3.2 混音控制:音量归一化与动态余量
混音台最大的坑在于音量感知不一致。不同频段的声音,即使幅值相同,人耳的主观响度差异也很大。低频特别容易被感知为“小声”,高频则容易觉得“刺耳”。所以不能简单地把三路都设成0.5就算完事,必须做音量归一化。
我的归一化做法是先建立一个基准:单独播放雨声,把gain调到0.4时主观听感舒适,记下这个值。然后播放篝火,逐步调整gain值直到主观响度和雨声0.4时接近。实测下来,篝火底噪大概需要0.5到0.6,咖啡馆底床只需要0.3左右。这里没有标准答案,因为不同设备扬声器频响曲线不一样,但归一化本身的价值在于给后续组合提供一个合理起点。
组合时的原则是“主次分明”。如果是工作场景,雨声为主,篝火和咖啡馆都作为背景,那么篝火音量设为雨声的60%,咖啡馆设为40%。如果是助眠场景,篝火为主,雨声做远端背景。这个“主次比例”是一个经验值,可以做成三档预设,避免用户每次手动调半天。
动态余量的管理我前面提到了,总线上留20%的空间。但还有一层容易被忽略:LFO调制增益时,gain值会周期性升高,如果某路gain本身就接近上限,LFO的调制幅度就会把信号推到削波边缘。所以我做LFO调制时,调制深度不超过该路当前音量的30%,并且所有路的gain初始值都控制在0.5以内,确保最坏情况下总输出也不会超过0.8。
3.3 定时播完自动停的可靠实现
定时停止是标题里最吸引人的功能点,也是我在实现过程中踩坑最多的地方。第一版我想得太简单,以为setTimeout定时器到期后把masterGain设为0就行。实际测试发现两个问题:一是当浏览器标签页切到后台或者手机锁屏后,setTimeout会被浏览器大幅度节流,原本设定30分钟计时,可能40分钟才触发;二是直接归零没有淡出,停止那一刻“啪”一声很突兀。
正确做法是抛弃setTimeout作为停止的最终手段,改用AudioContext.currentTime做时间调度。currentTime是音频时钟,不受页面后台节流影响,它按照音频硬件的时间轴走,精度是毫秒级。我的实现思路是:先记录启动时的时间基准,用linearRampToValueAtTime把masterGain的gain值在规定时间内平滑降到0,再由一个setTimeout作为兜底,在淡出完成后调用ctx.suspend()释放资源。
核心逻辑如下:
function scheduleAutoStop(ctx, masterGain, durationMinutes) { const durationSeconds = durationMinutes * 60; const stopTime = ctx.currentTime + durationSeconds; const fadeSeconds = 5; // 最后5秒淡出 const currentGain = masterGain.gain.value; masterGain.gain.cancelScheduledValues(ctx.currentTime); masterGain.gain.setValueAtTime(currentGain, ctx.currentTime); masterGain.gain.setValueAtTime(currentGain, stopTime - fadeSeconds); masterGain.gain.linearRampToValueAtTime(0.0001, stopTime); // setTimeout 只做兜底资源清理 const timerId = setTimeout(() => { if (ctx.state === 'running') { ctx.suspend().catch(() => {}); } }, (durationSeconds + 2) * 1000); return timerId; }注意我淡出的终点不是0,而是0.0001。这是因为gain值为0时,某些浏览器会做优化,把整个音频图切断,导致后续如果需要恢复播放,可能出现短暂的卡顿。保留一个极小的非零值,可以避免音频图被内部优化器挂起。
这个方案实测非常稳。我特意做过一个测试:设置3分钟定时,切到其他标签页干别的事,期间不断观察标签页卡顿情况,到了3分05秒左右声音平滑消失。虽然兜底setTimeout被节流延后了几秒,但音频时钟控制的淡出动作没有受到影响,5秒的淡出是从第3分钟整开始的,实际静音时间在3分05秒,完全满足预期。
还有一个细节是定时时长显示。我用一个每秒更新的定时器读取ctx.currentTime和停止时间的差值,计算出剩余分钟和秒数,更新到界面上。这个定时器即使被节流,也只是界面显示延时,不影响真正的停止逻辑。这种“显示用JS、执行用音频时钟”的分离设计,是保证可靠性的关键。
4. 常见问题与排查技巧实录
4.1 浏览器自动播放策略:为什么点了按钮还是没有声音
做Web Audio应用的人几乎都会遇到这个问题:页面加载后直接调用ctx.resume()或者source.start(),控制台报错说AudioContext被阻止,或者干脆没有任何声音。这是因为浏览器强制要求音频上下文必须由用户手势激活,防止网页一打开就自动发声吓人一跳。
我第一次调试时在页面onload里创建了AudioContext并立即调用resume(),Chrome直接返回一个promise被reject,声音完全出不来。排查了一圈才明白:必须在用户点击“开始”按钮之后,在点击事件回调里调用ctx.resume()或者创建AudioContext。
正确做法是给页面加一个启动按钮,在点击事件里统一初始化。初始化函数里要做的事包括:创建AudioContext、构建所有声音链路、启动各噪声源、然后调用ctx.resume()。因为这一连串动作都发生在用户手势回调里,浏览器就会允许音频播放。
这里还有一个移动端特有的坑:iOS Safari对AudioContext的处理和桌面Chrome不一样。iOS上不只是初始化需要用户手势,之后如果AudioContext因为超时或者锁屏被挂起,再次恢复播放时也必须有一次用户交互。我的做法是监听document的touchstart和click事件,如果检测到ctx.state不等于'running',就在事件回调里调用ctx.resume()。这样用户点击任意位置都能把音频上下文唤醒,体验上不会觉得“失灵”。
4.2 长时间播放的点击声和周期性爆音
第二个高频问题是在长时间播放后,每隔一小段时间就出现一次轻微的“咔哒”声。这个声音很像是循环播放的边缘没有处理好。我的白噪声buffer长度为2秒,按理说循环接缝处应该听不出来,但实际测试中发现,当某个BiquadFilter的Q值较高时,接缝处的瞬态会被滤波器放大,形成周期性的爆音。
排查方法是用浏览器录音工具录下输出音频,用波形编辑器查看爆音出现的间隔。结果发现爆音的间隔刚好等于循环buffer的时长,由此确认是循环接缝问题。解决办法有两个方向:一是把循环buffer加长到5秒甚至10秒,降低接缝频率,让耳朵更不容易察觉;二是对buffer的头部和尾部做交叉淡化处理,让接缝处的波形值尽量接近。
最终我采用了增加buffer长度的方案,兼顾简单和有效。5秒的白噪声随机buffer足够长,接缝处的波形相位差已经听不出来了。如果是篝火的爆裂声buffer,因为它是单次触发不循环,所以不存在这个问题;但短脉冲的尾部淡出处理必须做好,否则每个爆裂声都会自带一个“啪”的尾音。
4.3 多路混合时的削波与失真问题
当三路声音同时打开时,如果每路音量都调到0.6以上,总输出很容易削波,声音变得粗糙有毛刺。最开始我认为是扬声器音量开太大,后来把系统音量调低后问题依旧,这才意识到是信号链内部的削波,不是物理层面的过载。
解决削波需要从两个层面入手。第一是每路声源的初始增益限制,我统一把各链路的GainNode初始值控制在0.5以内,再加上LFO调制幅度限制在30%以内,保证最坏情况不会超限。第二是主总线的动态余量,masterGain最大值设为0.8,同时建议用户在系统层面把音量调到50%到70%,留出播放器的动态空间。
还有一个隐藏的削波源:爆裂声的瞬时峰值。篝火的爆裂声理论上每次音量都是随机的,但如果某次随机值碰巧很大,瞬时峰值可能直接冲顶。我给每个爆裂声的单次GainNode上也做了削波,设置一个音量上限0.8,防止单次脉冲成为削波的源头。这种“每路各自限制,主总线再统一留余量”的做法,能让整个系统在任何组合下都保持干净的听感。
4.4 内存与资源释放:关闭后仍然嗡嗡响怎么办
项目做到后期,我遇到一个奇怪现象:点击停止后,雨声停了,但能听到极微弱的底噪还在响。排查后发现是篝火的爆裂声调度器还在运行。我只在停止时把masterGain降到了0.0001,但原本用setInterval轮询的爆裂声调度器没有被清理,它会持续创建新的BufferSourceNode,虽然音量几乎为零听不到,但内存和CPU占用一直在涨。
解决办法是把调度器实例保存起来,在停止时调用clearInterval清理;同时把所有正在播放的BufferSourceNode都停止掉。AudioBufferSourceNode有stop()方法,可以在停止时遍历所有活跃源节点统一调用。更优雅的方案是用GainNode做一个总开关,停止时直接把所有链路从主总线上断开(disconnect),这样即使底层调度器还在运行,信号也不会到达扬声器,不会产生声音,之后再做资源清理。
资源清理的最终代码顺序是:先断开所有链路到主总线的连接,再清除定时器和调度器,最后调用ctx.suspend()。这样处理之后,停止后CPU占用率几乎归零,内存也不会持续增长。
根据我个人实际操作的经验,这个白噪音调音台做成纯浏览器应用之后,实用性远超预期。它不需要装App,手机和电脑打开同一个网页就能用,配合PWA还能一键添加到桌面。后面如果大家有兴趣,我还可以讲讲怎么用AudioWorklet进一步细化篝火声的爆裂模型,或者接入MIDI控制器做成一个实体推子的“物理调音台”。