Softex CelwareTech Blog
バニラJS Webアプリ2026-04-17

Web Audio APIで精密タイミング制御(事前スケジューリングでドリフト根絶)

setTimeoutのドリフトを根絶。AudioContext.currentTimeで全音符を一括事前スケジュールする精密タイミングパターン。

バニラJSWeb Audio APIAudioContextタイミング制御モールス信号

はじめに

モールス信号・メトロノーム・音ゲーなど、正確なタイミング が命の音声再生では、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 配列で停止を確実化

この技術で業務改善しませんか?

Excel VBA・GAS・Webアプリで業務の自動化ツールを開発しています。 「こんなことできる?」というご相談だけでもお気軽にどうぞ。

無料相談はこちら →