Wie man ein spielbares Synthesizer-Keyboard programmiert

Avatar of Bret Cameron
Bret Cameron am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Mit ein wenig Musiktheorie-Wissen können wir normales HTML, CSS und JavaScript verwenden – ohne Bibliotheken oder Audiostreams – um ein einfaches digitales Instrument zu erstellen. Lassen Sie uns dies in die Praxis umsetzen und eine Methode zur Erstellung eines digitalen Synthesizers erkunden, der im Internet spielbar und gehostet werden kann.

Hier ist, was wir machen

Wir werden die AudioContext API verwenden, um unsere Töne digital zu erstellen, ohne auf Streams zurückzugreifen. Aber zuerst arbeiten wir am Aussehen des Keyboards.

Die HTML-Struktur

Wir werden ein Standard-Westen-Keyboard unterstützen, bei dem jeder Buchstabe zwischen A und ; einer spielbaren natürlichen Note (den weißen Tasten) entspricht, während die obere Reihe für die erhöhten und erniedrigten Töne (die schwarzen Tasten) verwendet werden kann. Das bedeutet, dass unser Keyboard etwas mehr als eine Oktave abdeckt, beginnend bei C₃ und endend bei E₄. (Für alle, die mit musikalischer Notation nicht vertraut sind, geben die tiefgestellten Zahlen die Oktave an.)

Eine nützliche Sache, die wir tun können, ist, den Notenwert in einem benutzerdefinierten note-Attribut zu speichern, so dass er in unserem JavaScript leicht zugänglich ist. Ich werde die Buchstaben der Computertastatur ausgeben, um unseren Benutzern zu helfen zu verstehen, was sie drücken sollen.

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

Das CSS-Styling

Wir beginnen unser CSS mit etwas Boilerplate.

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;
}

Lassen Sie uns CSS-Variablen für einige der Farben festlegen, die wir verwenden werden. Ändern Sie sie ruhig nach Belieben!

: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);
}

Insbesondere die Änderung der Variablen --keyboard und --keyboard-border wird das Endergebnis dramatisch verändern.

Für das Styling der Tasten und des Keyboards – insbesondere im gedrückten Zustand – verdanke ich viel Inspiration diesem CodePen von zastrow. Zuerst legen wir das CSS fest, das von allen Tasten gemeinsam genutzt wird.

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

Die Verwendung eines spezifischen abgerundeten Ecken-Radius auf der ersten und letzten Taste hilft, das Design organischer wirken zu lassen. Ohne Rundung sehen die oberen linken und oberen rechten Ecken der Tasten etwas unnatürlich aus. Hier ist ein endgültiges Design, abzüglich jeglicher zusätzlicher Rundung an der ersten und letzten Taste.

Lassen Sie uns etwas CSS hinzufügen, um dies zu verbessern.

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

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

Der Unterschied ist subtil, aber effektiv.

Als nächstes wenden wir die Stile an, die die weißen und schwarzen Tasten unterscheiden. Beachten Sie, dass die weißen Tasten einen z-index von 1 und die schwarzen Tasten einen z-index von 2 haben.

.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);
}

Wenn eine Taste gedrückt wird, verwenden wir JavaScript, um dem entsprechenden li-Element eine Klasse namens "pressed" hinzuzufügen. Vorerst können wir dies testen, indem wir die Klasse direkt zu unseren HTML-Elementen hinzufügen.

.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;
}

Bestimmte weiße Tasten müssen nach links verschoben werden, damit sie unter den schwarzen Tasten liegen. Wir geben ihnen in unserem HTML eine Klasse namens "offset", damit wir das CSS einfach halten können.

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

Wenn Sie dem CSS bis zu diesem Punkt gefolgt sind, sollten Sie etwas Ähnliches wie dieses haben.

Schließlich gestalten wir das Keyboard selbst.

#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);
}

Wir haben jetzt ein gut aussehendes CSS-Keyboard, aber es ist nicht interaktiv und es erzeugt keine Geräusche. Um dies zu tun, benötigen wir JavaScript.

Musikalische JavaScript

Um die Klänge für unseren Synthesizer zu erzeugen, wollen wir uns nicht auf Audio-Samples verlassen – das wäre schummeln! Stattdessen können wir die AudioContext API des Webs nutzen, die Werkzeuge bietet, die uns helfen, digitale Wellenformen in Töne umzuwandeln.

Um einen neuen Audio-Kontext zu erstellen, können wir verwenden:

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

Bevor wir unseren audioContext verwenden, ist es hilfreich, alle unsere Notenelemente im HTML auszuwählen. Wir können diesen Helfer verwenden, um die Elemente einfach abzufragen.

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

Wir können die Elemente dann in einem Objekt speichern, wobei der Schlüssel des Objekts die Taste ist, die ein Benutzer auf der Tastatur drücken würde, um diese Note zu spielen.

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 }
};

Ich fand es nützlich, hier den Namen der Note anzugeben, sowie einen octaveOffset, den wir bei der Berechnung der Tonhöhe benötigen.

Wir müssen eine Tonhöhe in Hz angeben. Die Gleichung zur Bestimmung der Tonhöhe lautet x * 2^(y / 12), wobei x der Hz-Wert einer gewählten Note ist – üblicherweise A₄ mit einer Tonhöhe von 440 Hz – und y die Anzahl der Noten über oder unter dieser Tonhöhe ist.

Das ergibt in Code so etwas wie:

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);
};

