0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js × Web Audio API で「ズレない」メトロノームを作る【サンプル精度タイマーとWeb Workerの実装】

0
Posted at

Web アプリケーションでメトロノームやリズム同期ツールを開発する際、多くのエンジニアが「音が微妙にズレる」「バックグラウンドで再生するとリズムが狂う」という問題に直面します。

結論から言うと、setInterval や setTimeout などの JavaScript タイマーは UI スレッド(メインスレッド)で動作するため、高度なリズム精度を必要とする音楽アプリには適していません。

本記事では、Web Audio API のハードウェアクロックと**Lookahead Scheduler(先行スケジューリング)**を組み合わせ、さらに Web Worker を活用することで、人間には感知できないレベルまでタイマー誤差を抑えた「実質的にズレない」サンプル精度の Web メトロノームを Next.js / TypeScript で実装する方法を解説します。


1. なぜ setInterval や setTimeout はメトロノームに使えないのか?

JavaScript のタイマー関数は、ブラウザのイベントループ上で実行されます。これには 3 つの構造的な問題があります。

① メインスレッドのブロック(Main Thread Contention)

DOM の再描画、React のコンポーネント再レンダリング、重い計算処理、ガベージコレクション(GC)が発生すると、メインスレッドのタスクキューが詰まり、タイマーのコールバック実行が数ミリ秒〜数十ミリ秒遅延します。

② イベントループのジッター(Timer Jitter)

setInterval(fn, 500) は「500ミリ秒ごとに実行する」のではなく「最短で 500ミリ秒後にキューに追加する」仕様です。そのため、ミリ秒単位の正確な周期(Inter-Onset Interval)を維持できません。

③ バックグラウンドタブの制限(Background Throttling)

近年のブラウザ(Chrome, Safari, Firefox)は省電力化のため、非アクティブタブの JavaScript タイマーを強力に制限します。

  • Desktop Chrome / Firefox: タイマー実行頻度を 1Hz(1秒に1回)以下にクランプ。
  • iOS Safari: タブがバックグラウンドに移動するとイベントループ自体を停止。

120 BPM(500ms 周期)のメトロノームを setInterval で動かしている場合、バックグラウンドに移動した瞬間に 60 BPM(1000ms 周期)に落ちたり、完全に停止してしまいます。


2. 解決策:Lookahead Scheduler パターン(デュアルクロック戦略)

この問題を解決するのが、Google の Web Audio API チームが提唱した Lookahead Scheduler(先行スケジューリング) 手法です。

2 つのクロックの使い分け

クロック種類 動作スレッド 特徴 用途
JS Main Clock UI Thread / Worker 精度は低いが柔軟 (setInterval / Web Worker) 未来のイベントを監視・キューイング
Audio Clock Audio Thread (DAC) ハードウェア依存で完全なサンプル精度 (audioCtx.currentTime) 実際の音声ノードの発声時刻指定
       [ AudioContext.currentTime (Hardware Audio Timeline) ]
-------------------------------------------------------------------->
                  |<- Lookahead Window (e.g. 100ms) ->|
   Current Time --^                                   ^-- Max Scheduled Time
                     [ Schedule Audio Nodes Here ]
スケジューリングの仕組み
ルックアヘッドタイマー(例: 25ms 周期)が短い頻度で起動。

AudioContext.currentTime から「未来の一定ウィンドウ内(例: 100ms 以内)」に鳴るべきビートがあるかチェック。

条件に合うビートがあれば、oscillator.start(exactTime) を使ってオーディオスレッド側に未来の発音時刻を予約。

オーディオスレッドはメインスレッドがブロックされていても、指定された正確な時刻(ミリ秒未満の精度)で音を再生。

3. 音声クリックノードの実装(ポップノイズ対策)
オーディオを鳴らす際、単に Wave を即座に停止させると波形が途切れ、「プチッ」というデジタルポップノイズ(クリックノイズ)が発生します。これを防ぐためにエンベロープ(GainNode の傾斜)を適用します。

