今回取り組んだ内容
今回は、Web Audio APIを使ってブラウザ上でゲーム用の効果音を生成する処理について調べました。
取り組んだ内容は主に以下です。
- Web Audio APIとは何かを調べた
- 音声ファイルを使わずに、コードだけで効果音を作る仕組みを調べた
- 波形、周波数、音量、再生時間を変えて効果音を作ってみた
なぜやろうと思ったか
タイピング要素のあるゲームを作っている中で、キー入力・攻撃・被弾・回復などのタイミングに合わせて効果音を鳴らしたいと思いました。
最初は効果音ごとに音声ファイルを探して使おうと思っていましたが、欲しい効果音を探すのが大変だったのでWeb上で効果音を生成できないか試してみることにしました。
調べてみると、Web Audio APIを使えば mp3 や wav のような音声ファイルを読み込まずに、ブラウザ上で音を生成できるらしいのでやってみました。
学んだこと
1. そもそもWeb Audio APIとは?
Web Audio APIは、ブラウザ上で音を生成・加工・制御するためのブラウザ標準のAPIです。
音声ファイルを再生するだけなら、HTMLの audio タグや JavaScript の Audio オブジェクトでも実現できます。
<audio src="sound.mp3" controls></audio>
const audio = new Audio("/sounds/hit.mp3");
audio.play();
一方でWeb Audio APIを使うと、波形・周波数・音量・再生時間などを指定して、ブラウザ上で音そのものを生成できます。
今回やりたいのは「音を生成すること」なので、上記のような再生用の仕組みとは違い、ここからは音を作成する手順について書いていきます。
2. 音の流れ
Web Audio APIで音を鳴らすには、音を作るノードと音を出すノードをいくつか繋げる必要があります。
なお、これらのノードはすべて AudioContext というクラスから作ります。AudioContext は、ブラウザの中に用意する音声処理の作業スペースのようなもので、ノードの生成・出力先・時間管理などをまとめて担当します。コード中の ctx は、この AudioContext クラスのインスタンスです。
const ctx = new AudioContext();
音の流れのイメージは、こんな感じです。
[音を生成する] → [音量を調整する] → [スピーカーに出力する]
OscillatorNode GainNode AudioDestinationNode
1つずつ何をしているか見ていきます。
① 音を生成する:OscillatorNode
ctx.createOscillator() で作る OscillatorNode は、指定した波形・周波数で音の信号を作ってくれるノードです。
const osc = ctx.createOscillator();
② 音量を調整する:GainNode
ctx.createGain() で作る GainNode は、信号の音量を上げ下げするノードです。
①で作った音をそのまま鳴らすのではなく、ここで音量をコントロールします。
const gain = ctx.createGain();
③ 出力先につなぐ:AudioDestinationNode
ctx.destination は、最終的にスピーカーやイヤホンなどに繋がる出力先(AudioContext の出力先ノード)です。
①②で作った信号を、ここに繋ぐことで初めて音が鳴ります。
ノード同士を繋げる
それぞれのノードは作っただけでは何も起きません。connect() で繋いで、信号の通り道を作ってあげる必要があります。
osc.connect(gain); // 発生源 → 音量調整
gain.connect(ctx.destination); // 音量調整 → 出力先
これで音を作る → 音量を調整する → 出力する一連の流れが終わりました。
まとめると、今回の処理は以下のような流れです。
音声信号を生成する
↓
音量を調整する
↓
出力先に接続して音を鳴らす
3. buildOsc の役割
1つの音を作るために、buildOsc という関数を使いました。
const buildOsc = (
ctx: AudioContext,
type: OscillatorType,
freqStart: number,
volume: number,
duration: number,
freqEnd?: number,
startOffset = 0,
) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
const t = ctx.currentTime + startOffset;
osc.type = type;
osc.frequency.setValueAtTime(freqStart, t);
if (freqEnd) {
osc.frequency.exponentialRampToValueAtTime(freqEnd, t + duration);
}
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.start(t);
osc.stop(t + duration + 0.01);
};
この関数では、以下のような情報を指定して音を作っています。
type = 波形の種類
freqStart = 最初の周波数
volume = 音量
duration = 音の長さ
freqEnd = 最後の周波数
startOffset = 何秒後に鳴らすか
たとえば、キー入力時の音は次のように作りました。
buildOsc(ctx, "sine", 1200, 0.03, 0.02);
これは、sine という波形で、1200Hzの高めの音を、小さい音量で、0.02秒だけ鳴らすという意味です。
そのため、キー入力時の短いクリック音として使えます。
4. いつ音を鳴らすか(時間の指定)
Web Audio APIでは、「今すぐ鳴らす」のではなく、「何秒の時点で鳴らす」という形で時間を指定して音を制御します。
そのため、buildOsc の中でも時間の計算をしています。
基準になるのが ctx.currentTime です。
これは AudioContext が動き始めてからの経過時間(秒)を表します。
const t = ctx.currentTime + startOffset;
ctx.currentTime が「今」、startOffset が「今から何秒ずらすか」なので、t は「この音を鳴らし始める時刻」になります。
この t を、音の開始・停止や、周波数・音量の変化のタイミングに使っています。
osc.frequency.setValueAtTime(freqStart, t); // t の時点で周波数をセット
gain.gain.setValueAtTime(volume, t); // t の時点で音量をセット
osc.start(t); // t の時点で鳴らし始める
osc.stop(t + duration + 0.01); // t + 長さ の少し後で止める
setValueAtTime(値, t) は「t の時点でこの値にする」という意味で、osc.start(t) / osc.stop(...) は音を鳴らし始める・止める時刻を指定しています。
stop で + 0.01 と少しだけ余裕を持たせているのは、音量を下げきる処理(後述)が終わる前に音が切れてしまわないようにするためです。
この仕組みがあるおかげで、startOffset を変えるだけで「少し遅れて鳴る音」を作れます。
たとえば後述の enemy-hit では、startOffset を 0 → 0.08 → 0.16 とずらして、3つの音を連続して鳴らしています。
正直なところ、音声ファイルを再生するだけだと「いつ鳴らすか」くらいしか調整できないイメージだったので、秒単位でここまで細かくタイミングを指定できるのは驚きでした。この細かさが、音を重ねたりずらしたりして効果音を作れる理由なのだと感じました。
5. 波形の種類と音の印象
OscillatorNode では、type によって生成する波形を指定できます。今回のコードでは、主に以下の波形を使いました。
| 波形 | 読み方 | 音の印象 | 向いている用途 |
|---|---|---|---|
sine |
サイン波 | なめらかで柔らかい音 | 通知音、クリック音、チャイム |
square |
矩形波 / スクエア波 | ブザーのような音、レトロゲーム風の音 | ミス音、警告音 |
sawtooth |
のこぎり波 | 荒くて強い音、ジリジリした音 | 攻撃音、魔法音 |
triangle |
三角波 |
sine より少し硬く、square より柔らかい音 |
短い効果音、金属っぽい音 |
たとえば、sine は軽い音が鳴ってタイピングした時の音に最適でした。
buildOsc(ctx, "sine", 1200, 0.03, 0.02);
square は角ばった音がするのでタイピングミスをした際に利用しました
buildOsc(ctx, "square", 120, 0.2, 0.1);
そのため、タイピングミス時の効果音には square を使いました。
また、sawtooth はメリハリがついた音がします。ボフっと音がして可愛いですね。
buildOsc(ctx, "sawtooth", 200, 0.2, 0.35, 700);
このコードでは、200Hzから700Hzへ音の高さを上げているため、呪文を撃つときの上昇するような効果音になります。
triangle は、sine より少し硬い音ですが、square ほど強すぎない音です。
buildOsc(ctx, "triangle", 500, 0.2, 0.1);
このように、Web Audio APIでは波形を変えるだけでも音の印象が大きく変わります。
効果音を作るときは、音の高さや長さだけでなく、どの波形を使うかも重要だと感じました。
6. 音の高さを変化させる処理
呪文を撃ったときの音は、次のように作りました。
buildOsc(ctx, "sawtooth", 200, 0.2, 0.35, 700);
これは、200Hzから700Hzへ音の高さを上げる音です。
実際に周波数を変化させているのはこの部分です。
osc.frequency.exponentialRampToValueAtTime(freqEnd, t + duration);
freqStart が200、freqEnd が700なので、低い音から高い音へ上がるような効果音になります。
7. 音量をだんだん小さくする処理
音量は次のように設定しました。
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
最初に指定した音量で鳴らし、そこから duration 秒かけて 0.001 まで音量を下げています。
ここで 0 ではなく 0.001 にしているのは、exponentialRampToValueAtTime では0に向かう変化が扱いづらいためです。
完全な無音ではありませんが、ほぼ聞こえないくらい小さい値にすることで、音が自然に消えるようにしています。
やってみた結果
Web Audio APIで音声ファイルを読み込まなくても、波形・周波数・音量・時間を指定することで効果音を作れました。
音を重ねることができるっぽかったのでやってみました・hit の効果音では、2つの音を重ねました。
buildOsc(ctx, "sine", 100, 0.5, 0.4, 30);
buildOsc(ctx, "sawtooth", 80, 0.3, 0.35, 20);
また、enemy-hit では、3つの音を少しずつずらして鳴らしました。
buildOsc(ctx, "sine", 440, 0.2, 0.12, undefined, 0);
buildOsc(ctx, "sine", 550, 0.2, 0.12, undefined, 0.08);
buildOsc(ctx, "sine", 660, 0.25, 0.15, undefined, 0.16);
最後の引数が startOffset なので、0 → 0.08 → 0.16 秒と少しずつ遅らせて鳴らしています。
ただ、正直なところ、音を重ねても2個以上だとあんまり差は感じませんでした。
感想
exponentialRampToValueAtTime で音量を徐々に下げると音が自然に消えますが、これを使わずに止めると音が急に切れてしまうので知らないと損します
作業面では、一度作った音を後から直すのが楽でした。音声ファイルだと差し替えが必要ですが、コードなら数値を変えるだけなので、長さや変化の仕方をすぐ試せます。短いゲームの効果音なら、Web Audio API はありだと思います。
ただ、今回はパラメータをAIに作ってもらった部分が多く、波形や数値の意味は分かっても、狙った音を自分で作るのが結構大変でした。
来週やりたいこと
- 効果音ごとの音量を調整する
- 今回はパラメータをAIに任せた部分が多かったので、波形や周波数を自分で動かして、狙った音を作れるようにする
- ゲームの状態に合わせて、より気持ちいい効果音に調整する