在线节拍器

用 Web Audio 的音频时钟排程发声,而不是让 setInterval 直接发声音:每 25 毫秒做一次前瞻,把接下来 100 毫秒内的拍点一次性交给音频线程,节拍不会随定时器抖动而漂移。

BPM拍间隔每小节时长每分钟拍数100ms 前瞻覆盖

提示:BPM、拍数、重音倍数、音量四个输入框里按 Enter 会立刻重算下面的调度面板与对照表;想让节拍真的响起来,点一次「开始」——浏览器要求音频必须由用户手势启动。4/4 拍、120 BPM 是默认示例:每拍 500 毫秒,一小节 2 秒。

使用方法

  1. 四个输入框默认是 120 BPM、每小节 4 拍、重音频率倍数 2、音量 0.6,正好是一段最常见的 4/4 练习速度。
  2. 点「开始」,音频上下文会被创建并在用户手势里 resume(),第一拍从点击后约 60 毫秒处起算;按钮随即变成「停止」。
  3. 圆点行显示当前小节内的第几拍,重音拍用琥珀色标出,其余拍用深色;它只是一块显示面板,和调度队列之间隔着一条「按音频时间延迟显示」的通道,所以视觉上会略滞后于声音。
  4. 「试算」不发出声音,只把拍间隔、每小节时长、每分钟拍数、100 毫秒前瞻能覆盖多少拍算出来,并在下方对照表里列出 30 到 300 BPM 的完整换算;输入框里按 Enter 等价于点「试算」。
  5. 对照表默认显示 12 行,点「加载更多」每次追加 8 行;「复制对照表」写制表符文本到剪贴板,「导出 CSV」下载带 UTF-8 BOM 的文件,Excel 打开不会中文乱码。

这个在线节拍器 能解决什么

它解决的是「用 setInterval 直接发声音,节拍会飘」这件事。定时器回调不是精确闹钟:HTML 规范规定,一个嵌套超过 5 层的定时器,延时低于 4 毫秒会被抬到 4 毫秒;更严重的是回调本身要排队,页面一忙就晚几十毫秒。如果每个回调里直接 osc.play(),这几十毫秒的抖动会一比一变成听得出来的节奏不稳,练习时长音和节拍器就总是差一点点。

第二件事是把不确定的定时器和确定的音频时钟分开。Web Audio 的 AudioContext 内部跑着一块跟随声卡时钟推进的时间轴,currentTime 单调递增、不受页面忙碌影响,而且 oscillator.start(t) 里的那个 t 是绝对时间:你提前 100 毫秒告诉它「在第 3.500 秒响」,它就一定在第 3.500 秒响,哪怕那 100 毫秒里主线程卡了 80 毫秒。

第三件事是说清楚什么情况下这个方案也会失效。页签切到后台后,浏览器把 setInterval 从 25 毫秒夹到最长 1000 毫秒一次;进阶节流还会在隐藏满 5 分钟且页面 30 秒没出过声之后,把唤醒压到每分钟一次。这时候已经排进音频线程的音符照样准点响,但队列吃完之后就再没有新音符被排进去,听感上就是节拍断了——这是浏览器省电策略的必然结果,不是脚本写错了。

在线节拍器的调度原理:setInterval 只做前瞻,AudioContext.currentTime 负责排程

整个调度器只有三段逻辑,全部写在一个 setInterval 回调里,周期固定 25 毫秒:

第一段是「看窗口」。每次回调只做一件事:检查「下一个待排的拍点时间」是否已经落进「当前音频时间 + 100 毫秒」这个窗口。落进去就排,落不进去就退出等下一次回调。这个 100 毫秒的窗口就叫前瞻(lookahead),25 毫秒是它的刷新周期。两者之差 75 毫秒就是安全余量:只要单次回调迟到不超过 75 毫秒,窗口里永远不会出现空白,节拍就不会漏拍。

第二段是「排音符」。对每个落进窗口的拍点 t,新建一个 OscillatorNode 和一个 GainNode,把频率设成基频 880 Hz(重音拍则乘上重音频率倍数,默认 2 倍即 1760 Hz),再用 gain.setValueAtTime() 和 exponentialRampToValueAtTime() 画一个 2 毫秒起音、50 毫秒衰减的包络——直接给增益赋 0 或 1 会在波形上造成跳变,听感就是「咔」的一声爆音。最后 o.start(t)、o.stop(t + 0.06)。

第三段是「推进时间」。排完一拍,让 nextBeatTime 自增一个拍长 60 / bpm,小节内计数加一,满一小节归零并把重音留给下一小节的第 1 拍。关键细节:永远用「上一拍时间 + 拍长」推进,而不是每次从 currentTime 反推。从 currentTime 反推等于把每次回调的迟到量累加进节拍,几十秒之后就会明显偏掉;从上一拍累加则是一条绝对时间轴,误差不累积。

另外两条容易踩的规则:AudioContext 创建后默认处于 suspended 状态,必须在用户手势(点「开始」)里调用 resume(),否则静默无声;重音不是换个音色,而是把同一个振荡器的频率乘一个倍数,accentFrequencyRatio 取 2 就是一个八度,取 1.5 是纯五度左右,取 4 是两个八度。

完整示例:120 BPM、每小节 4 拍的时间轴

