在CSS中编写一个可播放的合成器键盘的代码

235 阅读10分钟

只要有一点音乐理论知识,我们就可以使用普通的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-index 注意,白键的1 ,黑键的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);
});

请注意,虽然我们的大部分事件监听器都被添加到了HTMLdocument ,但我们可以使用我们的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();
};

在这一点上,我们应该有一个可以工作的、可以在网络上使用的合成器!

我们的setAttack,setDecaysetRelease 函数里面的数字可能看起来有点随机,但实际上它们只是风格上的选择。试着改变它们,看看声音会发生什么变化。你可能最终会得到你喜欢的东西!

如果你有兴趣进一步推进这个项目,有很多方法可以改进它。也许是一个音量控制,一个在八度之间切换的方法,或者一个在波形之间选择的方法?我们可以添加混响或低通滤波器。或者也许每个声音可以由多个振荡器组成?

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