如何编写可玩合成器键盘

Avatar of Bret Cameron
Bret Cameron

DigitalOcean 为您的旅程的每个阶段提供云产品。立即开始使用 $200 免费信用额度!

只需了解一点音乐理论,我们就可以使用普通的 HTML、CSS 和 JavaScript(无需任何库或音频样本)来创建一个简单的数字乐器。让我们将其付诸实践,探索一种在互联网上播放和托管数字合成器的方法。

以下是我们要制作的内容

我们将使用 AudioContext API 以数字方式创建我们的声音,而不依赖于样本。但首先,让我们来设计键盘的外观。

HTML 结构

我们将支持标准的西方键盘,其中从 A; 的每个字母都对应一个可播放的自然音符(白键),而上面的行可用于升音符和降音符(黑键)。这意味着我们的键盘覆盖了略多于一个八度音程,从 C₃ 开始,到 E₄ 结束。(对于不熟悉音乐符号的人来说,下标数字表示八度音程。)

我们可以做的一件有用的事情是将音符值存储在一个自定义的 note 属性中,以便在我们的 JavaScript 中轻松访问。我将打印计算机键盘上的字母,以帮助我们的用户了解要按什么键。

<ul id="keyboard">
  <li note="C" class="white">A</li>
  <li note="C#" class="black">W</li>
  <li note="D" class="white offset">S</li>
  <li note="D#" class="black">E</li>
  <li note="E" class="white offset">D</li>
  <li note="F" class="white">F</li>
  <li note="F#" class="black">T</li>
  <li note="G" class="white offset">G</li>
  <li note="G#" class="black">Y</li>
  <li note="A" class="white offset">H</li>
  <li note="A#" class="black">U</li>
  <li note="B" class="white offset">J</li>
  <li note="C2" class="white">K</li>
  <li note="C#2" class="black">O</li>
  <li note="D2" class="white offset">L</li>
  <li note="D#2" class="black">P</li>
  <li note="E2" class="white offset">;</li>
</ul>

CSS 样式

我们将从一些样板开始我们的 CSS

html {
  box-sizing: border-box;
}

*,
*:before,
*:after {
  box-sizing: inherit;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}

body {
  margin: 0;
}

让我们为我们将要使用的某些颜色指定 CSS 变量。您可以随意更改为您喜欢的任何颜色!

:root {
  --keyboard: hsl(300, 100%, 16%);
  --keyboard-shadow: hsla(19, 50%, 66%, 0.2);
  --keyboard-border: hsl(20, 91%, 5%);
  --black-10: hsla(0, 0%, 0%, 0.1);
  --black-20: hsla(0, 0%, 0%, 0.2);
  --black-30: hsla(0, 0%, 0%, 0.3);
  --black-50: hsla(0, 0%, 0%, 0.5);
  --black-60: hsla(0, 0%, 0%, 0.6);
  --white-20: hsla(0, 0%, 100%, 0.2);
  --white-50: hsla(0, 0%, 100%, 0.5);
  --white-80: hsla(0, 0%, 100%, 0.8);
}

特别是,更改 --keyboard--keyboard-border 变量将极大地改变最终结果。

为了对键和键盘进行样式化(特别是在按下状态下),我从 zastrow 的这个 CodePen 获得了很多灵感。首先,我们指定所有键共享的 CSS

.white,
.black {
  position: relative;
  float: left;
  display: flex;
  justify-content: center;
  align-items: flex-end;
  padding: 0.5rem 0;
  user-select: none;
  cursor: pointer;
}

在第一个和最后一个键上使用特定的圆角可以使设计看起来更自然。如果没有圆角,键的左上角和右上角看起来会有点不自然。以下是一个最终的设计,不包括第一个和最后一个键上的任何额外圆角。

让我们添加一些 CSS 来改进它。

#keyboard li:first-child {
  border-radius: 5px 0 5px 5px;
}

#keyboard li:last-child {
  border-radius: 0 5px 5px 5px;
}

区别很微妙,但很有效

接下来,我们将应用区分白色和黑色键的样式。请注意,白色键的 z-index1,黑色键的 z-index2

