因为一些个人原因,我已经好长时间没有更新了,但别担心,我还在写代码。最近我去了趟德克萨斯,买了一架三排全音阶键钮式手风琴。大家应该知道,很多民俗音乐都会用全音阶手风琴,而民俗音乐大多都是口口相传流传下来的,这对我这个不太懂乐谱的人说是一件好事。
键盘式手风琴跟钢琴的逻辑和色彩布局是一样的,但我买的这个全音阶手风琴跟键盘式手风琴不一样,它有 34 个高音键钮,12 个低音键钮,而且同一键钮在推/拉风箱时发出的音符也不一样,所以准确来说,我的手风琴有 68 个高音音符(每个高音键钮有 2 个音符)。另外,众所周知,手风琴声音非常响亮。为了不吵到邻居,又能搞明白手风琴的布局逻辑,我打算搭建一个网页版应用。
我搭建了一个名为 KeyboardAccordion.com 的网页版应用,也是个 开源项目。我注意到电脑键盘的按键数量正好跟全音阶手风琴的键钮数量一样,并且布局也类似。所以,我记录了音符、音阶和和弦,开始思考如何把这两者结合在一起。
我的手风琴跟下图类似:
我决定用 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
为每个音符生成音波。音波有 square
、triangle
、sine
和 sawtooth
等,每种波的声音不一样。我选了锯齿波,因为试验证明这种波效果最好。方波的声音很大,有点像红白机播放的芯片音乐的声音。正弦波和三角波相对柔和,但如果没有处理好声音淡出效果,会有刺耳的切削噪声,就像声音突然卡顿一样。
波形
我给每个音符创建了一个振荡器,并设置了波形类型和频率,然后开启振荡器。下面是使用 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-pull
和 1-2-pull
就变成了 1-1-push
和 1-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 中获得 row
和 column
,进而获得被点按的键钮的 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 从对象中删除元素的最佳方法,或许大家有更好的方法。
我还创建了一些播放音阶的函数,可以处理手风琴的主要全音阶键钮 F
、B♭
和 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