[Перевод] Как я написал веб-синтезатор без сэмплов и зависимостей
Немного зная теорию музыки, чтобы создать цифровой инструмент, мы можем воспользоваться простыми 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
кардинально повлияет на результат:
Что касается стилизации клавиш и клавиатуры — особенно в нажатом состоянии, своим вдохновением я во многом обязан этому примеру. Определим общий для всех клавиш 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-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;
}
Если вы повторяли шаги в статье, у вас получилась такая клавиатура:
Стилизуем её:
#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
— количество нот выше или ниже данной высоты тона.
В коде получится что-то вроде этого:
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. Также вы можете узнать, как изменить карьеру или прокачаться в других направлениях:
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также: