示例和教程:简单合成器键盘

本文展示了一个可以用鼠标弹奏的视频键盘的代码和工作演示。该键盘允许你在标准波形和一种自定义波形之间切换,并且可以通过键盘下方的音量滑块来控制主增益。本示例使用了以下 Web API 接口:AudioContextOscillatorNodePeriodicWaveGainNode

因为 OscillatorNode 基于 AudioScheduledSourceNode,所以在某种程度上,这也是后者的一个示例。

视频键盘

HTML

我们的虚拟键盘显示主要有三个部分。首先是音乐键盘本身。我们用一对嵌套的 <div> 元素来绘制它,这样如果所有琴键都无法在屏幕上完全显示,我们可以让键盘水平滚动,而不会让它们换行。

键盘

首先,我们创建用来构建键盘的空间。我们将以编程方式构建键盘,因为这样做可以让我们在确定每个音符的相应数据时灵活地配置每个琴键。在我们的例子中,我们从一个表格中获取每个琴键的频率,但也可以通过算法来计算。

html
<div class="container">
  <div class="keyboard"></div>
</div>

名为 "container"<div> 是一个可滚动的框,如果键盘对于可用空间来说太宽,它可以让键盘水平滚动。琴键本身将被插入到类名为 "keyboard" 的块中。

设置栏

在键盘下方,我们将放置一些用于配置层的控件。目前,我们有两个控件:一个用于设置主音量,另一个用于选择生成音符时使用的周期性波形。

音量控制

首先,我们创建一个 <div> 来容纳设置栏,以便根据需要进行样式设置。然后,我们建立一个将显示在设置栏左侧的框,并放置一个标签和一个类型为 "range"<input> 元素。范围元素通常会以滑块控件的形式呈现;我们将其配置为允许 0.0 到 1.0 之间的任何值,每个位置步进 0.01。

html
<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>

我们指定了一个默认值 0.5,并提供了一个 <datalist> 元素,它通过 list 属性连接到范围输入框,以找到一个 ID 匹配的选项列表;在本例中,该数据集名为 "volumes"。这让我们能够提供一组常用值和特殊字符串,浏览器可以选择以某种方式显示它们;我们为 0.0(“静音”)和 1.0(“100%”)这两个值提供了名称。

波形选择器

在设置栏的右侧,我们放置一个标签和一个名为 "waveform"<select> 元素,其选项对应于可用的波形。

html
  <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

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: #eeeeff;
}

.key:active,
.active {
  background-color: black;
  color: white;
}

.octave {
  display: inline-block;
  padding-right: 6px;
}

.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 代码首先初始化一些变量。

js
const audioContext = new AudioContext();
const oscList = [];
let mainGainNode = null;
  1. audioContext 被创建为 AudioContext 的一个实例。
  2. oscList 被设置为准备好包含所有当前正在播放的振荡器的列表。它初始为空,因为还没有任何振荡器在播放。
  3. mainGainNode 被设置为 null;在设置过程中,它将被配置为一个 GainNode,所有正在播放的振荡器都将连接到它并通过它进行播放,从而允许使用单个滑块控件来控制整体音量。
js
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")。
js
let customWaveform = null;
let sineTerms = null;
let cosineTerms = null;

最后,创建将在构建波形时使用的全局变量

  • customWaveform 将被设置为一个 PeriodicWave,用于描述当用户从波形选择器中选择“自定义”时使用的波形。
  • sineTermscosineTerms 将用于存储生成波形的数据;当用户选择“自定义”时,它们将各自包含一个生成的数组。

创建音符表