Code snippet
export const scheduleClickSound = (
  audioCtx: AudioContext, 
  time: number, 
  isAccent: boolean = false
): void => {
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();

  // アクセント音(強拍)と通常音(弱拍)の周波数を変える
  osc.type = 'sine';
  osc.frequency.setValueAtTime(isAccent ? 1200 : 800, time);

  // ポップノイズを防ぐアタック・リリース・エンベロープ
  gain.gain.setValueAtTime(0, time);
  gain.gain.linearRampToValueAtTime(1, time + 0.002); // 2ms アタック
  gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); // 50ms リリリース

  osc.connect(gain);
  gain.connect(audioCtx.destination);

  osc.start(time);
  osc.stop(time + 0.06); // 音が減衰した後に停止
};
4. Web Worker の役割(スレッド分離とスロットリング対策)
タイマー処理を Web Worker に委譲することで、メインスレッドの UI レンダリングやガベージコレクションの影響からタイマーを切り離すことができます。

なお、Web Worker 自体もバックグラウンドタブにおいてブラウザのリソース制限を受ける場合がありますが、Lookahead Scheduler と組み合わせることで「タイマーの呼び出しタイミングの揺らぎ」をオーディオスレッド側で完全に補正できます。

Code snippet
let timerId: number | null = null;
const LOOKAHEAD_MS = 25;

self.onmessage = (e: MessageEvent) => {
  const { action } = e.data;

  if (action === 'start') {
    if (timerId !== null) clearInterval(timerId);
    timerId = self.setInterval(() => {
      self.postMessage('tick');
    }, LOOKAHEAD_MS) as unknown as number;
  } else if (action === 'stop') {
    if (timerId !== null) {
      clearInterval(timerId);
      timerId = null;
    }
  }
};

export {};
5. Next.js / React 用 カスタム Hook の実装
React の状態管理とタイマーの実行を完全に切り離し、型安全なコードで実装したカスタム Hook です。モバイル(iOS Safari)の Auto-play 制限解除や重複実行のガード処理も考慮しています。

Note: import.meta.url を使用した Worker の読み込みは、Next.js (App Router / Pages Router) や現代の Webpack 5 / Vite 環境で標準サポートされています。

Code snippet
import { useRef, useCallback, useEffect, useState } from 'react';
import { scheduleClickSound } from '../utils/audioEngine';

interface UseMetronomeReturn {
  isPlaying: boolean;
  bpm: number;
  setBpm: (bpm: number) => void;
  startMetronome: () => Promise<void>;
  stopMetronome: () => void;
}

