Published on

Web 钢琴

Authors

前言

做一款 Web 钢琴的想法由来已久。早年上学就选修过钢琴课,然后在学 Java 还是 C# 做编程作业的时候,就做过一个模拟钢琴的应用“丝竹乱耳”。来看看当年几乎没有乐理知识、对时序编程也毫无头绪的青涩想法:

piano-2008

当时实现的效果大概接近给 3 岁以内小朋友买的那种玩具钢琴,可以按键发音,可以保存,可以播放。但保存的“音乐格式”只是一个用字符编码对应音符的 txt,没有节拍节奏的概念,仅仅定义了音符的先后顺序。对于人机交互和音乐的喜爱,大概从那时候就埋下种子了。只可惜在这两方面水平一直实在有限。

工作以后,机缘巧合转做前端以来,依依希希有用 Web 技术再做一次音乐相关的应用的想法,尤其小孩开始学钢琴之后,家里的电子钢琴让这个想法愈发强烈。LLM 的普及,让探索这件事的心智负担越发低了之后,决定动手再做一次钢琴应用。

Web Audio

要正儿八经用 Web 技术做模拟钢琴,Web Audio 是首先要去了解的。老的思路(Flash 插件、HTML5 Audio 元素)当然也可以,存储钢琴琴键对应的所有音频,按键时用 audio 节点去播放。但这样会面临非常多的问题。譬如你很难去模拟普通的钢琴演奏,在单线程的页面中,当多个琴键同时激发时,老思路是无论如何做不优雅的。更不用说更复杂的场景了,譬如混音、拉伸时间轴等。

Web Audio 正是面向复杂音频处理场景而生。它的出现就是为了解决在 Web 语境下的游戏、音频应用中的复杂音频处理问题。显然它能做的,比“实现 Web 钢琴模拟应用”要全面、复杂得多。并且实现钢琴模拟这件事,使用如此底层的接口,显然 ROI 并不高。但开始动手实现之前,我们还是了解一下 Web Audio 相关的 API。

Web Audio 系列 API 的设计把音频处理这件事抽象成了“图”。W3C 里有两个例子:

最基础的、连接一个音源直接播放的例子

AudioContext
destination
source
const context = new AudioContext()

function playSound() {
  const source = context.createBufferSource()
  source.buffer = dogBarkingBuffer
  source.connect(context.destination)
  source.start(0)
}

连接三个音频源,并在最终输出阶段使用动态压缩器发送卷积混音结果的例子

AudioContext
LowPass
Filter
Source 1
Source 2
Waveshaper
Distortion
Source 3
Panner
dry1
dry2
dry3
wet1
wet2
wet3
Convolution
Reverb
Main dry-gain
Main wet-gain
Dynamics
Compressor
Destination
let context
let compressor
let reverb
let source1, source2, source3
let lowpassFilter
let waveShaper
let panner
let dry1, dry2, dry3
let wet1, wet2, wet3
let mainDry
let mainWet
function setupRoutingGraph() {
  context = new AudioContext()
  // Create the effects nodes.
  lowpassFilter = context.createBiquadFilter()
  waveShaper = context.createWaveShaper()
  panner = context.createPanner()
  compressor = context.createDynamicsCompressor()
  reverb = context.createConvolver()
  // Create main wet and dry.
  mainDry = context.createGain()
  mainWet = context.createGain()
  // Connect final compressor to final destination.
  compressor.connect(context.destination)
  // Connect main dry and wet to compressor.
  mainDry.connect(compressor)
  mainWet.connect(compressor)
  // Connect reverb to main wet.
  reverb.connect(mainWet)
  // Create a few sources.
  source1 = context.createBufferSource()
  source2 = context.createBufferSource()
  source3 = context.createOscillator()
  source1.buffer = manTalkingBuffer
  source2.buffer = footstepsBuffer
  source3.frequency.value = 440
  // Connect source1
  dry1 = context.createGain()
  wet1 = context.createGain()
  source1.connect(lowpassFilter)
  lowpassFilter.connect(dry1)
  lowpassFilter.connect(wet1)
  dry1.connect(mainDry)
  wet1.connect(reverb)
  // Connect source2
  dry2 = context.createGain()
  wet2 = context.createGain()
  source2.connect(waveShaper)
  waveShaper.connect(dry2)
  waveShaper.connect(wet2)
  dry2.connect(mainDry)
  wet2.connect(reverb)
  // Connect source3
  dry3 = context.createGain()
  wet3 = context.createGain()
  source3.connect(panner)
  panner.connect(dry3)
  panner.connect(wet3)
  dry3.connect(mainDry)
  wet3.connect(reverb)
  // Start the sources now.
  source1.start(0)
  source2.start(0)
  source3.start(0)
}

