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

本文提供了您可以使用鼠标播放的视频键盘的代码和工作演示。该键盘允许您在标准波形以及一个自定义波形之间切换,并且您可以使用键盘下方的音量滑块控制主增益。此示例使用了以下 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>
</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: #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 代码首先初始化许多变量。

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 noteFreq = null;
let customWaveform = null;
let sineTerms = null;
let cosineTerms = null;

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

  • noteFreq 将是一个二维数组;每个数组表示一个八度,每个八度包含该八度中每个音符的一个条目。每个条目的值都是音符音调的频率(以赫兹为单位)。
  • customWaveform 将被设置为 PeriodicWave,描述用户从波形选择器中选择“自定义”时要使用的波形。
  • sineTermscosineTerms 将用于存储生成波形的数据;每个都将包含一个数组,该数组在用户选择“自定义”时生成。

创建音符表

createNoteTable() 函数构建数组 noteFreq 以包含一个表示每个八度的对象数组。每个八度依次对该八度中的每个音符都有一个命名属性;属性的名称是音符的名称(例如“C#”表示 C 调升),值是该音符的频率(以赫兹为单位)。

js
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;
  // …

出于简洁起见,省略了一些八度。

js
  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 调升),属性的值是该音符的频率(以赫兹为单位)。部分结果对象如下所示

八度 音符
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() {
  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();
  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, 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() 函数的作用是以给定的频率播放音调。这将由处理键盘上触发琴键的事件的处理程序来使用,以开始播放相应的音符。

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 之一。

振荡器的频率设置为freq 参数中指定的值,方法是设置OscillatorNode.frequency AudioParam 对象的值。然后,最后,振荡器启动,以便通过调用振荡器继承的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");
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);

结果

将所有内容放在一起,结果是一个简单但有效的点击式音乐键盘。

另请参阅