没有音频文件的伽耶琴 ― 用 Web Audio 在运行时合成的游戏 BGM
音声ファイルのない伽耶琴 ― Web Audioで実行時に合成するゲームBGM
译文尚不完整,完整内容请切换到原文。
一款小巧的移动端弹反游戏,其机制是在运行时逐音计算并播放伽耶琴(가야금)BGM。在原文的演示中你可以实际弹奏它。

《墨之剑客》(Ink Parry: Seonbi Swordsman)是一款以朝鲜时代为背景的单手弹反游戏。攻击从圆外飞来,在命中的瞬间朝该方向滑动手指即可将其弹开。我想让声音带有韩国特色,而对我来说,韩国的声音就是伽耶琴。就是那种用左手按压、摇动、推起绢丝琴弦所发出的声音。
没有可用的录音,游戏必须轻到能在移动端 WebView 中运行。因此这个游戏中没有任何音频文件。音乐和音效全部由约 1,100 行运行在 Web Audio API 上的 TypeScript 代码生成。这篇文章就是对该机制的总结。
🎧 在原文中,你可以用浏览器实际弹奏与游戏使用相同代码的伽耶琴:https://boreumdal.app/devlog/gayageum/ja/
\n 先听一听
这是直接从游戏合成器离线导出的声音。除了最后的淡出外,没有做任何处理。
- 标题画面: https://boreumdal.app/devlog/gayageum/audio/title.mp3
- 逐渐加速的演奏: https://boreumdal.app/devlog/gayageum/audio/play.mp3
- 最终BOSS: https://boreumdal.app/devlog/gayageum/audio/boss.mp3
- 格挡音效: https://boreumdal.app/devlog/gayageum/audio/hits.mp3
1. Karplus-Strong 在正确音高下
Karplus-Strong 是一种经典的拨弦音生成方法。用噪声填充一个短延迟缓冲区,然后反复将其输出通过平滑平均滤波器并反馈回去。缓冲区的长度决定了音高,而每次取平均时,高频都会逐渐消退。这与真实琴弦振动并衰减的过程相同。
问题在于音高。由于延迟缓冲区的长度必须是整数个采样点,在 44.1kHz 下,弦越高,音高的偏差就越明显。因此在环路内加入了一阶全通滤波器,以补偿不足的小数采样点。
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听起来像明亮的钢弦或羽管键琴。伽耶琴的琴弦是捻制的蚕丝,因此拨弦的瞬间较为柔和,发出带有中空腔体感的音色。三个小调整已基本接近了效果。点击上方演示中的"丝弦激励"按钮即可听到区别。
- 柔和的激励。 将最初输入的噪声在进入环路前通过平均滤波器两次。瞬态的高频成分随之减少,音色变得圆润而富有弹性。
- 拨弦位置。在弦长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),短音也越多。无限模式依次经历昼、黄昏、夜、黎明,音域会升高或降低,夜晚时颤音加深。Boss战中,由经过滤波的噪声和音高滑动的正弦波制作的长鼓节奏会叠加进来。
7. 音乐使用音频时钟而非游戏时钟
弹反游戏偏爱在重击时用数帧的击停冻结时间。若音乐跟随游戏时间,每次击停都会让节拍卡顿。因此调度器只查看 AudioContext.currentTime。每帧仅预约未来 0.4 秒内的节拍,与游戏时间完全无关。
混音始终确保弹反是主角。游玩时音乐约为 −17dBFS,弹击声会升至 −6 附近。每次弹反时,音乐在 20ms 内降至 45%,再经约 0.3 秒恢复。当体力接近耗尽时,音乐整体的低通从 4.2kHz 滑向 700Hz,无需额外加音效就呈现出如同水下的闷浊感。



想对最初的自己说的话
- 首先对齐音高(全通滤波器)。音高若有偏差,其他一切听起来都会比实际更糟。
- 激励才是乐器。"丝绸"感的大部分并非来自循环,而是来自打磨最初几毫秒。
- 比起音色,奏法更为关键。带有恰当时间差的颤音与推升,比任何EQ都更能营造那种"韩味"。
- 离线导出为WAV文件并用耳机监听。准备了将标题、第一局及各个Boss分别写入文件的脚本,每次更改混音时都确认了峰值与RMS。
由于并非采样的伽耶琴,真正的演奏者想必能听出差别。即便如此,它依然轻柔、不重复,始终与游戏的声音相契合,听起来就像游戏舞台所在的那片土地的声音。那就是目标。
墨之剑客(Ink Parry: Seonbi Swordsman)现已在iPhone和Android平台上线。买断制,无广告,无应用内付费。
来源:zenn · 游戏开发社区 · zenn.dev