跳到正文
zenn · 游戏开发社区· 来源· boreumdal·· 10 小时前AI 评分61

没有音频文件的伽耶琴 ― 用 Web Audio 在运行时合成的游戏 BGM

音声ファイルのない伽耶琴 ― Web Audioで実行時に合成するゲームBGM

正文 · 原文

小さなモバイルのパリィゲームが、伽耶琴(カヤグム)のBGMを一音ずつ実行時に計算して鳴らす仕組み。元記事のデモで実際に弾けます。

『墨の剣客』(Ink Parry: Seonbi Swordsman)は、朝鮮時代を舞台にした片手で遊べるパリィゲームです。円の外から攻撃が飛んできて、当たる瞬間にその方向へ指をスワイプすると弾き返せます。音だけは韓国らしくしたくて、私にとって韓国の音といえば伽耶琴でした。絹糸の弦を左手で押さえ、揺らし、押し上げるあの音です。

使える録音はなく、ゲームはモバイルのWebViewでも動くほど軽くなければなりませんでした。そこでこのゲームには音声ファイルが一つもありません。音楽も効果音も、Web Audio APIの上で動くTypeScript約1,100行がすべて生成しています。この記事はその仕組みのまとめです。

🎧 元記事では、ゲームと同じコードの伽耶琴をブラウザで実際に弾けます:https://boreumdal.app/devlog/gayageum/ja/

まずは聴いてください

ゲームの合成器からオフラインでそのまま書き出した音です。最後のフェード以外は手を加えていません。

1. Karplus-Strong を正しい音程で

Karplus-Strong は撥弦音を作る古典的な手法です。短い遅延バッファをノイズで満たし、その出力をなめらかな平均フィルタに通して戻すことを繰り返します。バッファの長さが音の高さを決め、平均をとるたびに高音が少しずつ消えていきます。本物の弦が鳴って減衰していくのと同じです。

問題は音程です。遅延バッファの長さはサンプル単位の整数なので、44.1kHzでは高い弦ほど音がはっきりずれます。そこでループ内に1次オールパスフィルタを入れ、足りない小数サンプル分を補いました。

const period = sampleRate / freq;
const L = Math.floor(period - 0.5);   // 整数サンプル(平均フィルタが0.5足す)
const d = period - 0.5 - L;           // 残りの小数
const C = (1 - d) / (1 + d);          // オールパス係数

// サンプルごとに:
const avg = 0.5 * (x + last) * rho;   // 平均 + 減衰
const ap  = C * avg + apX - C * apY;  // 小数遅延

弦ごとの余韻はT60(音が60dB下がるまでの時間)で決めます:rho = 10 ** (-3 / (freq * t60))。低い弦は約3秒、高い弦は1秒未満鳴るので、低音は広がり高音はくっきりします。

2. 鋼ではなく絹の音に

素のKarplus-Strongは、明るい鋼の弦やチェンバロのように聞こえます。伽耶琴の弦は撚った絹糸なので、弾いた瞬間が柔らかく、中が空洞のような音がします。小さな工夫三つでほとんど近づきました。上のデモの「絹の励振」ボタンで違いを聴けます。

  • 柔らかい励振。 最初に入れるノイズを、ループに入れる前に平均フィルタへ2回通します。立ち上がりの高域が減って、丸く弾けます。
  • 弾く位置。 弦の長さの1/7を弾くと、そこが節になる倍音が消えます。励振をL/7ずらしたコピー(0.85倍)を引くくし形フィルタで真似しました。空洞のような響きはここから来ます。
  • 木の胴。 すべての弦を260Hzのピーキング EQ(+4dB)と4.2kHzのローパスに通します。桐の胴のごく大雑把な代わりですが、合成音特有のざらつきが消えます。

最後に、音の頭を4msかけて立ち上げ、「カチッ」というクリックの代わりに指先が絹に触れる感じを出しました。

3. 一度焼いて、軽く鳴らす

音ごとにループをリアルタイムで回すのはスマホでは無駄です。そこで弦が初めて必要になったときに一度だけ AudioBuffer に焼き(弦ごとにノイズのシードが固定なので毎回同じ音)、AudioBufferSourceNode で鳴らします。ゲーム全体で使う弦は多くても二十数本なので、キャッシュも小さく済みます。

4. 左手:弄絃と押し上げ

伽耶琴を伽耶琴らしくするのは弾く音ではなく、その後の左手です。柱(じ)の向こう側で弦を押して音を揺らしたり(弄絃・ノンヒョン)、押し上げて戻したり(コッキ)します。どちらもバッファの playbackRate の自動化だけです。

