Web Audio API实战:打造可混音定时停止的白噪音调音台

📅 发布时间:2026/9/9 3:56:49
Web Audio API实战:打造可混音定时停止的白噪音调音台
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移动端适配触屏操作友好锁屏后台继续播放P2Mvp版本我把全部精力放在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控制器做成一个实体推子的“物理调音台”。