パーティーゲームのサイトを個人で作っています。ゲームには秒読み音や開票のファンファーレが要るし、紹介動画には BGM が要る。ふつうならフリー素材の音源を探すところですが、効果音も BGM も、音源ファイルを 1 つも置かずに全部コードで生成することにしました。
フリー素材の音には、たいてい何かしらの利用条件が付いてきます。クレジット表記や再配布可否のような条件は 1 個なら大した手間ではありませんが、音源を増やすほど、その出典と条件をこの先ずっと覚えておく必要が増えていきます。音源を配布物として持たずコードで生成すると、帰属表記・再配布条件・規約変更の追跡という管理コストがまるごと消えます。
音源のライセンス管理に地味なコストを感じている個人開発者に向けた記事です。
なぜ音源を「持たない」ことにしたか
きっかけは技術的な興味ではなく、ライセンス表記の管理が面倒だったからです。
条件の内訳はこうです。クレジット表記が必要、商用利用は別ライセンス、再配布は不可、サイトの規約が変わったら遡って影響する——どれも 1 個なら大した手間ではありません。ただ、個人開発でいちばん高いコストは「あとで思い出さないといけないこと」が増えることです。音源を 5 個入れたら、5 個ぶんの出典と条件を、この先ずっと覚えておく (か、どこかに書いて維持する) 必要がある。
コードで生成すれば、この管理項目がまるごとゼロになります。帰属表記も、再配布条件も、規約変更の追跡も、最初から存在しない。書いたコードは自分のものなので、動画に載せようが Zenn に貼ろうが誰にも確認しなくていい。音の良し悪しより、この一点で決めました。
もちろん CC0 の音源を使えば帰属表記の問題は消えます。それでも探す時間と、配布元が消えたり規約が変わったりしたときに確認し直す作業は残る。ゼロにできるものはゼロにする、というだけの判断です。
サイトの効果音は oscillator を数行
ブラウザ側は Web Audio API の oscillator を鳴らすだけです。前提として、AudioContext と音のオン/オフはモジュール変数として 1 つだけ持ちます (後述の理由で、生成はページ読み込み時ではありません)。
let ctx: AudioContext | null = null;
let enabled = true;
そのうえで、音を出す実体はこれだけしかありません。createOscillator() が波形を作る発振器、createGain() が音量つまみで、2 つをつないでスピーカー (destination) に流します。
function beep(frequency: number, durationMs: number, volume = 0.08): void {
if (!enabled || !ctx || ctx.state !== 'running') return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(volume, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + durationMs / 1000);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + durationMs / 1000);
}
矩形波を鳴らして、音量を指数的に落として止める。これでピッという音になります。この関数ひとつが、知ったかリレー (実在しないお題を、知ってる風にでっち上げてリレーで語るゲーム) の残り 5 秒の秒読みから開票のファンファーレまで、全ゲームの音を賄っています。
1 か所だけ罠があって、exponentialRampToValueAtTime にターゲット値 0 を渡すと例外 (RangeError) で落ちます。指数的な変化はゼロに到達できないので、渡せる値は正の数だけ、という仕様です。「音を消す = 0 にする」と素直に書くと踏むので、聞こえないが 0 ではない値として 0.0001 を渡しています。Web Audio で最初に出会う罠だと思うので、先に書いておきます。
あとは周波数と長さの組み合わせで役割を作るだけです。
/** 残り 5 秒からの秒読み音 */
export function playTick(): void {
beep(880, 90);
}
/** 開票・結果のファンファーレ風 */
export function playFanfare(): void {
beep(523, 120);
setTimeout(() => beep(659, 120), 130);
setTimeout(() => beep(784, 240), 260);
}
ファンファーレはドミソを 130 ミリ秒ずつずらして鳴らしているだけです。それらしく聞こえるので、凝った合成は最後までやりませんでした。
音を自作したのは、音にこだわりがあるからではなく、逆に音に手をかけたくなかったからです。矩形波のピッという音は、探してきた高品質な効果音には遠く及びません。それでも「秒読みが進んでいると分かる」「開票が始まったと分かる」という必要な仕事はしているので、そこで止めています。足りているものを作り込まないために、いちばん手前で止まれる手段がコードだった、という順番です。
ハマった点: 音は「鳴らない」のではなく「鳴らせない」
実装で唯一つまずいたのが、ブラウザの自動再生制限です。AudioContext はページ読み込み時点では suspended 状態で、ユーザーが操作するまで音を出せません。ここを知らずに書くと、コードは完璧なのに何も鳴らないという状態になります。
対処は「最初のユーザー操作で resume() する」だけですが、置き場所に少し考えることがありました。
/** ユーザー操作のハンドラー内から呼び、AudioContext を使える状態にする */
export function unlockAudio(): void {
if (typeof AudioContext === 'undefined') return;
ctx ??= new AudioContext();
if (ctx.state === 'suspended') void ctx.resume();
}
ここでは 3 つの判断をしています。AudioContext の生成自体を初回操作まで遅らせているのは (ctx ??=)、読み込み時に作ると、使われないまま suspended のコンテキストが残るからです。typeof AudioContext === 'undefined' で守っているのは、このサイトが静的ビルドで、ビルド時にサーバー側で描画される経路があるためです。いまの呼び出し方 (クリックのハンドラー内) ならそこには到達しませんが、うっかり描画中に呼ぶ経路を足した瞬間にビルドが落ちるので、先に塞いでおく類の防御です。beep 側では ctx.state !== 'running' なら黙って何もしません。ロック解除前に鳴らそうとしても、例外ではなく無音で済ませる受け皿です。
音まわりは失敗しても致命傷にしないのが正解でした。音が出ないことより、音のせいで画面が止まることのほうがずっと困るからです。
呼び出す場所も、個別のボタンに仕込むと必ず付け忘れるので、状態遷移の入口 1 か所にまとめました。このゲームはすべての操作がイベント経由で状態を進めるので、そのディスパッチ関数の先頭に置けば「ユーザー操作起点」を漏れなく拾えます。
const dispatch = (event: GameEvent) => {
// すべてのイベントはユーザー操作起点なので、ここで AudioContext を有効化できる
unlockAudio();
setState((prev) => {
const next = transition(prev, event);
saveState(slug, next);
return next;
});
};
ここで意図的にやっていないことがひとつあります。unlockAudio() の直後に音を鳴らしていません。resume() は Promise を返すので、同じ同期処理の中で続けて beep() を呼んでも、そのときはまだ suspended のままで最初の 1 音が鳴りません。この実装では音を鳴らすのは状態が変わったあとの副作用なので、順序として自然に避けられています。解除と発音を同じ関数の中で連続させないことは、意識して分けたほうがいいところです。
動画の BGM は Node で WAV を書き出す
紹介動画のほうは、ブラウザではなくビルド時に WAV ファイルそのものを生成しています。やっていることは単純で、Float32Array に波形を足し込んで、16bit PCM に変換してヘッダを付けるだけです。
const addKick = (startSec, gain) => {
const start = Math.round(startSec * SR);
const n = Math.min(Math.round(0.16 * SR), total - start);
for (let i = 0; i < n; i++) {
const t = i / SR;
const freq = 130 * Math.exp(-t / 0.045) + 45;
bgm[start + i] += gain * Math.exp(-t / 0.05) * Math.sin(2 * Math.PI * freq * t);
}
};
キックドラムは周波数が急速に下がるサイン波です。式のとおり 175 Hz から始まって 45 Hz に漸近し、指数の時定数が 45 ミリ秒。実際の値を並べるとこうなります——0 ms で 175 Hz、20 ms で 128 Hz、45 ms で 93 Hz、160 ms で 49 Hz。そこへ音量の減衰 (時定数 50 ミリ秒) を掛けると、ドンという音になります。「45 ミリ秒で 45 Hz まで落ちる」ではないので注意——指数関数は漸近先に到達しません。ハイハットはノイズをハイパスに通したもの、コードは 112 BPM の 4 小節ループ。音楽の知識というより、音の正体を調べて式にする作業でした。
ひとつ地味に効いている工夫が、ノイズ生成に固定シードの擬似乱数を使っていることです。Math.random() を使うと生成のたびに波形が変わり、「昨日の動画と今日の動画で BGM が微妙に違う」ことになります。シードを固定してあるので、何度生成しても同じ WAV が出ます。生成物をリポジトリに持たない (ビルド時に作る) 判断とセットで、これは必要な性質でした。
まとめ
この判断が効くのは、必要な音の種類が少なく、音楽的な複雑さを求めない場面に限られると考えています。矩形波のビープや単純な波形合成の BGM は今回のように式で書けますが、声や環境音のような音源を同じ手段で再現するのは現実的ではありません。効果音や BGM の本数が少ない個人開発では、音質を多少犠牲にしてでも管理コストをゼロにする選択が割に合う場面が多いはずです。実際に鳴っているものは mawashite.com で聞けます。