用 Web Audio API 搭建“手风琴”

因为一些个人原因,我已经好长时间没有更新了,但别担心,我还在写代码。最近我去了趟德克萨斯,买了一架三排全音阶键钮式手风琴。大家应该知道,很多民俗音乐都会用全音阶手风琴,而民俗音乐大多都是口口相传流传下来的,这对我这个不太懂乐谱的人说是一件好事。

键盘式手风琴跟钢琴的逻辑和色彩布局是一样的,但我买的这个全音阶手风琴跟键盘式手风琴不一样,它有 34 个高音键钮,12 个低音键钮,而且同一键钮在推/拉风箱时发出的音符也不一样,所以准确来说,我的手风琴有 68 个高音音符(每个高音键钮有 2 个音符)。另外,众所周知,手风琴声音非常响亮。为了不吵到邻居,又能搞明白手风琴的布局逻辑,我打算搭建一个网页版应用。

我搭建了一个名为 KeyboardAccordion.com 的网页版应用,也是个 开源项目。我注意到电脑键盘的按键数量正好跟全音阶手风琴的键钮数量一样,并且布局也类似。所以,我记录了音符、音阶和和弦,开始思考如何把这两者结合在一起。

我的手风琴跟下图类似:

2

我决定用 Svelte 创建这个应用,因为我已经对使用 React 和 Vue 驾轻就熟了,但还没用过 Svelte,正好借这个项目学习一下。

Web Audio API

KeyboardAccordion.com 只有一个依赖,即 Svelte,其他操作都是通过原生 JavaScript 和内置浏览器 Web Audio API 完成的。我之前没有用过 Web Audio API,所以我的首先要研究如何使用 Web Audio API

首先,创建 AudioContext,添加 GainNode 来控制音量大小。

const audio = new (window.AudioContext || window.webkitAudioContext)()
const gainNode = audio.createGain()
gainNode.gain.value = 0.1
gainNode.connect(audio.destination)

我想给声音添加淡出效果,所以我试着给每个音符创建新的 AudioContext。经过几次试验,我发现浏览器的数量上限是 50,因为 AudioContext 的数量一旦超过 50,应用就没法工作了,所以,我建议给整个应用创建一个 AudioContext

我用 Audio API 实现音波的可视化,没有用音频样本,用 OscillatorNode 为每个音符生成音波。音波有 squaretrianglesinesawtooth 等,每种波的声音不一样。我选了锯齿波,因为试验证明这种波效果最好。方波的声音很大,有点像红白机播放的芯片音乐的声音。正弦波和三角波相对柔和,但如果没有处理好声音淡出效果,会有刺耳的切削噪声,就像声音突然卡顿一样。

波形

3

我给每个音符创建了一个振荡器,并设置了波形类型和频率,然后开启振荡器。下面是使用 440 的示例,440 是 “A”的标准音高。

const oscillator = audio.createOscillator()
oscillator.type = 'sawtooth'
oscillator.connect(gainNode)
oscillator.frequency.value = 440
oscillator.start()

如果开启振荡器并使用 440,会持续播放音符“A”,如果想停止播放,必须关闭振荡器。

oscillator.stop()

这与 DOM 上的事件监听器类似,通过监听 keypress 事件确定用户点按的具体键钮,然后再监听 keyup 事件确定用户已停止点按。这个项目用的是 Svelte,可以在 svelte:body 上添加事件监听器。

<svelte:body
  on:keypress="{handleKeyPressNote}"
  on:keyup="{handleKeyUpNote}"
  on:mouseup="{handleClearAllNotes}"
/>

以上是用 Web Audio API 设置这个应用的所有操作——创建 AudioContext、添加 Gain、为每个音符开启/关闭 Oscillator

大家可以把下列代码粘贴在控制台上,它会播放一个音符,只有刷新或输入 oscillator.stop() 才能停止播放。

