本文将展示一个可用鼠标弹奏的虚拟键盘的代码以及可运行的演示示例。该键盘允许你在标准波形以及一种自定义波形之间进行切换,而且你可以使用键盘下方的音量滑块来控制主增益。这个示例使用了以下 Web API 接口:AudioContext
、OscillatorNode
、PeriodicWave
以及 GainNode
。
由于 OscillatorNode
继承自 AudioScheduledSourceNode
,所以从某种程度上来说,这也是关于音频调度源节点的一个示例。
虚拟键盘
HTML
虚拟键盘的显示部分主要由三个组件构成。首先是音乐键盘本身,我们将其绘制在一对嵌套的<div>
元素中,这样一来,如果所有琴键在屏幕上显示不下,就可以实现键盘水平滚动,而不是换行显示。
键盘
首先,创建用于构建键盘的空间。我们将通过编程的方式来构建键盘,因为这样做能在为相应音符确定合适数据时灵活地配置每个按键。就我们的情况而言,我们是从一个表格中获取每个按键的频率,但它同样也可以通过算法计算得出。
<div class="container">
<div class="keyboard"></div>
</div>
名为 container
的 <div>
元素是一个可滚动的框,如果键盘对于可用空间来说太宽了,它能让键盘实现水平滚动。而琴键本身将会被插入到名为 keyboard
的块中。
设置栏
在键盘下方,我们会放置一些用于配置该层的控件。目前,我们将设置两个控件:一个用于设置主音量,另一个用于选择在生成音符时要使用的周期性波形。
音量控件
首先,我们创建 <div>
元素来容纳设置栏,这样就能根据需要为其设置样式。然后,我们创建一个将显示在该栏左侧的框,并放置一个 span
以及一个类型为 range
的 <input>
元素。这个范围元素通常会呈现为滑块控件的形式,我们将其配置为允许在 0.0
到 1.0
之间取值,且每一档的步长为 0.01
。
<div class="settingsBar">
<div class="left">
<span>Volume: </span>
<input
type="range"
min="0.0"
max="1.0"
step="0.01"
value="0.5"
list="volumes"
name="volume" />
<datalist id="volumes">
<option value="0.0" label="Mute"></option>
<option value="1.0" label="100%"></option>
</datalist>
</div>
</div>
指定默认值为 0.5
,并且提供一个 <datalist>
元素,它通过 list
属性与范围控件相关联,以查找其 ID 与之匹配的选项列表;在本例中,该数据集名为 volumes
。这使得我们能够提供一组常用值以及一些特殊字符串,浏览器可能会选择性地以某种方式将它们显示出来;我们为数值 0.0
(静音)和 1.0
(100%)提供了相应名称。
波形选择器
在设置栏的右侧,我们放置一个 span
以及一个名为 waveform
的 <select>
元素,该元素的选项与可用的波形相对应。
<div class="right">
<span>Current waveform: </span>
<select name="waveform">
<option value="sine">Sine</option>
<option value="square" selected>Square</option>
<option value="sawtooth">Sawtooth</option>
<option value="triangle">Triangle</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
CSS
.container {
overflow-x: scroll;
overflow-y: hidden;
width: 660px;
height: 110px;
white-space: nowrap;
margin: 10px;
}
.keyboard {
width: auto;
padding: 0;
margin: 0;
}
.key {
cursor: pointer;
font:
16px "Open Sans",
"Lucida Grande",
"Arial",
sans-serif;
border: 1px solid black;
border-radius: 5px;
width: 20px;
height: 80px;
text-align: center;
box-shadow: 2px 2px darkgray;
display: inline-block;
position: relative;
margin-right: 3px;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.key div {
position: absolute;
bottom: 0;
text-align: center;
width: 100%;
pointer-events: none;
}
.key div sub {
font-size: 10px;
pointer-events: none;
}
.key:hover {
background-color: #eef;
}
.key:active,
.active {
background-color: #000;
color: #fff;
}
.octave {
display: inline-block;
padding: 0 6px 0 0;
}
.settingsBar {
padding-top: 8px;
font:
14px "Open Sans",
"Lucida Grande",
"Arial",
sans-serif;
position: relative;
vertical-align: middle;
width: 100%;
height: 30px;
}
.left {
width: 50%;
position: absolute;
left: 0;
display: table-cell;
vertical-align: middle;
}
.left span,
.left input {
vertical-align: middle;
}
.right {
width: 50%;
position: absolute;
right: 0;
display: table-cell;
vertical-align: middle;
}
.right span {
vertical-align: middle;
}
.right input {
vertical-align: baseline;
}
JavaScript
JavaScript
代码首先初始化若干变量。
const audioContext = new AudioContext();
const oscList = [];
let mainGainNode = null;
audioContext
是AudioContext
的一个实例;oscList
被设置为包含所有当前正在播放的振荡器的列表。由于刚开始还没有振荡器在播放,所以它一开始是空的;mainGainNode
被设置为空值;在初始化过程中,它将被设置为一个GainNode
,所有正在播放的振荡器都将连接到该增益节点并通过它来播放,这样就可以使用单个滑块控件来控制整体音量了。
const keyboard = document.querySelector(".keyboard");
const wavePicker = document.querySelector("select[name='waveform']");
const volumeControl = document.querySelector("input[name='volume']");
获取对所需元素的引用:
keyboard
是用来放置琴键的容器元素;wavePicker
是用于选择音符波形的<select>
元素;volumeControl
是用于控制主音频音量的<input>
类型为range
的元素。
let noteFreq = null;
let customWaveform = null;
let sineTerms = null;
let cosineTerms = null;
最后,创建构建波形时将要用到的全局变量:
noteFreq
将是一个二维数组;每个内层数组代表一个八度音阶,每个八度音阶的内层数组中都包含该音阶内每个音符对应的一项。每一项的值是音符音调的频率,单位为赫兹;customWaveform
将被设置为一个PeriodicWave
(周期性波形),用于描述当用户从波形选择器中选择 “自定义” 选项时要使用的波形;sineTerms
和cosineTerms
将用于存储生成波形的数据;它们各自会包含一个在用户选择 “自定义” 选项时生成的数组。
创建音符表
createNoteTable()
函数用于构建 noteFreq
数组,使其包含一系列代表每个八度音阶的对象。反过来,每个八度音阶针对该音阶内的每个音符都有一个具名属性,该属性的名称就是音符的名称(比如用 “C#” 来表示升 C),而其值是该音符以赫兹为单位的频率。
function createNoteTable() {
const noteFreq = [];
for (let i=0; i< 9; i++) {
noteFreq[i] = [];
}
noteFreq[0]["A"] = 27.500000000000000;
noteFreq[0]["A#"] = 29.135235094880619;
noteFreq[0]["B"] = 30.867706328507756;
noteFreq[1]["C"] = 32.703195662574829;
noteFreq[1]["C#"] = 34.647828872109012;
noteFreq[1]["D"] = 36.708095989675945;
noteFreq[1]["D#"] = 38.890872965260113;
noteFreq[1]["E"] = 41.203444614108741;
noteFreq[1]["F"] = 43.653528929125485;
noteFreq[1]["F#"] = 46.249302838954299;
noteFreq[1]["G"] = 48.999429497718661;
noteFreq[1]["G#"] = 51.913087197493142;
noteFreq[1]["A"] = 55.000000000000000;
noteFreq[1]["A#"] = 58.270470189761239;
noteFreq[1]["B"] = 61.735412657015513;
// …
为简洁起见,省略了一些八度音阶未展示。
noteFreq[7]["C"] = 2093.004522404789077;
noteFreq[7]["C#"] = 2217.461047814976769;
noteFreq[7]["D"] = 2349.318143339260482;
noteFreq[7]["D#"] = 2489.015869776647285;
noteFreq[7]["E"] = 2637.020455302959437;
noteFreq[7]["F"] = 2793.825851464031075;
noteFreq[7]["F#"] = 2959.955381693075191;
noteFreq[7]["G"] = 3135.963487853994352;
noteFreq[7]["G#"] = 3322.437580639561108;
noteFreq[7]["A"] = 3520.000000000000000;
noteFreq[7]["A#"] = 3729.310092144719331;
noteFreq[7]["B"] = 3951.066410048992894;
noteFreq[8]["C"] = 4186.009044809578154;
return noteFreq;
}
结果是得到一个名为noteFreq
的数组,其中每个元素都是代表一个八度音阶的对象。每个八度音阶对象中都包含具名属性,属性名称就是音符的名称(比如用 “C#” 表示升 C),而属性的值是该音符以赫兹为单位的频率。生成的对象部分内容看上去是这样的:
Octave | Notes | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | "A" ⇒ 27.5 | "A#" ⇒ 29.14 | "B" ⇒ 30.87 | |||||||||
1 | "C" ⇒ 32.70 | "C#" ⇒ 34.65 | "D" ⇒ 36.71 | "D#" ⇒ 38.89 | "E" ⇒ 41.20 | "F" ⇒ 43.65 | "F#" ⇒ 46.25 | "G" ⇒ 49 | "G#" ⇒ 51.9 | "A" ⇒ 55 | "A#" ⇒ 58.27 | "B" ⇒ 61.74 |
2 | . . . |
有了这个表格,我们就能很容易地找出特定八度音阶中某个给定音符的频率。如果我们想知道第 1
个八度音阶中 升G
音符的频率,我们使用 noteFreq[1]["G#"]
,就能得到数值 51.9
这个结果。
注意:
上面示例表格中的数值已四舍五入到小数点后两位。
构建键盘
setup()
函数负责构建键盘并让应用做好播放音乐的准备工作。
function setup() {
noteFreq = createNoteTable();
volumeControl.addEventListener("change", changeVolume, false);
mainGainNode = audioContext.createGain();
mainGainNode.connect(audioContext.destination);
mainGainNode.gain.value = volumeControl.value;
// Create the keys; skip any that are sharp or flat; for
// our purposes we don't need them. Each octave is inserted
// into a <div> of class "octave".
noteFreq.forEach((keys, idx) => {
const keyList = Object.entries(keys);
const octaveElem = document.createElement("div");
octaveElem.className = "octave";
keyList.forEach((key) => {
if (key[0].length === 1) {
octaveElem.appendChild(createKey(key[0], idx, key[1]));
}
});
keyboard.appendChild(octaveElem);
});
document
.querySelector("div[data-note='B'][data-octave='5']")
.scrollIntoView(false);
sineTerms = new Float32Array([0, 0, 1, 0, 1]);
cosineTerms = new Float32Array(sineTerms.length);
customWaveform = audioContext.createPeriodicWave(cosineTerms, sineTerms);
for (let i = 0; i < 9; i++) {
oscList[i] = {};
}
}
setup();
- 通过调用
createNoteTable()
函数来创建将音符名称和八度音阶映射到其频率的表格; - 通过调用我们的老朋友
addEventListener()
函数来建立一个事件处理程序,以处理主增益控件上的change
事件。这会将主增益节点的音量更新为该控件的新值; - 接下来,遍历音符频率表中的每个八度音阶。对于每个八度音阶,我们使用
Object.entries()
来获取该八度音阶中音符的列表; - 创建一个
<div>
元素来容纳该八度音阶的音符(这样我们就能在各个八度音阶之间留出一点间隔空间),并将其类名设置为octave
; - 对于该八度音阶中的每个琴键,我们会检查音符名称是否包含不止一个字符。由于在这个示例中我们省略了升音音符,我们会跳过这个情况。如果音符名称只有一个字符,那么我们就调用
createKey()
函数,同时指定音符字符串、八度音阶以及频率。返回的元素会被添加到步骤 4
中创建的八度音阶元素里; - 当每个八度音阶元素都构建好之后,它就会被添加到键盘元素当中;
- 一旦键盘构建完成,我们就将第
5
个八度音阶中的音符B
滚动到可见区域;这样做的效果是确保中央C
及其周围的琴键都能显示出来; - 然后,使用
BaseAudioContext.createPeriodicWave()
创建一个新的自定义波形,每当用户从波形选择器控件中选择 “自定义” 选项时,都将使用这个波形; - 最后,对振荡器列表进行初始化,以确保其准备好接收用于确定哪些振荡器与哪些琴键相关联的信息。
创建琴键
对于我们想要在虚拟键盘中呈现的每个琴键,都会调用一次 createKey()
函数。该函数会创建构成琴键及其标签的元素,为元素添加一些数据属性以供后续使用,并为我们关注的事件分配事件处理程序。
function createKey(note, octave, freq) {
const keyElement = document.createElement("div");
const labelElement = document.createElement("div");
keyElement.className = "key";
keyElement.dataset["octave"] = octave;
keyElement.dataset["note"] = note;
keyElement.dataset["frequency"] = freq;
labelElement.appendChild(document.createTextNode(note));
labelElement.appendChild(document.createElement("sub")).textContent = octave;
keyElement.appendChild(labelElement);
keyElement.addEventListener("mousedown", notePressed, false);
keyElement.addEventListener("mouseup", noteReleased, false);
keyElement.addEventListener("mouseover", notePressed, false);
keyElement.addEventListener("mouseleave", noteReleased, false);
return keyElement;
}
在创建好用于表示琴键及其标签的元素之后,我们通过将琴键元素的类名设置为 key
(这会确定其外观样式)。然后,我们添加以 data-*
开头的自定义属性,这些属性包含琴键的八度音阶(data-octave
属性)、代表要演奏音符的字符串(data-note
属性)以及以赫兹为单位的频率(data-frequency
属性)。这样一来,我们在处理各类事件时就能根据需要轻松获取这些信息了。
制作音乐
演奏音调
playTone()
函数的作用是按照给定的频率演奏一个音调。键盘上触发琴键的事件处理程序会使用这个函数来开始演奏相应的音符。
function playTone(freq) {
const osc = audioContext.createOscillator();
osc.connect(mainGainNode);
const type = wavePicker.options[wavePicker.selectedIndex].value;
if (type === "custom") {
osc.setPeriodicWave(customWaveform);
} else {
osc.type = type;
}
osc.frequency.value = freq;
osc.start();
return osc;
}
playTone()
函数首先通过调用 BaseAudioContext.createOscillator()
方法来创建一个新的 OscillatorNode
。然后,通过调用这个新振荡器的 connect()
方法将其连接到主增益节点上,该方法指明了振荡器要将其输出发送到何处。通过这样做,改变主增益节点的增益将会影响所有正在生成的音调的音量。
然后,我们通过检查设置栏中波形选择器控件的值来确定要使用的波形类型。如果用户将其设置为 custom
,我们会调用OscillatorNode.setPeriodicWave()
来配置振荡器以使用我们自定义的波形。这样做会自动将振荡器的 type
设置为custom
。如果在波形选择器中选择了任何其他波形类型,我们会将振荡器的类型设置为选择器的值;该值将会是sine
、square
、 triangle
以及 sawtooth
中的一种。
通过设置 OscillatorNode.frequency
这个音频参数对象的值,将振荡器的频率设置为 freq
参数中指定的值。然后,最后调用振荡器继承自 AudioScheduledSourceNode.start()
方法启动振荡器,使其开始发声。
演奏音符
当在某个琴键上发生 mousedown
或 mouseover
事件时,我们希望开始演奏相应的音符。notePressed()
函数被用作这些事件的事件处理程序。
function notePressed(event) {
if (event.buttons & 1) {
const dataset = event.target.dataset;
if (!dataset["pressed"] && dataset["octave"]) {
const octave = Number(dataset["octave"]);
oscList[octave][dataset["note"]] = playTone(dataset["frequency"]);
dataset["pressed"] = "yes";
}
}
}
我们首先检查鼠标主键是否被按下,这有两个原因。其一,我们希望只允许通过鼠标主键来触发音符演奏。其二,也是更为重要的一点,我们利用这一点来处理 mouseover
的情况,即当用户从一个音符拖动到另一个音符时,只有当鼠标进入元素时处于按下状态,我们才希望开始演奏相应音符。
如果鼠标按键实际上处于按下状态,我们会获取被按下琴键的 dataset
属性;这样就能方便地访问该元素上的自定义数据属性了。我们查找 data-pressed
属性,如果不存在该属性(这意味着对应的音符尚未在演奏),我们就调用 playTone()
函数来开始演奏这个音符,传入该元素的 data-frequency
属性值。返回的振荡器会被存储到 oscList
中以供后续参考,并且将 data-pressed
属性设置为 yes
,以此表明该音符正在演奏,这样下次调用这个函数时就不会再次启动它了。
停止音调
noteReleased()
函数是当用户松开鼠标按键或者将鼠标移出当前正在演奏音符的琴键时被调用的事件处理程序。
function noteReleased(event) {
const dataset = event.target.dataset;
if (dataset && dataset["pressed"]) {
const octave = Number(dataset["octave"]);
if (oscList[octave] && oscList[octave][dataset["note"]]) {
oscList[octave][dataset["note"]].stop();
delete oscList[octave][dataset["note"]];
delete dataset["pressed"];
}
}
}
noteReleased()
函数利用 data-octave
和 data-note
这两个自定义属性来查找对应琴键的振荡器,然后调用该振荡器继承而来的 stop()
方法来停止演奏这个音符。最后,会清除该音符在oscList
中的记录,并从琴键元素(通过event.target
来确定)上移除data-pressed
属性,以此表明该音符当前没有在演奏。
改变主音量
设置栏中的音量滑块提供了一个简单的界面,用于改变主增益节点上的增益值,进而改变所有正在演奏音符的响度。 changeVolume()
方法是该滑块上 change
事件的处理程序。
function changeVolume(event) {
mainGainNode.gain.value = volumeControl.value;
}
这会将主增益节点的 gain
音频参数的值设置为滑块的新值。
键盘支持
以下代码添加了 keydown
和 keyup
事件监听器,用于处理键盘输入。keydown
事件的处理程序会调用 notePressed()
函数来开始演奏与被按下的按键相对应的音符,而 keyup
事件的处理程序则会调用 noteReleased()
函数来停止演奏与被松开的按键相对应的音符。
const synthKeys = document.querySelectorAll(".key");
const keyCodes = [
"Space",
"ShiftLeft", "KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "Comma", "Period", "Slash", "ShiftRight",
"KeyA", "KeyS", "KeyD", "KeyF", "KeyG", "KeyH", "KeyJ", "KeyK", "KeyL", "Semicolon", "Quote", "Enter",
"Tab", "KeyQ", "KeyW", "KeyE", "KeyR", "KeyT", "KeyY", "KeyU", "KeyI", "KeyO", "KeyP", "BracketLeft", "BracketRight",
"Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus", "Equal", "Backspace",
"Escape",
];
function keyNote(event) {
const elKey = synthKeys[keyCodes.indexOf(event.code)];
if (elKey) {
if (event.type === "keydown") {
elKey.tabIndex = -1;
elKey.focus();
elKey.classList.add("active");
notePressed({ buttons: 1, target: elKey });
} else {
elKey.classList.remove("active");
noteReleased({ buttons: 1, target: elKey });
}
event.preventDefault();
}
}
addEventListener("keydown", keyNote);
addEventListener("keyup", keyNote);
运行结果
把所有这些结合起来,其结果就是一个简单但能正常使用的点击式音乐键盘。
在线运行详见 英文原文 的最后一小节。
评论0
暂时没有评论