export const useMetronome = (initialBpm: number = 120): UseMetronomeReturn => {
  const [isPlaying, setIsPlaying] = useState<boolean>(false);
  const [bpm, setBpmState] = useState<number>(initialBpm);

  const audioCtxRef = useRef<AudioContext null |>(null);
  const workerRef = useRef<Worker null |>(null);
  
  const bpmRef = useRef<number>(initialBpm);
  const nextNoteTimeRef = useRef<number>(0);
  const currentBeatRef = useRef<number>(0);
  const schedulerRef = useRef<() => void>(() => {});

  const SCHEDULE_AHEAD_TIME = 0.1; // 100ms 先まで先行予約

  // BPM の最新値を Ref に保持(クロージャー問題の回避)
  const setBpm = useCallback((newBpm: number) => {
    setBpmState(newBpm);
    bpmRef.current = newBpm;
  }, []);

  // スケジューラ関数を Ref で更新(Worker 再初期化の防止)
  schedulerRef.current = () => {
    if (!audioCtxRef.current) return;

    const currentTime = audioCtxRef.current.currentTime;

    while (nextNoteTimeRef.current < currentTime + SCHEDULE_AHEAD_TIME) {
      const isAccent = currentBeatRef.current % 4 === 0;
      scheduleClickSound(audioCtxRef.current, nextNoteTimeRef.current, isAccent);

      // 次のビートの時刻を計算
      const secondsPerBeat = 60.0 / bpmRef.current;
      nextNoteTimeRef.current += secondsPerBeat;
      currentBeatRef.current++;
    }
  };

  // Worker の初期化とクリーンアップ(依存配列を空にして 1 度だけ実行)
  useEffect(() => {
    workerRef.current = new Worker(
      new URL('../workers/metronome.worker.ts', import.meta.url)
    );

    workerRef.current.onmessage = () => {
      schedulerRef.current();
    };

    return () => {
      workerRef.current?.terminate();
      if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') {
        audioCtxRef.current.close();
      }
    };
  }, []);

  // メトロノーム開始(iOS Safari の Auto-play 制約解除を含む)
  const startMetronome = useCallback(async () => {
    if (isPlaying) return; // 重複起動のガード

    if (!audioCtxRef.current) {
      const AudioContextClass = (window as any).AudioContext || (window as any).webkitAudioContext || AudioContext;
      audioCtxRef.current = new AudioContextClass({ latencyHint: 'interactive' } as AudioContextOptions);
    }

    // サスペンド状態の解除(ユーザー操作イベント内で実行必須)
    if (audioCtxRef.current.state === 'suspended') {
      await audioCtxRef.current.resume();
    }

    currentBeatRef.current = 0;
    nextNoteTimeRef.current = audioCtxRef.current.currentTime + 0.05; // 50ms のバッファ

    workerRef.current?.postMessage({ action: 'start' });
    setIsPlaying(true);
  }, [isPlaying]);

  // メトロノーム停止
  const stopMetronome = useCallback(() => {
    workerRef.current?.postMessage({ action: 'stop' });
    setIsPlaying(false);
  }, []);

  return { isPlaying, bpm, setBpm, startMetronome, stopMetronome };
};
6. コンポーネントへの組み込み例
Code snippet
import React from 'react';
import { useMetronome } from '../hooks/useMetronome';

export const Metronome: React.FC = () => {
  const { isPlaying, bpm, setBpm, startMetronome, stopMetronome } = useMetronome(120);

  return (
    <div style={{ padding: '20px', textAlign: 'center' }}>
      <h2>高精度 Web Audio メトロノーム</h2>
      <div style={{ margin: '20px 0' }}>
        <label>BPM: {bpm}</label>
        <input
          type="range"
          min="40"
          max="240"
          value={bpm}
          onChange={(e) => setBpm(Number(e.target.value))}
          style={{ marginLeft: '10px' }}
        />
      </div>
      <button 
        onClick={isPlaying ? stopMetronome : startMetronome}
        style={{ padding: '10px 20px', fontSize: '16px', cursor: 'pointer' }}
      >
        {isPlaying ? 'STOP' : 'START'}
      </button>
    </div>
  );
};
7. まとめ & パフォーマンス最適化の要点
JavaScript タイマー単体に頼らない: リズム計算と発声は必ず AudioContext.currentTime を基準にする。

Lookahead スケジューリング: 25ms 周期のルックアヘッドで 100ms 先のオーディオノードを先行予約する。

Web Worker の活用: タイマー処理をワーカーへ移動させ、メインスレッドの UI レンダリング負荷から独立させる。

React State との完全分離: 高頻度なスケジューラ内で setState を呼び出さず、useRef ベースで値を参照することで React の無駄な再レンダリングを回避する。

ライブデモ & 実装例
今回紹介したサンプル精度スケジューリングロジックや、BPM 計算・ミリ秒変換などの機能を実装したリアルタイム Web Audio ツールを公開しています。実際の精度や操作感の検証用にチェックしてみてください。

TheTapTempo - Live Web Audio & BPM Suite

技術的な質問や改善案があれば、ぜひコメント欄でお知らせください。
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?