const audio = new (window.AudioContext || window.webkitAudioContext)()
const gainNode = audio.createGain()
gainNode.gain.value = 0.1
gainNode.connect(audio.destination)

const oscillator = audio.createOscillator()
oscillator.type = 'sawtooth'
oscillator.connect(gainNode)
oscillator.frequency.value = 440
oscillator.start()

数据结构

应用的数据结构布局非常重要。如果直接使用 Web Audio API 的频率,需要先收集所有频率。

频率

下面是全部音符和对应的频率,一共有 12 个音符,每个音符有 8 到 9 个八度音阶,所以我可以用 A[4] 获得 440 频率。

tone

export const tone = {
  C: [16.35, 32.7, 65.41, 130.81, 261.63, 523.25, 1046.5, 2093.0, 4186.01],
  Db: [17.32, 34.65, 69.3, 138.59, 277.18, 554.37, 1108.73, 2217.46, 4434.92],
  D: [18.35, 36.71, 73.42, 146.83, 293.66, 587.33, 1174.66, 2349.32, 4698.64],
  Eb: [19.45, 38.89, 77.78, 155.56, 311.13, 622.25, 1244.51, 2489.02, 4978.03],
  E: [20.6, 41.2, 82.41, 164.81, 329.63, 659.26, 1318.51, 2637.02],
  F: [21.83, 43.65, 87.31, 174.61, 349.23, 698.46, 1396.91, 2793.83],
  Gb: [23.12, 46.25, 92.5, 185.0, 369.99, 739.99, 1479.98, 2959.96],
  G: [24.5, 49.0, 98.0, 196.0, 392.0, 783.99, 1567.98, 3135.96],
  Ab: [25.96, 51.91, 103.83, 207.65, 415.3, 830.61, 1661.22, 3322.44],
  A: [27.5, 55.0, 110.0, 220.0, 440.0, 880.0, 1760.0, 3520.0],
  Bb: [29.14, 58.27, 116.54, 233.08, 466.16, 932.33, 1864.66, 3729.31],
  B: [30.87, 61.74, 123.47, 246.94, 493.88, 987.77, 1975.53, 3951.07],
}

键钮布局

我试了好几次才搞清楚怎么把所有的键钮组合成数据结构。首先,收集以下数据:

  • 手风琴键钮的排(row)
  • 手风琴键钮的列(column)
  • 风箱的方向(push/pull)
  • 具体排、列和方向上的音符的名字和频率

这三个因素有多种组合。我给每个组合设置了 id,例如,1-1-pull 指的是第 1 排、第 1 列,pull(拉)风箱。

我用这个方法创建了数组,记录当前播放的每个音符的数据。例如,点击改变风箱方向的键钮,可以改变所有正在播放的音符的风箱方向,1-1-pull1-2-pull 就变成了 1-1-push1-2-push

包含 3 个高音键钮的排的数据如下:

layout

const layout = {
  one: [],
  two: [],
  three: [],
}

我把手风琴调成 FB♭Eb,即第 1 排调为 F,第 2 排调为 B♭,第 3 排调为 E♭。第 1 排的示例如下:

layout