.white {
  height: 12.5rem;
  width: 3.5rem;
  z-index: 1;
  border-left: 1px solid hsl(0, 0%, 73%);
  border-bottom: 1px solid hsl(0, 0%, 73%);
  border-radius: 0 0 5px 5px;
  box-shadow: -1px 0 0 var(--white-80) inset, 0 0 5px hsl(0, 0%, 80%) inset,
    0 0 3px var(--black-20);
  background: linear-gradient(to bottom, hsl(0, 0%, 93%) 0%, white 100%);
  color: var(--black-30);
}

.black {
  height: 8rem;
  width: 2rem;
  margin: 0 0 0 -1rem;
  z-index: 2;
  border: 1px solid black;
  border-radius: 0 0 3px 3px;
  box-shadow: -1px -1px 2px var(--white-20) inset,
    0 -5px 2px 3px var(--black-60) inset, 0 2px 4px var(--black-50);
  background: linear-gradient(45deg, hsl(0, 0%, 13%) 0%, hsl(0, 0%, 33%) 100%);
  color: var(--white-50);
}

当按下键时,我们将使用 JavaScript 为相关的 li 元素添加一个 "pressed" 类。现在,我们可以通过将类直接添加到我们的 HTML 元素来测试这一点。

.white.pressed {
  border-top: 1px solid hsl(0, 0%, 47%);
  border-left: 1px solid hsl(0, 0%, 60%);
  border-bottom: 1px solid hsl(0, 0%, 60%);
  box-shadow: 2px 0 3px var(--black-10) inset,
    -5px 5px 20px var(--black-20) inset, 0 0 3px var(--black-20);
  background: linear-gradient(to bottom, white 0%, hsl(0, 0%, 91%) 100%);
  outline: none;
}

.black.pressed {
  box-shadow: -1px -1px 2px var(--white-20) inset,
    0 -2px 2px 3px var(--black-60) inset, 0 1px 2px var(--black-50);
  background: linear-gradient(
    to right,
    hsl(0, 0%, 27%) 0%,
    hsl(0, 0%, 13%) 100%
  );
  outline: none;
}

某些白色键需要向左移动,以便它们位于黑色键下方。我们在 HTML 中为它们添加一个 "offset" 类,因此我们可以使 CSS 保持简单

.offset {
  margin: 0 0 0 -1rem;
}

如果您已经按照前面的 CSS 操作,那么您应该看到类似于这样的内容

最后,我们将对键盘本身进行样式化

#keyboard {
  height: 15.25rem;
  width: 41rem;
  margin: 0.5rem auto;
  padding: 3rem 0 0 3rem;
  position: relative;
  border: 1px solid var(--keyboard-border);
  border-radius: 1rem;
  background-color: var(--keyboard);
  box-shadow: 0 0 50px var(--black-50) inset, 0 1px var(--keyboard-shadow) inset,
    0 5px 15px var(--black-50);
}

现在我们有一个外观不错的 CSS 键盘,但它没有交互性,也没有发出任何声音。为此,我们需要使用 JavaScript。

音乐 JavaScript

为了创建我们合成器的声音,我们不想依赖音频样本——那样就作弊了!相反,我们可以使用网络的 AudioContext API,它具有可以帮助我们将数字波形转换为声音的工具。

要创建一个新的音频上下文,我们可以使用

const audioContext = new (window.AudioContext || window.webkitAudioContext)();

在使用我们的 audioContext 之前,选择 HTML 中的所有音符元素会很有帮助。我们可以使用此助手轻松查询元素

const getElementByNote = (note) =>
  note && document.querySelector(`[note="${note}"]`);

然后我们可以将元素存储在一个对象中,其中对象的键是用户在键盘上按下以播放该音符的键。

