はじめに
モールス信号・メトロノーム・音ゲーなど、正確なタイミング が命の音声再生では、setTimeout で1音ずつ鳴らすとイベントループのオーバーヘッドが蓄積してタイミングがズレます。長い信号ほどドリフトが顕著になり、最終的にモールス信号として成立しなくなる、というのはよくある落とし穴です。
Web Audio API の AudioContext.currentTime を使うと、全ての音を一括で事前スケジュール できます。JSイベントループに依存せず、サンプル精度でタイミングが保証されます。
こんな場面で使えます
- モールス信号・メトロノーム・リズムゲームの音声再生
- 正確な間隔で複数の音を連続再生したい場面
- BGM や効果音を指定時刻に確実に再生したい場面
- 音と画面演出(点滅等)を正確に同期させたい場面
実装コード
核心:AudioContext.currentTime で全音符を一括事前スケジュール
function play(text, { onDone } = {}) {
ensureCtx();
const dot = 1.2 / wpm;
const RAMP = Math.min(0.008, dot * 0.1);
const now = _ctx.currentTime + 0.03; // 30ms バッファ
let t = now;
for (const char of text.toUpperCase()) {
const pattern = MORSE_CODE[char];
if (!pattern) { t += dot * 7; continue; }
for (const sym of pattern) {
const dur = sym === '-' ? dot * 3 : dot;
// ★ AudioContext時刻で直接スケジュール
const osc = _ctx.createOscillator();
const gain = _ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, t);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(volume, t + RAMP);
gain.gain.setValueAtTime(volume, t + dur - RAMP);
gain.gain.linearRampToValueAtTime(0, t + dur);
osc.connect(gain);
gain.connect(_ctx.destination);
osc.start(t); // JSループに依存せず確実な時刻に開始
osc.stop(t + dur);
scheduledOscs.push(osc); // stop() でキャンセルするために保持
// UIコールバックはsetTimeoutで近似値でよい
const delayOn = Math.max(0, (t - _ctx.currentTime) * 1000);
const delayOff = Math.max(0, (t + dur - _ctx.currentTime) * 1000);
signalTimeouts.push(setTimeout(() => onSignalOn?.(), delayOn));
signalTimeouts.push(setTimeout(() => onSignalOff?.(), delayOff));
t += dur + dot;
}
t += dot * 2;
}
}
停止:スケジュール済みノードを配列で管理
function stop() {
scheduledOscs.forEach(o => { try { o.stop(); } catch(_) {} });
signalTimeouts.forEach(id => clearTimeout(id));
scheduledOscs = [];
signalTimeouts = [];
onSignalOff?.();
}
クリックノイズ対策:ランプ処理
const RAMP = Math.min(0.008, dot * 0.1); // 最大8ms or 符号の10%
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(volume, t + RAMP); // 立ち上がり
gain.gain.setValueAtTime(volume, t + dur - RAMP);
gain.gain.linearRampToValueAtTime(0, t + dur); // 立ち下がり
突然 0→1→0 と切り替えると「プチッ」とクリックノイズが出るので、linearRampToValueAtTime でなだらかに立ち上げ/立ち下げします。
AudioContext の遅延起動(Autoplay Policy対応)
function ensureCtx() {
if (!_ctx) _ctx = new (window.AudioContext || window.webkitAudioContext)();
if (_ctx.state === 'suspended') _ctx.resume();
}
ユーザー操作イベント(クリック等)内で初めて呼ぶことで、ブラウザのAutoplay Policyを回避します。
setTimeout vs AudioContext 事前スケジュール
| 比較 | setTimeout方式 | AudioContext事前スケジュール |
|------|---------------|------------------------------|
| タイミング精度 | ±数十msのドリフト蓄積 | サンプル精度(±0ms) |
| 停止のしやすさ | clearTimeoutで不完全 | osc.stop() で確実 |
| 長い系列 | ずれが顕著 | ずれない |
| UI更新 | 同じsetTimeoutで可 | 別途setTimeout(近似値でOK) |
注意点・ハマりポイント
- 30msバッファ:
now = currentTime + 0.03のバッファがないと、AudioContextの処理待ちで最初の音が欠けることがあります - UI更新はsetTimeoutでOK: インジケーター点灯は多少ズレても視覚的には気にならないので、JSタイマーで十分
scheduledOscsの配列保持:stop()時に全ノードをキャンセルするため必須です
実際の活用事例
このテクニックは、モールス信号学習アプリ「KochSprint モールス道場」(GitHub)で実際に使用しています。E/T から 0-9 まで全38文字のモールス信号をWPM 5〜40で再生しており、長い符号列でも一切ドリフトしません。
まとめ
AudioContext.currentTimeによる 事前スケジュール で、サンプル精度のタイミング制御が可能linearRampToValueAtTimeで クリックノイズを除去ensureCtx()でAutoplay Policyを回避、scheduledOscs配列で停止を確実化
