[Перевод] Как я написал веб-синтезатор без сэмплов и зависимостей

image-loader.svg

Немного зная теорию музыки, чтобы создать цифровой инструмент, мы можем воспользоваться простыми HTML, CSS и JavaScript без каких-либо библиотек или аудиосэмплов. К старту курса по Frontend-разработке делимся статьёй, автор которой рассказывает, как написать простой, но эффектный синтезатор.

Воспользуемся API AudioContext, чтобы создавать звуки в цифровом виде без сэмплов, но сначала поработаем над внешним видом клавиатуры.

Структура HTML

Мы будем поддерживать стандартную западную клавиатуру, где каждая буква между A и ; соответствует белой клавише, а ряд выше можно использовать для диезов и бемолей (чёрных клавиш). Это означает, что клавиатура охватывает чуть больше октавы, начинаясь с C₃ и заканчиваясь E₄. Для тех, кто не знаком с нотной грамотой, цифры подстрочных индексов обозначают октаву.

Одна из полезных вещей, которую мы можем сделать, — сохранить значение ноты в пользовательском атрибуте note, чтобы к нему можно было легко обратиться в JavaScript. Пропишем буквы компьютерной клавиатуры, чтобы помочь пользователям понять, что нажимать.

  • A
  • W
  • S
  • E
  • D
  • F
  • T
  • G
  • Y
  • H
  • U
  • J
  • K
  • O
  • L
  • P
  • ;

Стилизация на 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 кардинально повлияет на результат:

image-loader.svg

Что касается стилизации клавиш и клавиатуры — особенно в нажатом состоянии, своим вдохновением я во многом обязан этому примеру. Определим общий для всех клавиш 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;
}

Радиус границы на первой и последней клавише помогает сделать дизайн более органичным, без скругления левый и правый верхние углы клавиш выглядят немного неестественно. Вот окончательный вариант дизайна за вычетом лишних округлений на первой и последней клавишах.

image-loader.svg

CSS для эстетики клавиш:

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

Разница небольшая, но эффектная:

image-loader.svg

Теперь применяем стили, определяющие различия белых и чёрных клавиш. Обратите внимание, что белые клавиши имеют z-index: 1, а чёрные клавиши — z-index: 2:

.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, чтобы добавить класс pressed к соответствующему элементу li . Пока что мы можем проверить работоспособность класса, добавив его в 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;
}

Некоторые белые клавиши нужно сдвинуть влево, чтобы они оказались под чёрными. Ради простоты напишем класс offset:

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

Если вы повторяли шаги в статье, у вас получилась такая клавиатура:

image-loader.svg

Стилизуем её:

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

Элементы можно хранить в keys, где ключ объекта — это клавиша, которую нажмёт пользователь.

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, которое понадобится нам при определении высоты тона. Тон нужно подавать в герцах, вот его уравнение: x * 2^(y / 12), где x — выбранная нота в герцах, обычно A₄, частота которой равна 440Hz,  и y — количество нот выше или ниже данной высоты тона.

image-loader.svg

В коде получится что-то вроде этого:

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 = "";

Нам нужны две функции, одна из которых будет играть ключевую роль: её мы будем запускать по keydown и mousedown, другая функция будет останавливать игру и запускаться по keyup и mouseup. Каждая клавиша будет воспроизводиться на собственном осцилляторе со своим узлом усиления (он управляет громкостью) и типом формы волны для определения тембра звука. Я выбрал "triangle", но вы можете воспользоваться "sine", "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-документ, мы можем использовать keys, чтобы добавить слушателей кликов к определённым элементам. Мы также должны уделить особое внимание нашей самой высокой ноте, убедившись, что конвертируем клавишу ";" в "semicolon" для keys.

Теперь мы можем играть на клавишах синтезатора. Есть только одна проблема: звук по-прежнему довольно пронзительный. Изменим выражение, которое присваиваивается константе 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, setDecay и setRelease могут показаться немного случайными, но на самом деле это просто подбор стиля. Попробуйте поменять их местами и посмотрите, что произойдёт со звуком. Возможно, в итоге вы получите звук, который понравится вам больше!

Если вы заинтересованы в дальнейшем развитии проекта, есть много способов его улучшить. Регулятор громкости, переключение между октавами или выбор формы волны — это лишь некоторые примеры. Мы можем добавить реверберацию или фильтр низких частот. Или, возможно, составлять каждый звук из нескольких осцилляторов. Людям, которые хотят глубже понимать реализацию понятий теории музыки в вебе, я рекомендую ознакомиться с исходным кодом пакета npm tonal.

Эта статья напоминает, что веб, который начался как язык разметки для облегчения работы с научными публикациями, сегодня превратился в полноценную платформу, а браузер в смысле сложности внутренней организации не уступает операционным системам. При этом языки веба остаются относительно простыми и прозрачными, а значит веб-разработка в целом и Frontend в частности будут востребованы ещё долгие годы. Если вам интересна сфера программирования в вебе, приходите на наш курс по Frontend-разработке или, если не хотите ограничиваться фронтом, на курс по Fullstack-разработке на Python. Также вы можете узнать, как изменить карьеру или прокачаться в других направлениях:

image-loader.svg

Data Science и Machine Learning

Python, веб-разработка

Мобильная разработка

Java и C#

От основ — в глубину

А также:

© Habrahabr.ru