示例条件就是页面打开时的默认值:120 BPM、每小节 4 拍。拍长 = 60 ÷ 120 = 0.5 秒,一小节 4 拍 = 2 秒,一分钟 120 拍 / 30 小节。把 nextBeatTime 从 0 开始按拍长累加,得到的时间轴是:

再看调度侧的账:拍长 500 毫秒,100 毫秒的前瞻只覆盖 0.20 拍,也就是说每次回调最多排 0 到 1 个拍点,绝大多数回调其实什么也不排——这很正常,前瞻窗口小恰恰是它抗抖动的办法:窗口越小,排出去的时间点离「现在」越近,用户点停止时的回落也越干脆。75 毫秒的安全余量占拍长的 15%,意味着 120 BPM 下定时器要连续迟到超过 75 毫秒才可能漏拍。

把速度拉到区间上限 300 BPM:拍长缩到 200 毫秒,100 毫秒前瞻覆盖 0.50 拍,75 毫秒余量占拍长 38%。此时 50 毫秒的包络衰减仍有 4 倍余量,两拍之间能完整塞下 4 个包络,不会前后粘连。以上数值均可在下方对照表里逐行核对。

参数与取值对照表

第一张表是四个可控参数的作用与取值范围,第二张表是 BPM 到拍间隔的换算,也是页面结果区「加载更多」里那张表的完整版。

参数默认值范围作用
setInterval 周期25 ms固定前瞻窗口的刷新频率;越小越抗长迟到,但回调次数越多
前瞻窗口 scheduleAheadTime0.1 s固定每次向前排程的时间跨度;与 25 ms 之差即 75 ms 安全余量
BPM12030–300每分钟拍数,拍长 60 / bpm 秒
每小节拍数41–16重音在每小节第 1 拍触发
重音频率倍数21–4重音拍频率 = 880 Hz × 该倍数
音量0.60–1包络峰值,重音拍另乘 1.0、弱拍乘 0.7
包络衰减50 ms固定必须短于最短拍长,300 BPM 下仍留 4 倍余量
BPM拍间隔每分钟拍数100ms 前瞻覆盖75ms 余量占拍长
302000.0 ms300.05 拍4%
601000.0 ms600.10 拍8%
90666.7 ms900.15 拍11%
120500.0 ms1200.20 拍15%
160375.0 ms1600.27 拍20%
200300.0 ms2000.33 拍25%
240250.0 ms2400.40 拍30%
300200.0 ms3000.50 拍38%

边界与精度

常见问题

为什么不直接 setInterval(500) 每 500 毫秒响一下?

因为那 500 毫秒是「至少 500 毫秒」而不是「恰好 500 毫秒」。回调要排队,主线程一忙就晚;每次晚了多少会被下一次累加,一分钟下来能飘出几百毫秒。而把发声交给 oscillator.start(t),时间戳是绝对的,回调迟到与否都不影响发声时刻。

点「开始」没有声音怎么办?

先确认音量不是 0,再看浏览器是否拦截了音频。现代浏览器要求 AudioContext 必须在用户手势里 resume(),冷启动自动播放会被拒绝。本页的点「开始」就是手势触发点;如果仍无声,检查系统是否把该页签静音,或换一个前台页签再试。

切到别的页签再回来,节拍对不上或者断了,是 bug 吗?

不是。隐藏页签里浏览器把定时器夹到最长 1000 毫秒一次,而前瞻只有 100 毫秒,中间的空白拍没人排程,自然就断了。回到前台后本页会把 nextBeatTime 重新对齐到当前音频时间,从当前小节继续,不会把欠下的拍一次性补发出来。

重音听起来只是变高了,能不能换成别的音色?

能,改 OscillatorNode 的 type 即可:square 更接近传统节拍器的电子音,triangle 更柔和,sine 更干净。本页用同一套振荡器、只乘一个频率倍数(默认 2 倍即 1760 Hz),是为了让重音和弱拍的音长、包络完全一致,只有音高不同,练习时更容易分辨。

为什么会有轻微的「咔」声?

那是增益突变造成的爆音。给 gain.value 直接赋非零值,波形会从 0 跳到峰值再跳回 0,形成了两个瞬态。正确做法是用 exponentialRampToValueAtTime 从 0.0001 爬升到峰值再衰减——本页用的是 2 毫秒起音、50 毫秒衰减,0.0001 这个起点是因为指数斜坡不能以 0 为目标。

BPM 300 会不会来不及处理?

不会。300 BPM 下拍长 200 毫秒,而前瞻每 25 毫秒扫一次窗口,100 毫秒窗口最多覆盖 0.5 拍,也就是每次回调最多创建 1 个振荡器节点;一分钟 300 个节点,对浏览器是极轻的负载。这也是小前瞻窗口的另一个好处:节点是随用随建随销毁的,不会攒队列。

「试算」和「开始」有什么区别?

「试算」只做算术:拍间隔、每小节时长、每分钟拍数、前瞻覆盖率,并刷新对照表,全程不创建 AudioContext。在输入框里按 Enter 触发的也是它。「开始」才会创建音频上下文、resume() 并启动前瞻调度。

输入的内容会传到服务器吗?

不会。这个在线节拍器 的排程、发声和换算都在当前页面的脚本里完成,全程没有网络请求,也不写浏览器存储。打开开发者工具的 Network 面板边点边看,不会有新请求发出。

相关工具