Web MIDI

Web MIDI 则是另一套与数字音频相关的 API,是一套枚举、访问、操作 MIDI 设备相关的 API。

什么是 MIDI 设备?MIDI(Musical Instrument Digital Interface)是数字音乐的制作和演奏中广泛使用的协议。各种电子乐器和控制器都可以通过 MIDI 标准接口连接到计算机或者其他 MIDI 设备,实现音乐数据传输和控制。

而由此发展的音频存储格式也叫 MIDI 文件,后缀是 .mid 或 .midi。看一个 MIDI 文件的例子,大家就大致理解所谓 MIDI 标准和数字音频的轮廓了。

解析后的 MIDI 文件内容

{
  "header": {
    "format": 1,
    "numTracks": 2,
    "division": 480
  },
  "tracks": [
    {
      "notes": [
        {
          "duration": 0.321096,
          "durationTicks": 240,
          "midi": 57,
          "name": "A3",
          "ticks": 240,
          "time": 0.321096,
          "velocity": 0.3779527559055118
        },
        {
          "duration": 1.9325532500000002,
          "durationTicks": 1560,
          "midi": 64,
          "name": "E4",
          "ticks": 480,
          "time": 0.642192,
          "velocity": 0.4094488188976378
        },
        {
          "duration": 0.06666662499999987,
          "durationTicks": 60,
          "midi": 62,
          "name": "D4",
          "ticks": 2040,
          "time": 2.5747452500000003,
          "velocity": 0.33070866141732286
        }
      ]
    },
    {
      "notes": [
        {
          "duration": 0.321096,
          "durationTicks": 240,
          "midi": 81,
          "name": "A5",
          "ticks": 240,
          "time": 0.321096,
          "velocity": 0.47244094488188976
        },
        {
          "duration": 1.9325532500000002,
          "durationTicks": 1560,
          "midi": 88,
          "name": "E6",
          "ticks": 480,
          "time": 0.642192,
          "velocity": 0.5196850393700787
        },
        {
          "duration": 0.06666662499999987,
          "durationTicks": 60,
          "midi": 86,
          "name": "D6",
          "ticks": 2040,
          "time": 2.5747452500000003,
          "velocity": 0.4330708661417323
        },
        {
          "duration": 0.06666662499999987,
          "durationTicks": 60,
          "midi": 88,
          "name": "E6",
          "ticks": 2100,
          "time": 2.641411875,
          "velocity": 0.3700787401574803
        },
        {
          "duration": 0.26318350000000024,
          "durationTicks": 240,
          "midi": 86,
          "name": "D6",
          "ticks": 2160,
          "time": 2.7080785,
          "velocity": 0.4881889763779528
        }
      ]
    }
  ]
}

最核心存储数字音乐的部分是“tracks”,每一个音轨就是一个 track。每个音轨的存储内会保存多个按时序排序的音符。每个音符的定义,以上述钢琴曲的 MIDI 文件举例,里面包含几个关键属性:音符的音调、持续时间、音符音量。这些信息就是使用 MIDI 设备演奏时录制的信息,也可作为回放和处理时候的输入。

模拟钢琴应用的需求

