BGMの切り替えをスムーズに:クロスフェードの実装
遊びをつくる執筆にClaudeを使用しています。

ゲームのBGMを場面に合わせて切り替えるとき、音が唐突に止まって次の曲が始まると、プレイヤーの没入感が一気に崩れます。これを防ぐ手法が「クロスフェード」です。片方の音量を徐々に下げながら、もう片方の音量を徐々に上げることで、二つのBGMを重ねて自然につなぎます。 Web Audio APIを使えば、JavaScriptだけでこの処理を実装できます。この記事では、クロスフェードが必要になる場面の判断基準から、実際に動くコードの構造と、陥りやすい落とし穴まで順番に説明します。音量フェードとの違いや、ループBGMへの対応方法も合わせて押さえておくと、実装の手戻りが減ります。
クロスフェードが必要な場面と不要な場面
すべてのBGM切り替えにクロスフェードが必要なわけではありません。どちらを選ぶかは、場面転換の性質によって変わります。
クロスフェードが有効な場面は、場面が「溶け合う」ように変わるときです。フィールドマップからボス戦に入るタイミング、昼から夜への時間経過、感情的な山場への移行などがこれに当たります。二つの状態の間に明確な区切りがなく、音楽が変化そのものを演出する場合に向いています。
単純なフェードアウト→フェードインで足りる場面は、画面転換やロード画面を挟む場合です。暗転中に完全に無音になることが許容されるなら、クロスフェードの複雑さを持ち込む必要はありません。実装コストに見合うかどうかを先に判断してください。
即時切り替えが正解の場面もあります。ゲームオーバー時のブザー音直後など、インパクトを意図的に演出したいときは、フェードをかけると逆効果になります。
Web Audio APIの基本構造を押さえる
クロスフェードを実装するには、Web Audio APIの「GainNode(ゲインノード)」を経由して音量を制御します。<audio> タグの volume プロパティを直接変えることもできますが、タイミング制御の精度やスムーズさでGainNodeの方が優れています。
基本的な接続構造は次のとおりです。
AudioBufferSourceNode → GainNode → AudioContext.destinationAudioBufferSourceNode が音の再生源、GainNode が音量の蛇口、destination がスピーカー出力です。クロスフェードでは、新旧2本のこの経路を同時に動かし、片方のGainNodeを0→1、もう片方を1→0へと変化させます。
GainNode の音量をなめらかに変えるには、linearRampToValueAtTime または exponentialRampToValueAtTime を使います。前者は線形変化(等速で音量が変わる)、後者は指数変化(人の聴覚に近いなめらかな変化)です。BGMのクロスフェードには指数変化の方が自然に聴こえることが多いですが、0に向かう指数変化は0に到達できないため、目標値に 0.001 などの極小値を使う必要があります。
クロスフェードの実装手順
以下は、二つのBGMをクロスフェードさせる処理の骨格です。実際のプロジェクトに合わせて関数名や変数名を調整してください。
1. AudioContextとGainNodeを準備する
const audioCtx = new AudioContext();
function createTrack(buffer) {
const source = audioCtx.createBufferSource();
const gain = audioCtx.createGain();
source.buffer = buffer;
source.loop = true;
source.connect(gain);
gain.connect(audioCtx.destination);
return { source, gain };
}AudioContext はユーザー操作(クリックやキー入力)の後に生成するか、resume() を呼ぶ必要があります。ブラウザの自動再生制限により、操作前に音を出すことができないためです。
2. クロスフェード関数を作る
let currentTrack = null;
function crossfadeTo(nextBuffer, duration = 2.0) {
const now = audioCtx.currentTime;
const next = createTrack(nextBuffer);
// 新しいトラックを無音から始める
next.gain.gain.setValueAtTime(0.001, now);
next.gain.gain.exponentialRampToValueAtTime(1.0, now + duration);
next.source.start(now);
// 現在のトラックをフェードアウトして停止
if (currentTrack) {
const old = currentTrack;
old.gain.gain.setValueAtTime(old.gain.gain.value, now);
old.gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
old.source.stop(now + duration);
}
currentTrack = next;
}duration に渡す秒数がクロスフェードの長さです。BGMの雰囲気や場面転換のスピードに合わせて、0.5〜3秒の範囲で調整するのが一般的です。
3. 音声データを事前にデコードしておく
async function loadAudio(url) {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
return audioCtx.decodeAudioData(arrayBuffer);
}decodeAudioData は非同期処理です。場面切り替えのタイミングで初めてロードを始めると、デコード完了までにタイムラグが生じます。BGMファイルはゲーム起動時か、その場面に入る前のロード画面でまとめてデコードしておくと、クロスフェードのタイミングが安定します。
落とし穴と対処法
同じ曲への切り替えに注意する。プレイヤーが同じエリアに戻るなどして crossfadeTo が同じバッファで呼ばれると、同一の曲が二重に再生されます。切り替え前に「現在再生中のバッファと同じかどうか」を比較し、同じなら処理をスキップするガードを入れてください。
フェード中の再切り替えに注意する。フェードアウト中にさらに別の曲へ切り替えが発生すると、古いトラックの stop() スケジュールと新しい操作が衝突することがあります。フェード中フラグを持つか、前の stop() をキャンセルして改めてフェードを組み直す処理を検討してください。
モバイルでの音量が小さく聴こえる問題。複数の GainNode を同時に最大音量で動かすと、合算でクリッピング(音割れ)が発生する場合があります。クロスフェード中の音量の合計が1.0を超えないよう、MasterGainNode をひとつ設けて全体の音量をコントロールする構成が安心です。
AudioBufferSourceNode は使い捨て。AudioBufferSourceNode は一度 start() したら、同じインスタンスを再利用できません。曲を再開するたびに createBufferSource() で新しいノードを作り直す必要があります。
ループBGMのタイミングを揃える
ループするBGMをクロスフェードで切り替える場合、新しい曲の再生開始位置がずれると、ビートやフレーズの頭がうまく合わない場合があります。曲の拍数やテンポが決まっているなら、audioCtx.currentTime と曲のBPMから次の小節の先頭を計算して start() に渡す方法があります。
// 120BPMの曲で、4拍1小節のとき
const bpm = 120;
const beatsPerBar = 4;
const secPerBeat = 60 / bpm;
const secPerBar = secPerBeat * beatsPerBar;
const now = audioCtx.currentTime;
const nextBarStart = Math.ceil(now / secPerBar) * secPerBar;
next.source.start(nextBarStart);この場合、フェードの開始タイミングも nextBarStart に合わせることで、音楽的に自然なクロスフェードになります。すべての状況で必要なわけではありませんが、リズム重視のゲームでは効果的です。
次の行動
上記の crossfadeTo 関数を、まず2〜3秒のループBGM2本で試してみてください。duration を変えながら再生して、ゲームの雰囲気に合う長さを耳で確認するのが最短の調整方法です。実装したゲームはHTMLファイルやZIPにまとめてアソバースに投稿し、他のプレイヤーの反応から音の印象を確かめることもできます。
参考資料
- MDN Web Docs「Web Audio API」
- MDN Web Docs「AudioContext」「GainNode」「AudioBufferSourceNode」

