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
技術的な質問や改善案があれば、ぜひコメント欄でお知らせください。