首先需要梳理一下我们的 Web 钢琴要做成什么样子。参考最早做这个应用的发心,以及目前成熟的应用,MVP 版本大概需要跑通这样的流程:

  1. 在 Web 钢琴上可以鼠标按压发音,也能连按发音
  2. 能载入 MIDI 文件播放,以及还原琴键按压的状态

在这个基础上,后续可以扩展更多的高阶能力,譬如:

  • 映射键盘快捷键,使用电脑键盘操作 Web 钢琴(优先级低)
  • 能连接到 MIDI 设备,使用电子钢琴操作 Web 钢琴
  • 电子钢琴或者键盘的演奏能保存成 MIDI 文件
  • 可视化
    • 音频频域分析(使用 Web Audio 的 AnalyserNode)
    • 钢琴弹奏使用方块掉落模拟音符和出现时机、时长
  • 播放控制
    • 播放/暂停/停止
    • 快进/快退
    • 倍速控制
    • 音量控制
    • 滑动控制
  • 乐器支持和切换

如果 MVP 功能 + 扩展的高阶功能都完成的话,基本上就是一个功能完备的 MIDI 播放器,不限于钢琴曲,能播放交响乐的那种。

最终本次实验所实现的,除了 MVP 版本能力以及基本的 MIDI 音频播放控制以外,还参考 Synthesia、Midiano 实现了 MIDI 音频的方块掉落演奏效果。为什么先做这个,是因为具备这个可视化效果之后,可以方便自己自学钢琴 ;-)

实现思路

OK,目前为止,完成一个 Web 钢琴应用的前置条件已经齐备了。如果要从最底层开始,一步一步做好这件事,其实还有一个技能树需要点,那就是动画和时序编程相关的能力。音频处理对 ticks 的管理要求非常严苛。不过这个话题也很大,以后可以单开一篇讨论。实际应用我们就采用成熟的、带时序管理的 Web Audio 音频库好了。

绘制钢琴

首先最基本的问题要解决的是,怎么样画出来一个钢琴(琴键),然后按对应琴键时能发出钢琴声音呢?

市面上大部分标准钢琴是 88 键(52 白键,36 黑键),音阶从 A0 到 C8。对应 MIDI 音阶从 21 到 108,对照关系如下:

// MIDINotes.tsx
export interface IMIDINote {
  number: number
  keyNumber?: number
  name?: string
  frequency: number
}
export const MIDINotes: IMIDINote[] = [
  { number: 21, keyNumber: 1, frequency: 27.5, name: 'A0' },
  { number: 22, keyNumber: 2, frequency: 29.14, name: 'Bb0' },
  { number: 23, keyNumber: 3, frequency: 30.87, name: 'B0' },
  { number: 24, keyNumber: 4, frequency: 32.7, name: 'C1' },
  { number: 25, keyNumber: 5, frequency: 34.65, name: 'Db1' },
  { number: 26, keyNumber: 6, frequency: 36.71, name: 'D1' },
  { number: 27, keyNumber: 7, frequency: 38.89, name: 'Eb1' },
  { number: 28, keyNumber: 8, frequency: 41.2, name: 'E1' },
  // ...
  { number: 103, keyNumber: 83, frequency: 3135.96, name: 'G7' },
  { number: 104, keyNumber: 84, frequency: 3322.44, name: 'Ab7' },
  { number: 105, keyNumber: 85, frequency: 3520.0, name: 'A7' },
  { number: 106, keyNumber: 86, frequency: 3729.31, name: 'Bb7' },
  { number: 107, keyNumber: 87, frequency: 3951.07, name: 'B7' },
  { number: 108, keyNumber: 88, frequency: 4186.01, name: 'C8' },
]