const layout = {
  one: [
    // Pull
    { id: '1-1-pull', name: 'D♭', frequency: tone.Db[4] },
    { id: '1-2-pull', name: 'G', frequency: tone.G[3] },
    { id: '1-3-pull', name: 'B♭', frequency: tone.Bb[3] },
    { id: '1-4-pull', name: 'D', frequency: tone.D[4] },
    { id: '1-5-pull', name: 'E', frequency: tone.E[4] },
    { id: '1-6-pull', name: 'G', frequency: tone.G[4] },
    { id: '1-7-pull', name: 'B♭', frequency: tone.Bb[4] },
    { id: '1-8-pull', name: 'D', frequency: tone.D[5] },
    { id: '1-9-pull', name: 'E', frequency: tone.E[5] },
    { id: '1-10-pull', name: 'G', frequency: tone.G[5] },
    // Push
    { id: '1-1-push', name: 'B', frequency: tone.B[3] },
    { id: '1-2-push', name: 'F', frequency: tone.F[3] },
    { id: '1-3-push', name: 'A', frequency: tone.A[3] },
    { id: '1-4-push', name: 'C', frequency: tone.C[4] },
    { id: '1-5-push', name: 'F', frequency: tone.F[4] },
    { id: '1-6-push', name: 'A', frequency: tone.A[4] },
    { id: '1-7-push', name: 'C', frequency: tone.C[5] },
    { id: '1-8-push', name: 'F', frequency: tone.F[5] },
    { id: '1-9-push', name: 'A', frequency: tone.A[5] },
    { id: '1-10-push', name: 'C', frequency: tone.C[6] },
  ],
  two: [
    // ...etc
  ],
}

第 1 排有 1-10 个音符,每个音符都有自己的名称和频率,每个重复 2 到 3 次,就生成了 68 个高音音符。

键钮布局

现在要把键盘上的每个键对应手风琴上的排和列。z 键既对应 01-01-push,也对应 01-01-pull,所以不用考虑风箱方向。

keyMap

export const keyMap = {
  z: { row: 1, column: 1 },
  x: { row: 1, column: 2 },
  c: { row: 1, column: 3 },
  v: { row: 1, column: 4 },
  b: { row: 1, column: 5 },
  n: { row: 1, column: 6 },
  m: { row: 1, column: 7 },
  ',': { row: 1, column: 8 },
  '.': { row: 1, column: 9 },
  '/': { row: 1, column: 10 },
  a: { row: 2, column: 1 },
  s: { row: 2, column: 2 },
  d: { row: 2, column: 3 },
  f: { row: 2, column: 4 },
  g: { row: 2, column: 5 },
  // ...etc
}

如上,我把电脑键盘从 z/a'w[ 全部规划好。电脑键盘和手风琴的键钮非常相似。

点按键钮,播放音符

如上所述,我给整个页面创建了一个事件监听器,用来监听用户的点按键钮事件。所有点按键钮事件都会发送到这个事件监听函数。

首先,一定要检查大/小写键,避免按下大小写转换键或大写锁定键,导致键钮无法工作。然后,改变风箱方向的键钮(我设置的是 q 键)必须单独使用。否则,系统会查看 keyMap,如果发现有正在点按的键钮,会查看当前风箱方向并从 keymap 中获得 rowcolumn,进而获得被点按的键钮的 id

handleKeyPressNote

let activeButtonIdMap = {}

function handleKeyPressNote(e) {
  const key = `${e.key}`.toLowerCase() || e.key // handle caps lock

  if (key === toggleBellows) {
    handleToggleBellows('push')
    return
  }

  const buttonMapData = keyMap[key]

  if (buttonMapData) {
    const { row, column } = buttonMapData
    const id = `${row}-${column}-${direction}`

    if (!activeButtonIdMap[id]) {
      const { oscillator } = playTone(id)

      activeButtonIdMap[id] = { oscillator, ...buttonIdMap[id] }
    }
  }
}

我把正在播放的音符放进 activeButtonIdMap 对象里来记录。在 React 中更新变量需要用 useState

React

const [activeButtonIdMap, setActiveButtonIdMap] = useState({})

const App = () => {
  function handleKeyPressNote() {
    setActiveButtonIdMap(newButtonIdMap)
  }
}

在 Svelte 里更新变量只需要重新分配该变量,把变量声明为 let,然后重新分配:

Svelte

let activeButtonIdMap = {}

function handleKeyPressNote() {
  activeButtonIdMap = newButtonIdMap
}

一般来说,用 Svelte 更简单,除非你想从对象中删除键钮。据我所知,Svelte 只在变量被重新分配时才会重新渲染,所以只变更一部分值是不够的,必须对变量进行克隆、改变和重新配置。下面是我在 handleKeyUpNote 函数中的操作。