createNoteTable() 函数构建数组 noteFreq,其中包含一个表示每个八度的对象的数组。每个八度又为该八度中的每个音符都有一个命名的属性;属性的名称是音符的名称(例如“C#”表示升 C),值是该音符的频率,单位是赫兹。我们只硬编码了一个八度;每个后续的八度都可以通过将前一个八度的每个音符频率加倍来推导出来。

js
function createNoteTable() {
  const noteFreq = [
    { A: 27.5, "A#": 29.13523509488062, B: 30.867706328507754 },
    {
      C: 32.70319566257483,
      "C#": 34.64782887210901,
      D: 36.70809598967595,
      "D#": 38.89087296526011,
      E: 41.20344461410874,
      F: 43.65352892912549,
      "F#": 46.2493028389543,
      G: 48.99942949771866,
      "G#": 51.91308719749314,
      A: 55,
      "A#": 58.27047018976124,
      B: 61.73541265701551,
    },
  ];
  for (let octave = 2; octave <= 7; octave++) {
    noteFreq.push(
      Object.fromEntries(
        Object.entries(noteFreq[octave - 1]).map(([key, freq]) => [
          key,
          freq * 2,
        ]),
      ),
    );
  }
  noteFreq.push({ C: 4186.009044809578 });
  return noteFreq;
}

最终生成的对象部分如下所示

八度 注意
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() 函数负责构建键盘并准备应用程序以播放音乐。

js
function setup() {
  const noteFreq = createNoteTable();

  volumeControl.addEventListener("change", changeVolume);

  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();
  1. 通过调用 createNoteTable() 创建将音符名称和八度映射到其频率的表。
  2. 通过调用我们熟悉的老朋友 addEventListener() 来建立一个事件处理程序,以处理主增益控件上的 change 事件。这将把主增益节点的音量更新为控件的新值。
  3. 接下来,我们遍历音符频率表中的每个八度。对于每个八度,我们使用 Object.entries() 来获取该八度中音符的列表。
  4. 创建一个 <div> 来容纳该八度的音符(这样我们可以在八度之间留出一点空间),并将其类名设置为“octave”。
  5. 对于八度中的每个琴键,我们检查音符的名称是否超过一个字符。我们跳过这些,因为在这个例子中我们省略了升调音符。如果音符的名称只有一个字符,那么我们调用 createKey(),并指定音符字符串、八度和频率。返回的元素被附加到步骤 4 中创建的八度元素上。
  6. 当每个八度元素构建完成后,它会被附加到键盘上。
  7. 键盘构建完成后,我们将八度 5 中的音符“B”滚动到视图中;这样做可以确保中央 C 及其周围的琴键是可见的。
  8. 然后使用 BaseAudioContext.createPeriodicWave() 构建一个新的自定义波形。当用户从波形选择器控件中选择“自定义”时,就会使用这个波形。
  9. 最后,初始化振荡器列表,以确保它已准备好接收标识哪些振荡器与哪些琴键相关联的信息。

创建一个琴键

对于我们想在虚拟键盘中呈现的每个琴键,都会调用一次 createKey() 函数。它创建构成琴键及其标签的元素,为元素添加一些数据属性以供后续使用,并为我们关心的事件分配事件处理程序。

js
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);
  keyElement.addEventListener("mouseup", noteReleased);
  keyElement.addEventListener("mouseover", notePressed);
  keyElement.addEventListener("mouseleave", noteReleased);

  return keyElement;
}

在创建了代表琴键及其标签的元素后,我们通过将其类设置为“key”来配置琴键的元素(这决定了它的外观)。然后我们添加 data-* 属性,其中包含琴键的八度(属性 data-octave)、代表要播放的音符的字符串(属性 data-note)以及频率(属性 data-frequency),单位是赫兹。这将让我们在处理事件时可以轻松获取这些信息。

制作音乐

播放一个音调

playTone() 函数的工作是播放给定频率的音调。键盘上触发琴键的事件处理程序将使用它来开始播放相应的音符。

js
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。如果在波形选择器中选择了任何其他波形类型,我们就将振荡器的类型设置为选择器的值;该值将是 sinesquaretrianglesawtooth 之一。

通过设置 OscillatorNode.frequency AudioParam 对象的值,将振荡器的频率设置为 freq 参数中指定的值。最后,通过调用振荡器继承的 AudioScheduledSourceNode.start() 方法启动振荡器,使其开始产生声音。

弹奏一个音符

mousedownmouseover 事件在琴键上发生时,我们想开始播放相应的音符。notePressed() 函数被用作这些事件的事件处理程序。

js
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() 函数是当用户释放鼠标按钮或将鼠标移出当前正在播放的琴键时调用的事件处理程序。

js
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-octavedata-note 自定义属性来查找琴键的振荡器,然后调用振荡器继承的 stop() 方法来停止播放该音符。最后,清除该音符的 oscList 条目,并从琴键元素(通过 event.target 识别)中移除 data-pressed 属性,以表示该音符当前未在播放。

改变主音量

设置栏中的音量滑块提供了一个接口,用于更改主增益节点上的增益值,从而改变所有正在播放的音符的响度。changeVolume() 方法是滑块上 change 事件的处理程序。

js
function changeVolume(event) {
  mainGainNode.gain.value = volumeControl.value;
}

这将主增益节点的 gain AudioParam 的值设置为滑块的新值。

键盘支持

下面的代码添加了 keydownkeyup 事件监听器来处理键盘输入。keydown 事件处理程序调用 notePressed() 来开始播放与按下的键相对应的音符,而 keyup 事件处理程序调用 noteReleased() 来停止播放与释放的键相对应的音符。

js
const synthKeys = document.querySelectorAll(".key");
// prettier-ignore
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);

结果

将所有部分组合在一起,结果就是一个简单但可用的点击式音乐键盘

另见