前端也能造乐器:用 Web Audio 从零合成钢舌鼓音色,再用 Three.js 把它敲响

0 阅读7分钟

在线体验:zhimalab.tech/games/mingd… 这是一个纯前端实现的「空明鼓」——钢舌鼓在线模拟器。没有采样音频,没有后端,一个静态页面 + 一段 TypeScript 就完成了合成器、3D 鼓面、粒子特效和录制回放。

最近做儿童编程与音乐启蒙站点(芝麻园地)时,想给孩子们一件「打开就能玩、怎么敲都好听」的虚拟乐器。选了钢舌鼓——也就是大家常说的空灵鼓、tongue drum。它音色空灵、延音悠长,天然自带「治愈感」,而且只要音阶设计得当,随手乱敲都不会难听,特别适合零基础启蒙。

需求定了:零采样、零后端、纯前端。于是就有了这篇文章——把整个实现拆开讲讲,希望能给想做「网页乐器」的读者一些参考。

一、音色:不用采样,用 Web Audio 合成

钢舌鼓的声学特征可以概括成四个词:极快起音、强基频、轻微非谐波的金属泛音、长指数尾音。这四个特征用 Web Audio 的振荡器完全可以模拟,根本不需要音频采样文件。

我把它建模成「基频 + 5 个非谐波泛音 + 一个撞击瞬态」:

const PARTIALS = [
  { mult: 1.0, gain: 1.0, decay: 2.8, type: 'triangle' }, // 基频用三角波略暖
  { mult: 2.0, gain: 0.55, decay: 2.2, type: 'sine' },
  { mult: 2.71, gain: 0.38, decay: 1.8, type: 'sine' },  // 非整数倍 → 金属感
  { mult: 3.02, gain: 0.28, decay: 1.5, type: 'sine' },
  { mult: 3.57, gain: 0.18, decay: 1.1, type: 'sine' },
  { mult: 4.28, gain: 0.1,  decay: 0.9, type: 'sine' },
];

几个关键点:

  • 基频用三角波,比纯正弦更暖、更有「木头感」,贴合钢舌鼓的敲击质感。
  • 泛音是非整数倍的(2.71、3.02、3.57、4.28)——金属之所以听起来是「金属」,就是因为这些不和谐泛音。整数倍泛音(比如 2.0、3.0)是弦乐/管乐的声音,会「太干净」。
  • 每个泛音的衰减时间递减:高频泛音消散得快,低频基频留得久,这样延音尾部会自然变「空灵」。
  • 另外加了一点 ±0.3 音分的随机微调,防止连续敲同一音时产生拍频。

金属敲击还有一个「啵」的瞬态质感,来自短促的带通噪声:

const noise = ctx.createBufferSource();
noise.buffer = buf;                          // 40ms 衰减噪声
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 2200;                   // 带通在 2.2kHz

混响:程序化 IR,零资源

空灵感的另一半来自混响。我没有找混响采样,而是在代码里现场生成一个 2.5 秒的指数衰减立体声噪声 IR,喂给 ConvolverNode

function buildIR(ctx) {
  const buffer = ctx.createBuffer(2, length, rate);
  for (let ch = 0; ch < 2; ch++) {
    for (let i = 0; i < length; i++) {
      const decay = Math.pow(1 - i / length, 2.2); // 指数衰减包络
      data[i] = (Math.random() * 2 - 1) * decay;   // 两声道独立随机 → 立体声宽度
    }
  }
  return buffer;
}

左右声道各自独立的随机噪声天然形成立体声宽度,指数衰减包络让混响「来得很轻、散得很远」。整个音频链路是:

合成(strike) → sfxBus → 压缩器 → dry ──────────┐
                              │        → 混响 → wet ──┤ → master → Analyser → 输出

中间挂了一个 DynamicsCompressor(threshold -18dB,ratio 6),多点触控同时敲很多舌片时也不会削波。

二、音阶:让它「怎么敲都好听」

钢舌鼓的精髓是无论怎么敲都不难听,这全靠音阶设计。我用的是大调自然音阶,7 个音为一个八度:

const DIATONIC_OFFSETS = [0, 2, 4, 5, 7, 9, 11]; // C D E F G A B

鼓面有两种模式:

  • 8 舌:C3 ~ C4,一个完整八度(C D E F G A B C),键盘 ASDFGHJK;
  • 15 舌:C3 ~ C5,两个完整八度(多出 7 个音),键盘 A Q W E R T Y U I O P 1 2 3 4。

并且支持 C / D / G 三个调式移调——其实只是把根音平移几个半音:

const SCALE_SEMITONE = { C: 0, D: 2, G: -5 };
const root = 48 + SCALE_SEMITONE[scale]; // C3 = MIDI 48

MIDI 号转频率用标准公式:440 * 2^((midi - 69) / 12)

三、鼓面:Three.js 里的 3D 舌片

鼓面用 Three.js 搭了一个 3D 场景:WebGL 渲染器 + 渐变天空穹顶 Shader + 半球光/方向光 + PMREM 房间环境贴图(给金属一点高光,但不刺眼)。

舌片几何:拉伸 + 摊平