Obwohl wir im Rest unseres Codes nur erhöhte Töne verwenden, habe ich mich entschieden, hier auch erniedrigte Töne einzuschließen, so dass diese Funktion leicht in einem anderen Kontext wiederverwendet werden kann.

Für alle, die sich mit musikalischer Notation nicht auskennen: Die Noten A# und Bb zum Beispiel beschreiben exakt die gleiche Tonhöhe. Wir könnten eine über die andere wählen, wenn wir in einer bestimmten Tonart spielen, aber für unsere Zwecke spielt der Unterschied keine Rolle.

Noten spielen

Wir sind bereit, einige Noten zu spielen!

Zuerst benötigen wir eine Möglichkeit, festzustellen, welche Noten zu einem bestimmten Zeitpunkt gespielt werden. Lassen Sie uns dazu eine Map verwenden, da ihre eindeutige Schlüsselbeschränkung uns helfen kann, die gleiche Note nicht mehrmals mit einem einzigen Druck auszulösen. Außerdem kann ein Benutzer nur eine Taste gleichzeitig drücken, so dass wir diese als String speichern können.

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

Wir benötigen zwei Funktionen, eine zum Abspielen einer Taste – die wir bei keydown oder mousedown auslösen werden – und eine weitere zum Stoppen des Abspielens der Taste – die wir bei keyup oder mouseup auslösen werden.

Jede Taste wird auf ihrem eigenen Oszillator mit ihrem eigenen Gain-Knoten (zur Lautstärkeregelung) und ihrem eigenen Wellenformtyp (zur Bestimmung des Klangs) gespielt. Ich entscheide mich für eine "triangle"-Wellenform, aber Sie können jede beliebige der folgenden verwenden: "sine", "triangle", "sawtooth" und "square". Die Spezifikation bietet weitere Informationen zu diesen Werten.

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();
};

Unser Sound könnte etwas Verfeinerung vertragen. Im Moment hat er eine leicht durchdringende Mikrowellen-Summton-Qualität! Aber das reicht fürs Erste. Wir werden zurückkommen und am Ende einige Anpassungen vornehmen!

Das Stoppen einer Taste ist eine einfachere Aufgabe. Wir müssen jede Note nach dem Loslassen des Fingers für eine bestimmte Zeit "ausklingen" lassen (zwei Sekunden sind ungefähr richtig) und die notwendige visuelle Änderung vornehmen.

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);
  }
};

Alles, was noch übrig ist, ist das Hinzufügen unserer Ereignis-Listener.

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);
});

Beachten Sie, dass, obwohl die meisten unserer Ereignis-Listener dem HTML-document hinzugefügt werden, wir unser keys-Objekt verwenden können, um Klick-Listener zu den spezifischen Elementen hinzuzufügen, die wir bereits abgefragt haben. Wir müssen auch unserer höchsten Note eine spezielle Behandlung zukommen lassen, indem wir sicherstellen, dass die Taste ";" in das ausgeschriebene "semicolon" umgewandelt wird, das in unserem keys-Objekt verwendet wird.

Wir können jetzt die Tasten auf unserem Synthesizer spielen! Es gibt nur ein Problem. Der Ton ist immer noch ziemlich schrill! Wir wollen vielleicht die Oktave des Keyboards absenken, indem wir den Ausdruck ändern, den wir der freq-Konstante zuweisen.

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

Sie können vielleicht auch ein "Klicken" am Anfang und Ende des Tons hören. Wir können dies lösen, indem wir jeden Ton schnell einblenden und allmählich ausblenden lassen.

In der Musikproduktion verwenden wir den Begriff attack (Anschlag), um zu beschreiben, wie schnell ein Ton von Nichts zu seiner maximalen Lautstärke geht, und „release“ (Ausklang), um zu beschreiben, wie lange es dauert, bis ein Ton verhallt, sobald er nicht mehr gespielt wird. Ein weiteres nützliches Konzept ist decay (Abfall), die Zeit, die benötigt wird, damit ein Ton von seiner Spitzenlautstärke auf seine anhaltende Lautstärke abfällt. Glücklicherweise hat unser noteGainNode eine gain-Eigenschaft mit einer Methode namens exponentialRampToValueAtTime, mit der wir Anschlag, Ausklang und Abfall steuern können. Wenn wir unsere vorherige playKey-Funktion durch die folgende ersetzen, erhalten wir einen viel schöneren, pluckernden Klang.

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();
};

An diesem Punkt sollten wir einen funktionierenden, web-tauglichen Synthesizer haben!

Die Zahlen in unseren setAttack-, setDecay- und setRelease-Funktionen mögen etwas zufällig erscheinen, aber sie sind wirklich nur stilistische Entscheidungen. Versuchen Sie, sie zu ändern und zu sehen, was mit dem Klang passiert. Möglicherweise finden Sie etwas, das Ihnen besser gefällt!

Wenn Sie daran interessiert sind, das Projekt weiter auszubauen, gibt es viele Möglichkeiten, es zu verbessern. Vielleicht eine Lautstärkeregelung, eine Möglichkeit, zwischen Oktaven zu wechseln, oder eine Möglichkeit, zwischen Wellenformen zu wählen? Wir könnten Hall oder einen Tiefpassfilter hinzufügen. Oder vielleicht besteht jeder Ton aus mehreren Oszillatoren?

Für alle, die mehr darüber erfahren möchten, wie man Konzepte der Musiktheorie im Web implementiert, empfehle ich, sich den Quellcode des tonal npm-Pakets anzusehen.