3Dモデルで電車が走るようになり、見た目のリアリティはかなり向上しました。しかし、実際にプレイしてみると何かが足りない。そうです、「音」です。
モーターの唸り、レールのジョイント音、ブレーキの緩解音……。これらがあるだけで没入感が段違いになります。今回は、シミュレーターに音声を実装した際のアプローチと、そのために必要だった「車両の状態(State)管理」について書き残しておきます。
なぜState管理が必要なのか?
最初は単純に「速度が出たら走行音を鳴らす」だけで良いと思っていました。しかし、鉄道模型や実車の挙動を考えると、それだけでは不十分だということに気づきます。
- 加速中(インバータ音が鳴る)
- 惰性走行中(ジョイント音だけが響く)
- 減速中(回生ブレーキの音がする)
- 停車中(エアコンやコンプレッサーの音)
このように、「同じ速度」であっても、状況によって鳴らすべき音が異なります。
そこで、車両が今どういう状態にあるかを判定する簡易的なステートマシンを導入することにしました。
状態判定のロジック
今回は、現在の速度 (`currentSpeedKmh`) と、前フレームとの速度差 (`speedDiff`)、そしてマスコンの入力値 (`currentNotch`) を監視して状態を決定しています。
// 0.5秒ごとに状態をチェック
setInterval(() => {
let newState = '停車';
const speedDiff = currentSpeedKmh - prevSpeedKmh;
if (Math.abs(currentSpeedKmh) < 0.1) {
newState = '停車';
} else if (speedDiff > 0.1) {
newState = '加速';
} else if (speedDiff < -0.1) {
newState = '減速';
if (currentNotch < 0) newState = 'ブレーキ'; // 機械ブレーキ使用時
} else {
newState = '速度維持'; // 惰性走行
}
if (newState !== state) {
changeState(newState); // 状態が変わった時だけ音声を切り替える
}
prevSpeedKmh = currentSpeedKmh;
}, 500);
自然な音の切り替え(クロスフェード)
状態が変わった瞬間に音をバツンと切り替えると、非常に違和感があります。そこで、setInterval を使った簡易的なフェードイン・フェードアウト関数を用意しました。
Web Audio APIを使えばもっと高度なミキシングができますが、今回は手軽に実装するため、HTMLAudioElementの volume プロパティを操作する方法を採用しています。
// 音声のフェードアウト処理
function fadeOut(audio, duration = 1000) {
const step = 0.05;
const interval = duration * step;
const fade = setInterval(() => {
if (audio.volume > step) {
audio.volume -= step;
} else {
audio.volume = 0;
audio.pause();
clearInterval(fade);
}
}, interval);
}
この関数を使って、「加速」状態に入ったら、それまで鳴っていた「停車音」や「減速音」をフェードアウトさせつつ、同時に「加速音」をフェードインさせます。これにより、複数の音が混ざり合いながら滑らかに推移する表現が可能になりました。
速度に合わせた再生位置の調整
個人的にこだわったのが、「すでにスピードが出ている状態で加速音を再生し始める場合」の処理です。
例えば、時速60km/hで惰性走行中に再加速する場合、インバータ音の「出だし(低音)」から再生されるとおかしいですよね。そこで、現在の速度に応じて音声ファイルの再生開始位置(currentTime)を動的に決定するようにしました。
case '加速':
fadeOutAllExcept('start');
// 速度(0~180km/h)の割合に応じて、音声ファイル(27秒)のどこから再生するか決定
let startPos = Math.floor(26 * (currentSpeedKmh / 180));
fadeIn(sounds.start, 500, startPos);
break;
地味な処理ですが、これのおかげで運転操作に対する音の追従性がグッと上がり、気持ちの良い操作感につながりました。
モバイル端末での音声再生の壁
PC上では問題なく動作した音声システムですが、スマートフォンでは大きな壁にぶつかりました。iOSのSafariをはじめとするモバイルブラウザでは、ユーザーの明示的な操作(タップ等)なしに音声を自動再生することが禁止されています。
これはWeb Audio APIの AudioContext にも適用されるため、ページ読み込み時に音声を初期化しても、実際にはミュート状態のままになります。
解決策として、シミュレーターの「スタート」ボタンのタップイベント内で AudioContext.resume() を呼び出す処理を入れました。これにより、ユーザーが操作を開始した時点で音声コンテキストがアクティブになり、以降は通常どおり音声が再生されるようになります。
// スタートボタンのタップで音声コンテキストを起動
document.getElementById('start-btn').addEventListener('click', () => {
if (audioContext.state === 'suspended') {
audioContext.resume();
}
// ゲーム開始処理...
});
HTMLAudioElementとWeb Audio APIの使い分け
今回、走行音には HTMLAudioElement(= <audio> タグ相当)を使い、効果音(ドアの開閉音、発車メロディ等)にはWeb Audio APIを使うというハイブリッド構成を採用しました。
HTMLAudioElementはシンプルで扱いやすい反面、複数の音声を精密にタイミング制御するのが苦手です。一方、Web Audio APIは高機能ですが、長時間の音声ファイルをメモリに展開するとモバイル端末でメモリ不足になるリスクがあります。
走行音はループ再生が前提で、ファイルサイズも大きい(30秒〜1分程度)ため、ストリーミング再生が可能なHTMLAudioElementが適しています。効果音は短い音声を即座に鳴らす必要があるため、バッファに事前読み込みするWeb Audio APIが適しています。
音声素材の調達と加工
リアルな音声体験を実現するうえで、素材選びも重要です。今回使用した走行音は、実際に山手線の車内で録音した環境音をベースに加工しています。
録音した生データには車内アナウンスや乗客の会話が含まれるため、イコライザーで人声の帯域(300Hz〜3kHz付近)を減衰させ、モーター音やレールのジョイント音が際立つように調整しました。さらに、ループ再生時に継ぎ目が目立たないよう、音声ファイルの先頭と末尾にクロスフェード処理を施しています。
ブレーキ音やコンプレッサー音については、フリーの鉄道効果音素材を利用し、再生速度(playbackRate)を微調整することでE235系の音に近づけました。完璧な再現は難しいですが、「雰囲気として伝わる」レベルを目標にしています。
ステートマシンの設計ミス
記事の前半ではステートマシンがスッキリ書けているように見せていますが、最初から綺麗に書けていたわけではまったくありません。むしろ最初の実装は、今見ると恥ずかしくなるくらい泥沼のif文ネストでした。
if文のネストで破綻した最初の実装
初期のコードはおおむね以下のような形で、「速度が0より大きいか」「前フレームより増えているか」「マスコンが入っているか」といった判定を、ひたすらif-elseでぶら下げていました。
// 負の遺産。今見ると卒倒しそうなコード
if (currentSpeedKmh > 0) {
if (speedDiff > 0) {
if (currentNotch > 0) {
playSound('accel');
} else {
if (prevState === 'brake') {
stopSound('brake');
playSound('coast');
} else {
playSound('coast');
}
}
} else {
// ... 以下延々と続く
}
}
このコードでは、「加速から停車」に瞬間移行したときに走行音が鳴りっぱなしになる、ブレーキ中に再加速すると両方の音が鳴る、といった問題が頻発しました。特に覚えているのは、マスコンを急激に引いたら「加速中」→「停車」が一瞬で遷移してしまい、加速音の余韻を残したまま発車メロディが鳴り出すという、非常にシュールな不具合です。
状態を列挙型にして整理
いい加減これではまずいと思い、状態を明示的な列挙型(風の定数オブジェクト)として定義し、状態遷移ロジックを一箇所に集約しました。本記事の前半で紹介しているステートマシンは、この整理後の姿です。
const TrainState = Object.freeze({
STOP: 'stop',
ACCEL: 'accel',
COAST: 'coast',
DECEL: 'decel',
BRAKE: 'brake',
});
// 状態ごとに「入る時 / 出る時」の処理を明示
const transitions = {
[TrainState.ACCEL]: {
onEnter: () => fadeIn(sounds.accel, 500),
onExit: () => fadeOut(sounds.accel, 500),
},
// ...
};
いわゆるFSM(有限状態機械)の基本形ですが、これに落とし込んだだけで音の切り替えロジックの見通しは劇的に良くなりました。当時、ちゃんとしたFSMライブラリとしてXStateの導入も検討しましたが、「状態数が5〜6個しかないのに依存を増やしたくない」と判断して自前実装に落ち着きました。結果、現在でも200行以下の小さなモジュールに収まっています。
Web Audio APIのタイミング問題
音声の細かなタイミング制御を詰めていく過程でも、いくつもの罠を踏み抜きました。とくにsetTimeoutを信じすぎてひどい目に遭った話は、自戒を込めて書いておきたいと思います。
setTimeoutで音を制御したら音ズレ発生
発車メロディの後に「プルルル…ガシャン(ドア閉)」というタイミングで効果音を重ねたい、というケースがありました。最初は単純に setTimeout を使って「3秒後にドア閉音、3.5秒後にモーター起動音」といった形で予約していたのですが、これが再生のたびに微妙にタイミングがズレるのです。ひどい時は300msくらい遅れて、発車メロディが鳴り終わる前にドアが閉まる始末でした。
原因はもちろん、JavaScriptのsetTimeoutが本質的に精度を保証しないからです。メインスレッドがThree.jsの描画で忙しいフレームに当たるとタイマーが遅延し、その遅延がそのまま音のズレとして聞こえてしまいます。
AudioContext.currentTimeで正確にスケジュール
解決策として、すべての音を AudioContext.currentTime 基準でスケジューリングするよう書き直しました。Web Audio APIの各ソースノードは、start(when) の引数に未来の時刻を渡すことでオーディオスレッド上で正確に再生を開始してくれます。
// setTimeoutではなくAudioContextの時刻でスケジュール
function scheduleSound(buffer, delaySec) {
const src = audioContext.createBufferSource();
src.buffer = buffer;
src.connect(audioContext.destination);
// メインスレッドの混雑に影響されず、±1ms以下の精度で再生
src.start(audioContext.currentTime + delaySec);
}
scheduleSound(bellBuf, 0.0);
scheduleSound(doorBuf, 3.0);
scheduleSound(motorBuf, 3.5);
この書き換えで、発車シーケンスは毎回ピタリと同じタイミングで鳴るようになりました。メインスレッドが一時的に重くなっても、オーディオスレッド側で独立にスケジュールされているので音ズレが発生しません。
Safariでだけ最初の1秒が鳴らない
もう一つ、本当に泣かされたのがSafariでだけ、発車メロディの最初の1秒ほどが無音になる現象でした。Chromeでは完璧に再生されるので、長い間「録音データの先頭に無音があるのかな?」と思い込んでいて、素材ファイルを開いて波形を確認してはまた悩む、ということを繰り返していました。
原因は、Safariでは AudioContext.resume() が実際に解決されるまでに内部で十数〜数百ms程度かかる場合があり、その間にstartを呼ぶと最初の部分が欠けるというものでした。対策として、 resume() のPromiseがresolveしてから初めて音声スケジューリングを開始するよう明示的にawaitを挟みました。
// Safariでの欠落を防ぐため resume 完了を待つ
startButton.addEventListener('click', async () => {
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
beginDepartureSequence(); // ここで初めて音をスケジュール
});
たった1行 await を付けるだけですが、この対応で全ブラウザで音が正しく頭から鳴るようになりました。ブラウザ間の些細な差を吸収するのは、Web開発における永遠のテーマだと感じます。
リアルな音声のための細かな工夫
最後に、表には出にくいけれどプレイ感に効く細かい工夫を2つだけ紹介させてください。どちらも「知っている人は知っている」系のテクニックですが、実装するかしないかで体験の質がけっこう変わります。
クロスフェードは等ラウドネス曲線で
当初、音量のクロスフェードは素朴に「音量を0〜1でリニアに変化させる」方式でした。しかしこれだと、クロスフェードの中間地点で体感音量がガクッと下がって聞こえる、いわゆる「音量の谷」が発生します。
人間の聴覚は対数的なので、本当は等ラウドネスを保つためには音量カーブを指数的に変化させる必要があります。さらに言えば、低〜中音域の成分が多い走行音では、指数カーブよりも等ラウドネス曲線(ISO 226)を近似した関数でクロスフェードする方が自然に聞こえました。
// 時刻 t (0〜1) に対するフェード係数を計算
function crossfadeGain(t, isFadeOut) {
// 等ラウドネス曲線を簡易近似した関数
const x = isFadeOut ? 1 - t : t;
return Math.pow(x, 0.6); // sqrt(0.5)より弱めのカーブ
}
この微調整によって、加速音から惰行音への切り替わりが、ほとんど気づかないレベルでなめらかになりました。耳を澄まさないと差分は分からないのですが、体感の「自然さ」が地味に向上しています。
VVVFの音階シフトをピッチベンドで実装
E235系に採用されているVVVFインバータは、速度に応じて特徴的な音階が変わっていくのが魅力です。これを再現するため、走行音のうちインバータ成分だけを AudioBufferSourceNode.playbackRate で動的にピッチ変更しています。
ただし、 playbackRate を直接書き換えるとプチッというノイズが乗ることがあるため、 playbackRate.linearRampToValueAtTime を使って数十ms単位で滑らかに遷移するよう工夫しました。結果として、加速時の「ふぃーーん」というあの独特の音階上昇が、実車に近い雰囲気で再現できていると自負しています。
まとめ
音の実装は、単なる演出以上の「情報のフィードバック」として機能します。音が変わることで、ユーザーは画面を見なくても「あ、今ブレーキがかかったな」「惰性走行に入ったな」と直感的に理解できるようになりました。
今後は、トンネル内での反響音や、すれ違い時のドップラー効果などにも挑戦してみたいですね。