const keys = {
  A: { element: getElementByNote("C"), note: "C", octaveOffset: 0 },
  W: { element: getElementByNote("C#"), note: "C#", octaveOffset: 0 },
  S: { element: getElementByNote("D"), note: "D", octaveOffset: 0 },
  E: { element: getElementByNote("D#"), note: "D#", octaveOffset: 0 },
  D: { element: getElementByNote("E"), note: "E", octaveOffset: 0 },
  F: { element: getElementByNote("F"), note: "F", octaveOffset: 0 },
  T: { element: getElementByNote("F#"), note: "F#", octaveOffset: 0 },
  G: { element: getElementByNote("G"), note: "G", octaveOffset: 0 },
  Y: { element: getElementByNote("G#"), note: "G#", octaveOffset: 0 },
  H: { element: getElementByNote("A"), note: "A", octaveOffset: 1 },
  U: { element: getElementByNote("A#"), note: "A#", octaveOffset: 1 },
  J: { element: getElementByNote("B"), note: "B", octaveOffset: 1 },
  K: { element: getElementByNote("C2"), note: "C", octaveOffset: 1 },
  O: { element: getElementByNote("C#2"), note: "C#", octaveOffset: 1 },
  L: { element: getElementByNote("D2"), note: "D", octaveOffset: 1 },
  P: { element: getElementByNote("D#2"), note: "D#", octaveOffset: 1 },
  semicolon: { element: getElementByNote("E2"), note: "E", octaveOffset: 1 }
};

我发现在这里指定音符的名称以及 octaveOffset 会很有用,我们在计算音高时需要它。

我们需要提供以 Hz 为单位的音高。确定音高的方程式为 x * 2^(y / 12),其中 x 是所选音符的 Hz 值(通常是 A₄,其音高为 440Hz),而 y 是高于或低于该音高的音符数。

在代码中,它看起来像这样

const getHz = (note = "A", octave = 4) => {
  const A4 = 440;
  let N = 0;
  switch (note) {
    default:
    case "A":
      N = 0;
      break;
    case "A#":
    case "Bb":
      N = 1;
      break;
    case "B":
      N = 2;
      break;
    case "C":
      N = 3;
      break;
    case "C#":
    case "Db":
      N = 4;
      break;
    case "D":
      N = 5;
      break;
    case "D#":
    case "Eb":
      N = 6;
      break;
    case "E":
      N = 7;
      break;
    case "F":
      N = 8;
      break;
    case "F#":
    case "Gb":
      N = 9;
      break;
    case "G":
      N = 10;
      break;
    case "G#":
    case "Ab":
      N = 11;
      break;
  }
  N += 12 * (octave - 4);
  return A4 * Math.pow(2, N / 12);
};

虽然我们在代码的其余部分中只使用了升音符,但我还是决定在这里也包括降音符,以便此函数可以轻松地用于其他上下文。

对于任何不确定音乐符号的人来说,例如,音符 A#Bb 描述的是完全相同的音高。如果我们在特定的调式中演奏,我们可能会选择其中一个而不是另一个,但对于我们的目的来说,差异并不重要。

播放音符

我们准备开始播放一些音符了!

首先,我们需要某种方法来告诉我们哪些音符正在播放。让我们使用 Map 来做到这一点,因为它的唯一键约束可以帮助我们防止在一次按下中多次触发相同的音符。另外,用户一次只能点击一个键,因此我们可以将其存储为字符串。

const pressedNotes = new Map();
let clickedKey = "";

我们需要两个函数,一个用于播放键(我们将在 keydownmousedown 上触发),另一个用于停止播放键(我们将在 keyupmouseup 上触发)。

每个键都将使用自己的振荡器、自己的增益节点(用于控制音量)和自己的波形类型(用于确定声音的音色)来播放。我选择使用 "triangle" 波形,但您可以使用任何您喜欢的波形,例如 "sine""triangle""sawtooth""square"规范 提供了有关这些值的更多信息。

const playKey = (key) => {
  if (!keys[key]) {
    return;
  }

  const osc = audioContext.createOscillator();
  const noteGainNode = audioContext.createGain();
  noteGainNode.connect(audioContext.destination);
  noteGainNode.gain.value = 0.5;
  osc.connect(noteGainNode);
  osc.type = "triangle";

  const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) + 4);

  if (Number.isFinite(freq)) {
    osc.frequency.value = freq;
  }

  keys[key].element.classList.add("pressed");
  pressedNotes.set(key, osc);
  pressedNotes.get(key).start();
};

我们的声音可以进行一些改进。目前,它具有略带尖锐、微波蜂鸣器的音质!但这已经足够开始使用了。我们将在最后回来进行一些调整!

停止键是一个更简单的任务。我们需要让每个音符在用户抬起手指后“持续”一段时间(两秒钟左右),并进行必要的视觉更改。

