小さなモバイルのパリィゲームが、伽耶琴(カヤグム)のBGMを一音ずつ実行時に計算して鳴らす仕組み。下のデモで実際に弾けます。
Boreumdal(FullMoon)・個人開発 · 2026年10月
『墨の剣客』(Ink Parry: Seonbi Swordsman)は、朝鮮時代を舞台にした片手で遊べるパリィゲームです。円の外から攻撃が飛んできて、当たる瞬間にその方向へ指をスワイプすると弾き返せます。音だけは韓国らしくしたくて、私にとって韓国の音といえば伽耶琴でした。絹糸の弦を左手で押さえ、揺らし、押し上げるあの音です。
使える録音はなく、ゲームはモバイルのWebViewでも動くほど軽くなければなりませんでした。そこでこのゲームには音声ファイルが一つもありません。音楽も効果音も、Web Audio APIの上で動くTypeScript約1,100行がすべて生成しています。この記事はその仕組みのまとめです。
弦をタップすると鳴ります。ゲームに入っている合成コードそのものが、今ブラウザで動いています。「絹の励振」をオフにすると教科書どおりの音と比べられます。
ゲームの合成器からオフラインでそのまま書き出した音です。最後のフェード以外は手を加えていません。
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秒未満鳴るので、低音は広がり高音はくっきりします。
素のKarplus-Strongは、明るい鋼の弦やチェンバロのように聞こえます。伽耶琴の弦は撚った絹糸なので、弾いた瞬間が柔らかく、中が空洞のような音がします。小さな工夫三つでほとんど近づきました。上のデモの「絹の励振」ボタンで違いを聴けます。
最後に、音の頭を4msかけて立ち上げ、「カチッ」というクリックの代わりに指先が絹に触れる感じを出しました。
音ごとにループをリアルタイムで回すのはスマホでは無駄です。そこで弦が初めて必要になったときに一度だけ AudioBuffer に焼き(弦ごとにノイズのシードが固定なので毎回同じ音)、AudioBufferSourceNode で鳴らします。ゲーム全体で使う弦は多くても二十数本なので、キャッシュも小さく済みます。
伽耶琴を伽耶琴らしくするのは弾く音ではなく、その後の左手です。柱(じ)の向こう側で弦を押して音を揺らしたり(弄絃・ノンヒョン)、押し上げて戻したり(コッキ)します。どちらもバッファの 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秒後に始まり、だんだん深くなります。奏者がまず音を出してから揺らすように。
すべての音高は一つの関数から出てきます。五音の平調(黄・太・仲・林・南)で、黄を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がぶつからず、弾く音がほとんど旋律の一部のように聞こえます。
百回遊び直すゲームでループする曲はすぐ飽きます。そこで曲の代わりに、12拍のフレーズをその場で作る小さな作曲器を入れました。
密度と速さはゲームから受け取ります。長く生き残るほど速くなり(76→102 BPM)、短い音が増えます。無限モードは昼・夕暮れ・夜・夜明けと進み、音域を下げたり上げたりし、夜はビブラートを深くします。ボス戦では、フィルタをかけたノイズと音高が滑るサイン波で作ったチャングのリズムが重なります。
パリィゲームは大きな一撃で数フレーム時間を止めるヒットストップが大好きです。音楽がゲーム時間に従うと、ヒットストップのたびに拍がつまずきます。そのためスケジューラは AudioContext.currentTime だけを見ます。毎フレーム、この先0.4秒以内に入る拍だけを予約し、ゲーム時間は一切関わりません。
ミックスは常にパリィが主役になるようにしました。プレイ中の音楽は−17dBFS前後、弾く音は−6付近まで上がります。パリィのたびに音楽は20ms以内に45%まで下がり、約0.3秒かけて戻ります。体力が尽きかけると、音楽全体のローパスが4.2kHzから700Hzへ滑り、音を足さなくても水中のようにこもります。
サンプリングした伽耶琴ではないので、本物の奏者には違いがわかるでしょう。それでも小さく、繰り返さず、ゲームといつも音が合っていて、ゲームの舞台となった土地の音がします。それが目標でした。
墨の剣客 はiPhoneとAndroidで配信中です。買い切りで、広告もアプリ内課金もありません。