整理这样的元数据使用 LLM 再简单不过了。绘制策略很简单,我选用的是 DOM + CSS 的方式实现,根据钢琴琴键的分布特点,应用 flex 布局,然后白键均分横向宽度,黑键插空画一半就可以了。这里的小技巧是,黑键可以包裹在一个宽度为 0 的父容器中,黑键再设置本身宽度(通常是白键宽度一半),再向左偏移一半宽度即可。这种布局方式可以比较好地适应各种宽度的场景,但会有一定的问题,毕竟琴键总数有 88 个,单看白键也需要横向布局 52 个琴键。在过窄的容器中,容易挤压,不易操作。更好的方式,其实是以中央 C 为中心,只展示最常用的音域对应琴键,播放 MIDI 文件时也是如此,可以根据当前播放音符的音域范围,动态地展示几个八度的音域就可以了,这样琴键的外观和可交互性是最好的,但实现难度较高。

鼠标按压发音

要模拟某种乐器的声音,使用 Tonejs 的方案是加载这种乐器对应所有 MIDI 音符的采样音频。普通大钢琴、高音大钢琴的采样各不相同,表现出来的音色也不同。MIDI 标准对不同乐器的音色处理就是定义对应的 instrument,然后实际应用中对这个 instrument 加载对应音阶的采样音频。

Tonejs 处理这件事非常简单,只需要用 Sampler 加载,connect 对应的音源,然后连接到输出设备即可。

useEffect(() => {
  const loadSampler = async () => {
    const sampler = new Tone.Sampler({
      urls: MIDIPianoUrls,
      baseUrl: './',
      onload: () => {
        setSampler(sampler)
      },
    }).toDestination()
  }
  if (!sampler) loadSampler()
  return () => {
    if (sampler) {
      sampler.releaseAll()
      sampler.dispose()
    }
  }
}, [sampler])

这里又有个小技巧。一般来说某个乐器的某个音阶采样音频文件不是很大,如果按照默认的方式去加载采样音频,会有非常多细碎的 HTTP 请求(钢琴的场景就是发 88 次请求),这个对于应用加载体验而言是不好的。这里可以简单写个 Node.js 脚本,把某个乐器所有音阶的采样音频构建成一个 JSON 文件,直接在代码里引用这个文件即可。

加载完采样音频之后,模拟钢琴发音就很简单了,只需要给琴键组件绑定对应激发事件,在实践中播放采样音频即可:

const handleMouseEnter = () => {
  setIsHovered(true)
  if (isPressed) sampler.triggerAttack(note)
}
const handleMouseLeave = () => {
  setIsHovered(false)
  if (isPressed) sampler.triggerRelease(note)
}
const handleClick = () => {
  // @ts-ignore
  sampler.triggerAttackRelease(note)
}

MIDI 文件播放

激发单个琴键采样音频的流程跑通,那么播放 MIDI 文件就简单很多了。这里我们结合 Tonejs 时域调度的能力,使用它 Transport 相关接口,就可以顺畅播放 MIDI 文件了。

这里实现的前提是先做 MIDI 文件的解析。Tonejs 并不能直接处理二进制的 MIDI 文件,需要先使用社区的 MIDI 插件完成解析,然后对 MIDI 文件中的音轨就可以做进一步处理:

midi.tracks.forEach((track, trackIndex) => {
  track.notes.forEach((note) => {
    Tone.getTransport().scheduleOnce(() => {
      sampler.triggerAttackRelease(note.name, note.duration, note.time + now, note.velocity)
    }, note.time)
  })
})

Tonejs 的封装抽象层次还是挺高的,如果不用这个库,直接底层代码做时序管理、调用 Web Audio 相关能力的话,代码会比这个复杂不少。

可视化

接下来是好玩的部分。怎么样能在 MIDI 文件播放时,还原琴键的按压状态,并且用方块掉落的效果来模拟音轨?

琴键按压效果

还原琴键按压是相对简单的。给琴键组件赋予一个“激活状态”,然后在对应音符播放时,指定激活状态即可。熟悉动画编程的同学应该清楚,直接在上述播放音频的代码里,加上对应的状态处理就可以了。这里我目前采用的方式是性能不够好的方法,直接把正在播放的音符放到一个数组,渲染琴键时对照是否在数组中,从而决定要不要为组件传入 actived 属性。相当于全程依赖 React 的更新机制来做。这种做法性能上损耗其实很大,因为音频的播放基本上会在一十六分音符空隙就会有两个以上的事件触发,DOM Diff、组件状态更新带来的开销其实相当占用性能。