handleKeyUpNote

function handleKeyUpNote(e) {
  const key = `${e.key}`.toLowerCase() || e.key

  if (key === toggleBellows) {
    handleToggleBellows('pull')
    return
  }

  const buttonMapData = keyMap[key]

  if (buttonMapData) {
    const { row, column } = buttonMapData
    const id = `${row}-${column}-${direction}`

    if (activeButtonIdMap[id]) {
      const { oscillator } = activeButtonIdMap[id]
      oscillator.stop()
      // Must be reassigned in Svelte
      const newActiveButtonIdMap = { ...activeButtonIdMap }
      delete newActiveButtonIdMap[id]
      activeButtonIdMap = newActiveButtonIdMap
    }
  }
}

这是我能想到的用 Svelte 从对象中删除元素的最佳方法,或许大家有更好的方法。

我还创建了一些播放音阶的函数,可以处理手风琴的主要全音阶键钮 FB♭E♭ 等。播放音阶时,我把音阶里对应音符的所有 id 简单循环,在每个音符之间使用 JavaScript 的 600 毫秒的“sleep” 命令。

渲染

我已经设置好了所有的数据结构和 JavaScript,现在只需要渲染所有键钮。Svelte 有 #each 块的循环逻辑,所以我只循环了 3 排键钮,为每个键钮渲染了圆圈。

<div class="accordion-layout">
  {#each rows as row}
    <div class="row {row}">
      {#each layout[row].filter(({ id }) => id.includes(direction)) as button}
        <div
          class={`circle ${activeButtonIdMap[button.id] ? 'active' : ''} ${direction} `}
          id={button.id}
          on:mousedown={handleClickNote(button.id)}
        >
          {button.name}
        </div>
      {/each}
    </div>
  {/each}
</div>

每个圈有自带的 mousedown 事件,除了使用键盘,还可以点击这些事件,但我没在这个圈上放置 mouseup 事件。因为如果你把鼠标挪到了某个音符上,应用无法准确地确定 mouseup 事件,就会一直播放这个音符。

另外,我只用了原生 CSS,因为我觉得小项目不需要太多花哨的东西。

.circle {
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  height: 60px;
  width: 60px;
  margin-bottom: 10px;
  background: linear-gradient(to bottom, white, #e7e7e7);
  box-shadow: 0px 6px rgba(255, 255, 255, 0.4);
  color: #222;
  font-weight: 600;
  cursor: pointer;
}

.circle:hover {
  background: white;
  box-shadow: 0px 6px rgba(255, 255, 255, 0.3);
  cursor: pointer;
}

.circle.pull:active,
.circle.pull.active {
  background: linear-gradient(to bottom, var(--green), #56ea7b);
  box-shadow: 0px 6px rgba(255, 255, 255, 0.2);
}

.circle.push:active,
.circle.push.active {
  background: linear-gradient(to bottom, var(--red), #f15050);
  box-shadow: 0px 6px rgba(255, 255, 255, 0.2);
  color: white;
}

总结

希望大家喜欢我写的 Keyboard Accordion 应用!可以点击 GitHub 获取完整代码哦~

这个应用多多少少会有一些 bug,比如,如果在点按其他键盘的同时使用键盘快捷键,就会卡在一个音符上。大家在使用过程中还会发现更多 bug~

我很喜欢创建这个应用的过程,我学会了 Svelte 和 Web Audio API,还进一步了解了 squeezebox。

希望这篇文章可以激励大家动手搭建自己的线上乐器,或根据自己的爱好做一个应用。写代码最好玩的事情就是可以做任何想做的东西啦!!!

原文作者:Tania Rascia
原文链接:Building a Musical Instrument with the Web Audio API | Tania Rascia

相关专栏
前端与跨平台
74 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。