使用方法
- 四个输入框默认是 120 BPM、每小节 4 拍、重音频率倍数 2、音量 0.6,正好是一段最常见的 4/4 练习速度。
- 点「开始」,音频上下文会被创建并在用户手势里
resume(),第一拍从点击后约 60 毫秒处起算;按钮随即变成「停止」。 - 圆点行显示当前小节内的第几拍,重音拍用琥珀色标出,其余拍用深色;它只是一块显示面板,和调度队列之间隔着一条「按音频时间延迟显示」的通道,所以视觉上会略滞后于声音。
- 「试算」不发出声音,只把拍间隔、每小节时长、每分钟拍数、100 毫秒前瞻能覆盖多少拍算出来,并在下方对照表里列出 30 到 300 BPM 的完整换算;输入框里按 Enter 等价于点「试算」。
- 对照表默认显示 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 开始按拍长累加,得到的时间轴是:
- 第 1 拍(小节重音,1760 Hz)
t = 0.000 s,小节 1 的第 1 拍 - 第 2 拍(880 Hz)
t = 0.500 s - 第 3 拍(880 Hz)
t = 1.000 s - 第 4 拍(880 Hz)
t = 1.500 s - 第 5 拍(下一小节重音)
t = 2.000 s,小节 2 的第 2 拍计数归零
再看调度侧的账:拍长 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 | 固定 | 前瞻窗口的刷新频率;越小越抗长迟到,但回调次数越多 |
| 前瞻窗口 scheduleAheadTime | 0.1 s | 固定 | 每次向前排程的时间跨度;与 25 ms 之差即 75 ms 安全余量 |
| BPM | 120 | 30–300 | 每分钟拍数,拍长 60 / bpm 秒 |
| 每小节拍数 | 4 | 1–16 | 重音在每小节第 1 拍触发 |
| 重音频率倍数 | 2 | 1–4 | 重音拍频率 = 880 Hz × 该倍数 |
| 音量 | 0.6 | 0–1 | 包络峰值,重音拍另乘 1.0、弱拍乘 0.7 |
| 包络衰减 | 50 ms | 固定 | 必须短于最短拍长,300 BPM 下仍留 4 倍余量 |
| BPM | 拍间隔 | 每分钟拍数 | 100ms 前瞻覆盖 | 75ms 余量占拍长 |
|---|---|---|---|---|
| 30 | 2000.0 ms | 30 | 0.05 拍 | 4% |
| 60 | 1000.0 ms | 60 | 0.10 拍 | 8% |
| 90 | 666.7 ms | 90 | 0.15 拍 | 11% |
| 120 | 500.0 ms | 120 | 0.20 拍 | 15% |
| 160 | 375.0 ms | 160 | 0.27 拍 | 20% |
| 200 | 300.0 ms | 200 | 0.33 拍 | 25% |
| 240 | 250.0 ms | 240 | 0.40 拍 | 30% |
| 300 | 200.0 ms | 300 | 0.50 拍 | 38% |
边界与精度
- 音频时钟的分辨率是「渲染量子」,不是无限小。Web Audio 每次处理 128 个采样,48000 Hz 下等于 2.67 毫秒一个量子,44100 Hz 下是 2.90 毫秒。也就是说 100 毫秒的前瞻窗口里只有 37.5 个量子,你给的时间点会被吸附到量子边界上,节拍的理论抖动下限就在这个量级——比人耳能分辨的 10 毫秒左右小得多,所以听不出来。
- 定时器抖动不会进到声音里,但会决定会不会漏拍。假设
setInterval(25)每次回调迟到 10 毫秒,一分钟 2400 次回调累计漂移 24 秒;这个漂移只影响「什么时候把音符放进队列」,不影响音符本身的时间戳。真正要守的只有一条:单次迟到必须小于 75 毫秒。 - 页签进后台会断拍,原因在位不在音。隐藏页签里 Chrome 把定时器夹到最长 1000 毫秒一次;1000 毫秒的唤醒间隔对着 100 毫秒的前瞻,中间有 900 毫秒的排程空白,120 BPM 下就是 1.8 拍没有音符被排进去。再叠加「隐藏满 5 分钟、页面 30 秒没出过声」之后的进阶节流,唤醒被压到每分钟一次,空白扩大到 59.9 秒。已经进入音频线程的音符仍然准点发声,音频线程不受这套节流管——断的只是「后续排程」。
- 停止要连音频线程一起停。点「停止」时必须先
clearInterval断掉前瞻,再把当前已排但尚未发声的节点逐个stop()。只清定时器的话,那 100 毫秒窗口里已经排好的音符还会响完,听感上是「按了停止又响了一下」。 - BPM 区间为什么是 30–300。300 BPM 时拍长 200 毫秒,已经是多数人能清晰分辨并跟上的上限;再快人耳会把相邻两拍并成一个音,包络的 50 毫秒衰减也会开始互相盖住。30 BPM 时拍长 2 秒,再慢就失去了「持续给出时间参考」的意义,变成单次提示音,那属于另一类工具。
- 数值都是按公式算出来的,不是估的。拍间隔 = 60000 ÷ BPM;小节时长 = 拍间隔 × 每小节拍数 ÷ 1000;前瞻覆盖率 = 100 ÷ 拍间隔。对照表里 90 BPM 的 666.7 毫秒就是 60000 ÷ 90,保留一位小数后仍略有截断,所以要精确到毫秒请以页面结果区的实时计算为准。
常见问题
为什么不直接 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 面板边点边看,不会有新请求发出。