每一片舌片都是一个「环形扇形」,用 ExtrudeGeometry 挤出厚度后旋转摊平到 XZ 平面:

const shape = makeTongueShape(innerR, outerR, start, sweep); // 外弧 + 内弧闭合
const geo = new THREE.ExtrudeGeometry(shape, {
  depth: 0.05, curveSegments: 28,
  bevelEnabled: true, bevelSize: 0.008, bevelThickness: 0.01,
});
geo.rotateX(-Math.PI / 2); // 摊平到 XZ 平面

舌片按音高排列:低音在外圈更长、更靠近中心,高音更短——这和真实钢舌鼓一致。舌片之间的切缝(开槽)也按真实乐器做了,模拟「留出缝隙的音盘」造型。

命中检测:一个 Raycaster 搞定

触屏/鼠标点下去,用 Raycaster 从相机射一条线,找到命中的舌片:

this.raycaster.setFromCamera(ndc, camera);
const hits = this.raycaster.intersectObjects(this.tongueMeshes, false);

命中后触发统一的「敲击」入口:音频合成 + 舌片按压下沉动画 + 视觉爆发。多点触控用 Map<pointerId, tongueIndex> 维护,能同时按住多个舌片敲出和弦。

音名标注:Canvas 纹理「雕刻」在舌面上

为了让孩子认识音名,每个舌片上刻了 C3/D3/E3… 字样。实现方式是在 Canvas 上画「凹槽阴影 + 底部高光 + 暖象牙色填充」的字,生成 CanvasTexture 贴到舌面——模仿中国象棋刻字那种「雕刻后涂色」的质感:

ctx.strokeStyle = 'rgba(0,0,0,0.55)';      // 凹槽边缘暗线
ctx.fillText(text, x, y - 2);               // 顶部阴影(凹陷感)
ctx.fillStyle = 'rgba(255,255,255,0.5)';    // 底部受光(金属高光)
ctx.fillText(text, x, y + 2);
ctx.fillStyle = 'rgb(234,216,174)';         // 暖象牙色填充
ctx.fillText(text, x, y);

四、音频驱动视觉:粒子跟着音高变色

这是我最喜欢的部分——让画面跟着声音走

音频引擎里有一个 AnalyserNode,每帧读取频域数据,算出低频/中频/高频和整体 RMS 能量(一阶平滑避免抖动):

const f = this.features;
f.bass += (target.bass - f.bass) * 0.12;
f.rms += (target.rms - f.rms) * 0.12;

然后这些能量去驱动视觉:

  • Bloom 强度跟随整体音量(0.15 ~ 0.55,刻意压低上限护眼);
  • 方向光强度跟随 RMS,敲得越响鼓面越亮;
  • 敲击粒子按音高映射色相:低音暖金(35°)→ 高音冷紫(270°),从鼓面舌片中心爆发,外加一圈向外扩散的音波涟漪。

后处理用的是 UnrealBloomPass,配合 OutputPass 做色彩空间校正。

五、录制回放:事件流 + AudioContext 精确排时

录制很简单:把每次敲击记成事件流 { t, tongueIndex, velocity },t 是相对会话开始的时间。

回放要讲究一点——为了节奏准确,把事件按时序调度:

const dueAt = playStartWall + ev.t;
if (dueAt > now) break;                     // 还没到点
const delay = Math.max(0, (dueAt - now) / 1000);
onEvent(ev.tongueIndex, ev.velocity, delay); // 剩余延迟交给合成器排时

合成器拿到 delay 后,用 ctx.currentTime + delay 精确排时发声,这样即使渲染帧率不稳,音频依然踩在准确的时间点上。支持循环回放,按 Space 停止。

六、性能与适配:把体验做扎实

网页乐器的性能问题主要来自移动端。我做了一个 detectQuality() 自动降档:

const coarse = matchMedia('(pointer: coarse)').matches;   // 触屏
const mem = navigator.deviceMemory;                        // 内存
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

// 低端设备:粒子减到 600、关 Bloom、渲染分辨率 0.75
// 桌面:2200 粒子 + Bloom(尊重减少动效偏好)

响应式也做了兜底:resize 事件经常在布局就绪前触发,所以用 requestAnimationFrame 把测量推迟到布局完成之后,再叠加 ResizeObserverorientationchange 延迟重测、visualViewport 三保险,保证手机旋转屏幕、地址栏显隐都不变形。

另外别忘了浏览器自动播放策略:AudioContext 默认是 suspended,需要在首次用户手势ctx.resume(),所以页面挂了 pointerdown/keydown 的一次性监听来解锁。

写在最后

整个项目是纯静态的,没有后端、没有框架依赖,核心代码就十几个 TypeScript 文件:合成器、鼓面、视觉引擎、录制回放、状态总线。如果你也在做「在线乐器」或「声音 × 视觉」的互动内容,这套「合成器 + Analyser 驱动视觉 + 事件流回放」的组合拳值得一试。

在线体验(免费,浏览器打开即玩):

如果这篇文章对你有启发,欢迎点赞收藏,也欢迎去敲两下提提意见——毕竟「怎么敲都好听」的乐器,得让更多人敲了才算数。😄