不过 Web Audio 本身的处理是 Native 的,实际不会占用前台 JS 线程资源,所以目前来看这个方案似乎还好。后续如果要优化这一点,可以在渲染完琴键之后,维持一个音符对应 88 琴键的 DOM 对象,每次需要更新 actived 状态时就直接 DOM 指令更新就可以了,这是开销最低的方式。

方块掉落效果

方块掉落的可视化效果是比较有意思的。绘制方块很简单,方块的高度就是 MIDINote 的 duration 属性做个映射就可以了。问题在于如何在正确的 timing 让方块动画下落。比较传统,并且性能会比较好的方式,是确定好可视化视窗高度之后,预估时长,渲染三屏左右的方块,这三个屏轮番滚动下落,可以最大限度复用 DOM 和节省 DOM 属性变更的开支(参考虚拟滚动相关的实现)。

但如果采用 DOM 结构结合虚拟滚动的话,DOM 管理带来的代码复杂度会增加很多,并且每帧内要做的处理都要求很精细,代码维护会变得困难。但如果要做成生产级长期项目,DOM 的方案还是需要往这个方向去做。要么就用 Canvas / WebGL 来绘制。

DOM 的方案有没有更简单的?综合考虑各个效果和要求,最简单的方式其实是先用 SVG rect 把所有方块提前绘制好,然后配合播放进度来 translate 整个方块画布,就可以实现了。其实在相对少量(5 分钟以内的 MIDI 文件)的场景下,这种处理反而是性能更好的,因为没有进一步的 DOM 维护开销,每一帧里只需要对所有方块的父节点 g 做偏移就可以了。只有一个 DOM 的一个属性值发生变化,还是比较节能的。

确定简单思路之后,核心绘图代码就很简单了:

// 将方块添加到 <g> 元素中
const gElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
gElement.setAttribute('class', 'note-group')
// 遍历 MIDI 数据,绘制方块
midi.tracks.forEach((track, trackIndex) => {
  const colorArray = trackIndex % 2 === 0 ? AntD_DustRed_10 : AntD_PolarGreen_10
  track.notes.forEach((note, noteIndex) => {
    const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
    const noteNumber = note.midi
    const isBlack = note.name.includes('b') || note.name.includes('#')
    const x = calculateX(noteNumber)
    const y = maxHeight + VISUALIZER_HEIGHT - (note.time + note.duration) * H_SCALE // 从下往上计算 y 坐标
    const width = isBlack ? BLACK_KEY_WIDTH : WHITE_KEY_WIDTH
    const height = note.duration * H_SCALE
    rect.setAttribute('x', x.toString())
    rect.setAttribute('y', y.toString())
    rect.setAttribute('width', width.toString())
    rect.setAttribute('height', height.toString())
    rect.setAttribute('fill', colorArray[note.midi % colorArray.length])
    rect.setAttribute('stroke', '#000000')
    // noteElements.push(rect)
    gElement.appendChild(rect)
  })
})
svg.appendChild(gElement)

这里有一个小问题需要注意,就是计算方块 y 坐标的时候,需要考虑一个点:方块是自上而下掉落的,而 SVG 坐标原点是左上角,y 坐标往下正值增大。因此绘制方块时,最先出现的方块 y 坐标是接近 0,剩余方块 y 坐标依次变小,最后的方块是一个极小的负值。SVG 动画其实就是每一帧让父容器 g 产生一个 y 方向的正向位移。

绘制好 SVG 之后,剩下的工作就是对齐琴键、微调动画效果等等了。其实整个过程也是,确定好核心思路之后,交给 GPT4 和 Claude,同一个实现点对话调整个三五次,基本都能很好地完成任务。

结果和相关链接

项目目前的状态如下:

web-piano

可以到这里体验具体效果:

参考链接