鉄道シミュレーターにおいて、映像と同じくらい大事なのが音です。VVVFインバータの励磁音、ジョイントを跨ぐときのカタンコトン、ドアの開閉音、発車メロディ、駅到着のアナウンス音声……全部入れていたら、いつの間にか効果音だけで20ファイル、合計30MB超に膨らんでいました。
この記事では、その音声データをなんとか軽量化しようと、MP3 / AAC / OGG Vorbis / Opus を比較検証した結果と、実装で詰まった話を書きます。結論から言うと、ブラウザ互換性の現実は想像以上に厳しく、Safariが全ての計画を狂わせました。
最初の状態と改善目標
リリース時点では、効果音はすべてMP3(128kbps / 44.1kHz)で配信していました。MP3は全ブラウザで再生できる安心感がありますが、圧縮効率が現代的な基準ではやや非効率です。
特に、ループさせるVVVF音(1ファイルあたり約8秒で2MB)と、列車無線のノイズ音が容量の大部分を占めていました。全体30MBという数字は、初回読み込みで明らかに重すぎます。目標は合計10MB以下、可能であれば音質も向上させたい、というものでした。
フォーマット別の比較実験
Audacityに元のWAVファイル(合計約400MB)を読み込み、MP3 / AAC / OGG Vorbis / Opus の4フォーマットで書き出して比較しました。ビットレートはいずれも「透明になる」ギリギリを狙って 96kbps としています。
# ffmpeg による一括変換例
ffmpeg -i src.wav -c:a aac -b:a 96k -movflags +faststart out.m4a
ffmpeg -i src.wav -c:a libvorbis -b:a 96k out.ogg
ffmpeg -i src.wav -c:a libopus -b:a 80k out.opus
ffmpeg -i src.wav -c:a libmp3lame -b:a 128k out.mp3
結果はおおむね以下の通りでした(VVVF音8秒の例)。MP3 128kbps: 128KB、AAC 96kbps: 98KB、OGG Vorbis 96kbps: 94KB、Opus 80kbps: 80KB(しかも音質が一番良い)。Opusの圧縮効率は頭一つ抜けており、「これで決まりだろ」と思っていました。
ブラウザ対応表の現実
ところが、ブラウザ対応表を確認すると、Opusの普遍的サポートはまだ怪しいという事実に気付かされます。2026年時点でも、iOS Safari のOpus対応は部分的で、特にCoreAudio経由で再生する際に無音になるケースや、AudioBufferへのデコードでフェイルするケースが確認できました。
また、OGG Vorbis は長年、Safari(macOS/iOS)が非対応でした。Safari 17からはコンテナ入りのOGG Vorbisが一部読めるようになっていますが、実際に iOS 16 の端末で検証したところ再生できない音声が多く、Operaや古いデバイスも含めると OGG を主力にするのは無理という判断に至りました。
AAC と OGG の二重配信 workaround
結局、私が採用したのはAAC (m4a) と OGG Vorbis を両方配信するという古典的な workaround です。HTMLAudioElement の <source> タグと同じ考え方で、ブラウザが対応している方を選んで再生します。
async function loadDecodableAudio(ctx, baseName) {
const tryFormats = ['.m4a', '.ogg', '.mp3'];
for (const ext of tryFormats) {
try {
const res = await fetch(`/assets/audio/${baseName}${ext}`);
if (!res.ok) continue;
const arrayBuf = await res.arrayBuffer();
return await ctx.decodeAudioData(arrayBuf);
} catch (e) {
// 次の候補を試す
continue;
}
}
throw new Error(`All formats failed for ${baseName}`);
}
decodeAudioData はブラウザがデコードできないフォーマットだとPromiseがrejectされるので、fallback chain として順に試す形にしました。これで、最新Chromeは AAC、古い Chromebook は OGG、Firefox は OGG、Safari は AAC という棲み分けが自動的に成立します。
Apple 製品は基本的に AAC が最速
後になって知ったのですが、iOS / macOS のSafariは AAC (m4a) のハードウェアデコーダを持っているため、他フォーマットより格段に低遅延で再生できます。VVVFのような「音源が途切れると気持ち悪い」ケースでは、特に AAC の安定感が光りました。
シームレスループが途切れる罠
圧縮フォーマット選定がひと段落して「これで完璧だ」と思っていたら、リリース前のテストでとんでもない不具合に気づきました。VVVFのループ音が、ループの継ぎ目で毎回「プチッ」と無音が入るのです。
調べてみると、これはMP3 / AAC といった圧縮音声の仕様上の問題でした。MP3やAACは「フレーム」という単位でエンコードされ、デコード時には先頭と末尾にプライミング(Encoder Delay)サンプルが挿入されます。このサンプル分の無音部分がループ時に挟まってしまい、シームレスにならないのです。
Audacity での正確なトリミング
対策として、Audacityでループ区間を正確に取り直し、サンプル単位で先頭と末尾がクロスフェードするように切り出しました。
ただし、どうやってもAACエンコーダがプライミングサンプルを追加してくるので、プライミング分をデコード後に手動でスキップする必要があります。ffmpegで -movflags +faststart -af "aresample=async=1" を付ければある程度軽減されますが、完全には消えません。
// AudioBuffer から先頭のプライミング分を切り捨てる
function trimPriming(audioBuf, primingSamples = 2112) {
const trimmedLength = audioBuf.length - primingSamples * 2;
const newBuf = ctx.createBuffer(
audioBuf.numberOfChannels,
trimmedLength,
audioBuf.sampleRate
);
for (let ch = 0; ch < audioBuf.numberOfChannels; ch++) {
const src = audioBuf.getChannelData(ch);
const dst = newBuf.getChannelData(ch);
dst.set(src.subarray(primingSamples, primingSamples + trimmedLength));
}
return newBuf;
}
AACのプライミングは一般的に2112サンプル(44.1kHz で約48ms)です。これを雑に決めうちすると、音源によってはピッチがズレて聴こえるので、本来はファイルごとの edts/elst ボックスを読んで正確に処理すべきです。私は最初この詳細を知らず、なぜか「特定の音源だけ違和感がある」と数日悩みました。
最終的に、ループ必須の音源だけOGG Vorbisに揃えることにして、Safari用にはAPIレベルで境界補正をかけるという折衷案に落ち着きました。
AudioBuffer か HTMLAudioElement か
音声の鳴らし方についても設計判断がありました。Web Audio API には主に2つの選択肢があります。
1つは decodeAudioData で事前に AudioBuffer としてメモリに載せる方法。もう1つは HTMLAudioElement (つまり <audio> タグ)を MediaElementAudioSourceNode で繋ぐ方法です。
効果音(短い音)は前者、BGMやアナウンス(長い音)は後者、という住み分けが一般的です。私は最初、VVVFもアナウンスも全て AudioBuffer に載せていたのですが、全部で20音源をデコードしてメモリに展開すると、JS Heap の使用量が一気に100MBくらい膨らむことに気づきました。
// 短い効果音はAudioBufferでゼロ遅延再生
const clickBuf = await loadDecodableAudio(ctx, 'click');
function playClick() {
const src = ctx.createBufferSource();
src.buffer = clickBuf;
src.connect(ctx.destination);
src.start();
}
// 長いアナウンスはHTMLAudioElementで都度ロード
function playAnnouncement(name) {
const audio = new Audio(`/assets/audio/${name}.m4a`);
audio.play();
}
この使い分けに切り替えたら、メモリ使用量が半分以下になりました。ただし、HTMLAudioElement は play() 呼び出しからの初回遅延が大きく、「発車メロディが鳴りそうで鳴らない」という小さなストレスにつながるので、重要な音は事前に load() までしておくのがコツです。
スマホ Safari の「ユーザー操作がないと再生しない」問題
最後に、モバイルSafari特有の大問題です。iOS Safari(およびモバイルChrome系ブラウザ)は、ページが自動的に音を出すことを禁じており、ユーザーのタップ等のイベントハンドラ内でしか AudioContext.resume() が通らない仕様です。
最初にこの仕様を知らず、ページ読み込み時に new AudioContext() して、自動再生しようとしたら、iPhoneで完全無音という悲惨な結果になりました。解決策は、明示的に「タップして始める」ボタンを置き、その中でコンテキストを起動することです。
const ctx = new (window.AudioContext || window.webkitAudioContext)();
document.getElementById('start').addEventListener('click', async () => {
if (ctx.state === 'suspended') {
await ctx.resume();
}
startSimulator();
});
これは知っていれば簡単なのですが、初見だと「Chromeなら鳴るのにiPhoneだけ鳴らない…」とブラウザのバグを疑って1日溶かすタイプの罠です。私も見事にハマりました。
まとめ
AACとOGG Vorbisの二重配信、プライミング対策、AudioBuffer と HTMLAudioElement の使い分け、Safariのユーザー操作要件——音声まわりは表面的には「ただ鳴らすだけ」ですが、掘り下げるほど世界が広がる領域でした。
最終的に、合計30MBだった音声データは、AAC+OGGの二重配信後でも実効6MB強(ブラウザごとに片方しか読み込まないため)に収まり、シームレスループも安定。次はEngineSound的なリアルタイム合成を試してみたいと思っています。