示例和教程:簡單合成器鍵盤

本文展示了一個可以用滑鼠彈奏的影片鍵盤的程式碼和工作演示。該鍵盤允許你在標準波形和一種自定義波形之間切換,並且可以透過鍵盤下方的音量滑塊來控制主增益。本示例使用了以下 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);

結果

將所有部分組合在一起,結果就是一個簡單但可用的點選式音樂鍵盤

另見