ボタンを押した瞬間に音が鳴る。サウンドボードに求められるのはほぼこれだけですが、ブラウザで素直に作ると「最初の1回だけ遅れる」「iPhoneで鳴らない」「連打するとメモリが増え続ける」といった問題にすぐ当たります。
この記事では、私が作っている無料のミームサウンドボード Meme Tap を実例として、ブラウザで低遅延な効果音再生を実現するための設計を整理します。Meme Tap は Next.js(Vercel 上)で動いていて、アカウント登録なしでボタンを押せば音が鳴り、MP3 のダウンロードやお気に入り登録もできるサイトです。本番のバンドルで実際に使っている方針と、Web Audio API を使う場合の選択肢の両方を書きます。
遅延はどこで発生するのか
クリックから音が出るまでの時間は、大きく3つに分けて考えると整理しやすいです。
- ネットワーク:音声ファイルがまだ手元にない。最初の再生で一番効く要因です。
- デコード:MP3 などを PCM に展開する時間。短い効果音なら小さいですが、ゼロではありません。
- 出力パイプライン:OS とブラウザのオーディオ出力のバッファ。ここは JavaScript からはほぼ触れません。
体感の遅れのほとんどは 1 と 2 です。つまり「押される前に取ってきて、押される前に再生可能な状態にしておく」ことが設計の中心になります。
選択肢A:HTMLAudioElement をプールする
Meme Tap が採用しているのはこちらです。new Audio() を音ごとに作り、preload = "auto" にして canplay を待っておき、押されたら currentTime = 0 に戻して play() するだけ、というシンプルな方式です。
ただし、何も考えずにボタンの数だけ Audio を作ると、一覧ページで何十ものリクエストが同時に飛び、メモリも増え続けます。そこで本番では次の2つの制限を入れています。
-
同時プリロード数の上限:ウォームアップ中の読み込みは最大 6 本。残りはキューに積み、
canplayかerrorが来たら次を取りに行きます。 - LRU による上限管理:保持する Audio 要素は最大 16 個。超えたら、再生中のもの以外で一番古いものを解放します。
解放するときは pause() だけでは不十分で、src 属性を外して load() を呼び、メディアリソースを確実に手放させます。簡略化するとこんな形です。
const pool = new Map(); // slug -> HTMLAudioElement
const lru = []; // 古い順に slug を並べる
const queue = [];
let loading = 0;
let current = null;
function warm(sound, urgent = false) {
if (pool.has(sound.slug)) return touch(sound.slug);
if (urgent) return load(sound);
queue.push(sound);
pump();
}
function pump() {
while (loading < 6 && queue.length) {
const s = queue.shift();
if (!pool.has(s.slug)) load(s);
}
}
function load(sound) {
const a = new Audio();
a.preload = "auto";
let settled = false;
const done = () => {
if (settled) return;
settled = true;
loading -= 1;
pump();
};
a.addEventListener("canplay", done, { once: true });
a.addEventListener("error", done, { once: true });
loading += 1;
a.src = sound.url;
pool.set(sound.slug, a);
touch(sound.slug);
}
function touch(slug) {
const i = lru.indexOf(slug);
if (i >= 0) lru.splice(i, 1);
lru.push(slug);
while (lru.length > 16) {
const victim = lru.find((s) => s !== current && s !== slug);
if (!victim) return;
evict(victim);
}
}
function evict(slug) {
const a = pool.get(slug);
if (!a) return;
a.pause();
a.removeAttribute("src");
a.load(); // デコーダとバッファを解放させる
pool.delete(slug);
lru.splice(lru.indexOf(slug), 1);
}
再生側は、前の音を止めてから対象の要素を巻き戻して鳴らします。play() は Promise を返し、自動再生ポリシーに引っかかると reject されるので、必ず catch しておきます。
function play(sound) {
let a = pool.get(sound.slug);
if (!a) { load(sound); a = pool.get(sound.slug); }
touch(sound.slug);
current = sound.slug;
if (a.readyState > 0) a.currentTime = 0;
return a.play().catch(() => "blocked");
}
この方式の利点は、ストリーミング・コーデック対応・メモリ管理をブラウザに任せられることです。音の長さもバラバラで、長めのクリップも混ざるミームサイトでは、すべてを AudioBuffer に展開するより扱いやすいと判断しました。
選択肢B:Web Audio API で AudioBuffer を鳴らす
ゲームのように「同じ音を重ねて連打したい」「ピッチや音量をノードで加工したい」場合は Web Audio API が向いています。fetch → decodeAudioData で事前にデコードしておけば、押した瞬間はバッファを AudioBufferSourceNode に渡すだけなのでデコード時間がゼロになります。
const ctx = new AudioContext({ latencyHint: "interactive" });
const buffers = new Map();
async function preload(url) {
if (buffers.has(url)) return buffers.get(url);
const res = await fetch(url);
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
buffers.set(url, buf);
return buf;
}
function trigger(url) {
const buf = buffers.get(url);
if (!buf) return preload(url).then(() => trigger(url));
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(); // 同じ音を何重にも鳴らせる
}
注意点は、デコード後の PCM はファイルサイズより大幅に大きくなることです。数秒の効果音なら問題になりにくいですが、カテゴリ単位で何百もの音を抱えるなら、こちらでも LRU で buffers を削る仕組みが必要になります。AudioBufferSourceNode は使い捨てなので、毎回新しく作るのが正しい使い方です。
ざっくりした使い分けは次の通りです。
| 観点 | Audio 要素プール | Web Audio + AudioBuffer |
|---|---|---|
| 初回の手間 | 少ない | fetch とデコードが必要 |
| 同じ音の重ね鳴らし | 要素を複製する必要あり | 得意 |
| エフェクト・ミキシング | 不向き | 得意 |
| メモリ | ブラウザ任せ | PCM を自分で保持 |
| 長いクリップ | 得意(ストリーミング) | 全部展開するので重い |
プリロードの優先度を決める
全部を先読みするのは無駄なので、「次に押されそうなもの」だけを温めます。Meme Tap では3段階に分けています。
-
HTML レベル:トップページの トレンドのサウンド の先頭 8 件は、サーバーで
<link rel="preload" as="audio">を出力しています。JavaScript の実行を待たずに取得が始まります。 -
ウォームアップ API:プレイヤーのコンテキストが
warm()を公開していて、UI から「そろそろ押されそう」な音をキューに積めます。 - 連続再生の先読み:自動再生モードやランダム再生モードでは、現在の音を鳴らしている間に次の 1 曲を緊急扱い(キューを飛ばす)で読み込みます。
ランダム再生のページ のように次が予測できない画面でも、「1つ先だけは常に持っておく」というルールにすると、待ち時間はほぼ最初の 1 回だけになります。
キャッシュと配信
音声は /api/sounds/<slug>?exp=...&sig=... のような署名付き URL で配信していて、レスポンスには Cache-Control: private, max-age=604800 を付けています。同じ端末で同じ音を押し直した場合は、ネットワークに出ずにブラウザのキャッシュから返ります。URL には有効期限と署名が付いていますが、一度取得した音は 1 週間ブラウザに残るので、押し直しのコストはほぼかかりません。
もうひとつ地味に効くのがファイルサイズです。効果音はモノラル・低めのビットレートでも十分なことが多く、ファイルが小さいほど最初の 1 回が速くなります。
モバイルの自動再生制限
iOS Safari や Android Chrome では、ユーザー操作なしの音声再生はブロックされます。サウンドボードは基本的に「押してから鳴る」ので有利ですが、それでもハマりどころがあります。
-
play()はジェスチャーのハンドラ内で同期的に呼ぶ:await fetch()の後にplay()すると、ジェスチャーとの紐付けが切れたと判断されることがあります。だからこそ事前に読み込んでおき、クリック時はplay()だけにするのが重要です。 -
AudioContext は
suspendedで始まる:Web Audio を使う場合は、最初のタップでctx.resume()を呼びます。 -
reject を握りつぶさない:
play()の Promise が reject されたら、UI を「停止」状態に戻します。Meme Tap では失敗も「stopped」として扱い、ボタンが再生中表示のまま固まらないようにしています。 -
連続再生は最初のタップが起点:自動再生モードも、ユーザーが「Autoplay」を押した操作から始まり、各音の
endedイベントで次へ進みます。
button.addEventListener("pointerdown", () => {
if (ctx.state === "suspended") ctx.resume();
trigger(button.dataset.src);
});
click より pointerdown で発火させると、指を離すまでの時間ぶん早く鳴らせます。ただしスクロール中の誤タップが増えるので、一覧の密度とのトレードオフです。
再生状態の競合を防ぐ
連打や、再生中に別のボタンを押すケースでは、古い ended イベントが新しい再生の状態を上書きしてしまいがちです。Meme Tap ではリクエストごとに世代番号(カウンタ)を発行し、await から戻ったときに番号が最新でなければ何もしない、という方式で防いでいます。React の useRef にカウンタを置くだけで済むので、状態管理ライブラリを足す必要はありません。
実際に触って確かめる
DevTools の Network タブを開いた状態で Meme Tap のトップページ を読み込むと、as=audio のプリロードが先に走るのが確認できます。その後 サウンドボード一覧 から Discord 向けの効果音 や リアクション系のボタン を開いて押していくと、プールの上限を超えた古い要素が解放されていく様子も見られます。個別ページ(例:67 Brainrot のボタン)ではダウンロードも試せます。こちらは fetch で Blob を取り、URL.createObjectURL で一時 URL を作って <a download> をクリックさせる、よくある方式です。
まとめ
- 遅延の大半はネットワークとデコード。押される前に終わらせる。
- 効果音中心なら Audio 要素プール+同時読み込み上限+LRU で十分戦える。
- 重ね鳴らしやエフェクトが必要なら Web Audio の AudioBuffer。
- モバイルではジェスチャー内で同期的に
play()、失敗時の UI 復帰を忘れない。
サウンドボードは小さなアプリですが、プリロード戦略・メモリ上限・自動再生ポリシーと、フロントエンドのパフォーマンスの論点が一通り詰まっています。新しく追加された音は 最近追加されたサウンド に並ぶので、実装を試す題材としても使ってみてください。