const stopKey = (key) => {
  if (!keys[key]) {
    return;
  }
  
  keys[key].element.classList.remove("pressed");
  const osc = pressedNotes.get(key);

  if (osc) {
    setTimeout(() => {
      osc.stop();
    }, 2000);

    pressedNotes.delete(key);
  }
};

剩下的就是添加我们的事件监听器了

document.addEventListener("keydown", (e) => {
  const eventKey = e.key.toUpperCase();
  const key = eventKey === ";" ? "semicolon" : eventKey;
  
  if (!key || pressedNotes.get(key)) {
    return;
  }
  playKey(key);
});

document.addEventListener("keyup", (e) => {
  const eventKey = e.key.toUpperCase();
  const key = eventKey === ";" ? "semicolon" : eventKey;
  
  if (!key) {
    return;
  }
  stopKey(key);
});

for (const [key, { element }] of Object.entries(keys)) {
  element.addEventListener("mousedown", () => {
    playKey(key);
    clickedKey = key;
  });
}

document.addEventListener("mouseup", () => {
  stopKey(clickedKey);
});

请注意,虽然我们的大多数事件监听器都添加到 HTML document 中,但我们可以使用我们的 keys 对象将点击监听器添加到我们已经查询过的特定元素。我们还需要对我们最高的音符进行一些特殊处理,确保将 ";" 键转换为我们 keys 对象中使用的拼写完整的 "semicolon"

现在我们可以播放我们合成器上的键了!只有一个问题。声音仍然很尖锐!我们可能想要通过更改分配给 freq 常量的表达式来降低键盘的八度音程

const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) + 3);

您可能还能听到声音开始和结束时的“咔哒”声。我们可以通过快速淡入和更逐渐淡出每个声音来解决这个问题。

在音乐制作中,我们使用术语 攻击 来描述声音从无到最大音量的时间,以及“释放”来描述声音从不再播放到完全消失所需的时间。另一个有用的概念是 衰减,即声音从峰值音量衰减到持续音量的所需时间。谢天谢地,我们的 noteGainNode 具有一个 gain 属性,它有一个名为 exponentialRampToValueAtTime 的方法,我们可以用它来控制攻击、释放和衰减。如果我们将之前的 playKey 函数替换为以下函数,我们将获得更悦耳的拨片声音

const playKey = (key) => {
  if (!keys[key]) {
    return;
  }

  const osc = audioContext.createOscillator();
  const noteGainNode = audioContext.createGain();
  noteGainNode.connect(audioContext.destination);

  const zeroGain = 0.00001;
  const maxGain = 0.5;
  const sustainedGain = 0.001;

  noteGainNode.gain.value = zeroGain;

  const setAttack = () =>
    noteGainNode.gain.exponentialRampToValueAtTime(
      maxGain,
      audioContext.currentTime + 0.01
    );
  const setDecay = () =>
    noteGainNode.gain.exponentialRampToValueAtTime(
      sustainedGain,
      audioContext.currentTime + 1
    );
  const setRelease = () =>
    noteGainNode.gain.exponentialRampToValueAtTime(
      zeroGain,
      audioContext.currentTime + 2
    );

  setAttack();
  setDecay();
  setRelease();

  osc.connect(noteGainNode);
  osc.type = "triangle";

  const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) - 1);

  if (Number.isFinite(freq)) {
    osc.frequency.value = freq;
  }

  keys[key].element.classList.add("pressed");
  pressedNotes.set(key, osc);
  pressedNotes.get(key).start();
};

此时,我们应该有一个可用的、网络就绪的合成器了!

我们 setAttacksetDecaysetRelease 函数中的数字可能看起来有点随机,但实际上它们只是风格选择。尝试更改它们并观察声音的变化。您最终可能会获得自己喜欢的效果!

如果您有兴趣进一步进行该项目,您可以通过多种方式进行改进。也许是音量控制、切换八度音程的方法或选择波形的方法?我们可以添加混响或低通滤波器。或者每个声音都可能由多个振荡器组成?

对于任何有兴趣了解如何在网络上实现音乐理论概念的人来说,我建议查看 tonal npm 包 的源代码。