// 押し上げ:2半音上げて戻す
const up = 2 ** (2 / 12);
rate.setValueAtTime(1, t + 0.18);
rate.linearRampToValueAtTime(up, t + 0.34);
rate.setValueAtTime(up, t + 0.50);
rate.linearRampToValueAtTime(1, t + 0.68);

// 弄絃:弾いた後にだんだん深くなる5.2Hzのビブラート
curve[i] = 2 ** (depth * Math.min(1, u * 2.5) * Math.sin(u * dur * 2 * Math.PI * 5.2) / 12);
rate.setValueCurveAtTime(curve, t + 0.22, dur);

カーブの形より時間差のほうが大事でした。ビブラートは弾いてから約0.2秒後に始まり、だんだん深くなります。奏者がまず音を出してから揺らすように。

5. すべての音に一つの音階

すべての音高は一つの関数から出てきます。五音の平調(黄・太・仲・林・南)で、黄をE♭4付近の311.13Hzにしました。

const PYEONGJO = [0, 2, 5, 7, 9];        // 黄からの半音
function scaleFreq(step) {
  const oct = Math.floor(step / 5);
  const deg = ((step % 5) + 5) % 5;
  return 311.13 * 2 ** (oct + PYEONGJO[deg] / 12);
}

パリィ音も同じ関数を使うので、完璧なパリィは必ず音階のどれかの音に着地します(どの音かはスワイプの方向で変わります)。激しい戦いの最中でも弾く音とBGMがぶつからず、弾く音がほとんど旋律の一部のように聞こえます。

6. 同じことを繰り返さない作曲器

百回遊び直すゲームでループする曲はすぐ飽きます。そこで曲の代わりに、12拍のフレーズをその場で作る小さな作曲器を入れました。

  • 次の音は前の音から1〜2段上下し、2オクターブの中に収まります。
  • 長い音にはビブラート。短い音が仲に落ちると、ときどき林の方へ押し上げます。私の耳にはこれがかなり伽耶琴らしく聞こえました。
  • フレーズは必ず黄か林で休むので、終わった感じがします。
  • 低い弦が3拍ごとに支えます。
  • 新しいフレーズが直近60秒のどれかと同じ形なら、捨てて作り直します(最大8回)。

密度と速さはゲームから受け取ります。長く生き残るほど速くなり(76→102 BPM)、短い音が増えます。無限モードは昼・夕暮れ・夜・夜明けと進み、音域を下げたり上げたりし、夜はビブラートを深くします。ボス戦では、フィルタをかけたノイズと音高が滑るサイン波で作ったチャングのリズムが重なります。

7. 音楽はゲームの時計ではなくオーディオの時計で

パリィゲームは大きな一撃で数フレーム時間を止めるヒットストップが大好きです。音楽がゲーム時間に従うと、ヒットストップのたびに拍がつまずきます。そのためスケジューラは AudioContext.currentTime だけを見ます。毎フレーム、この先0.4秒以内に入る拍だけを予約し、ゲーム時間は一切関わりません。

ミックスは常にパリィが主役になるようにしました。プレイ中の音楽は−17dBFS前後、弾く音は−6付近まで上がります。パリィのたびに音楽は20ms以内に45%まで下がり、約0.3秒かけて戻ります。体力が尽きかけると、音楽全体のローパスが4.2kHzから700Hzへ滑り、音を足さなくても水中のようにこもります。

最初の自分に言いたいこと

  • まず音程(オールパス)を合わせること。音がずれていると、ほかのすべてが実際より悪く聞こえました。
  • 励振こそが楽器。「絹」らしさの大半は、ループではなく最初の数ミリ秒を整えることから生まれました。
  • 音色より奏法。適切な時間差をつけたビブラートと押し上げが、どんなEQよりも「韓国らしさ」に効きました。
  • オフラインでWAVに書き出してヘッドホンで聴くこと。タイトル・1プレイ・各ボスをファイルに書き出すスクリプトを用意し、ミックスを変えるたびにピークとRMSを確認しました。

サンプリングした伽耶琴ではないので、本物の奏者には違いがわかるでしょう。それでも小さく、繰り返さず、ゲームといつも音が合っていて、ゲームの舞台となった土地の音がします。それが目標でした。


墨の剣客(Ink Parry: Seonbi Swordsman)はiPhoneとAndroidで配信中です。買い切りで、広告もアプリ内課金もありません。

元記事(デモ付き): https://boreumdal.app/devlog/gayageum/ja/

来源:zenn · 游戏开发社区 · zenn.dev