
Немного зная теорию музыки, чтобы создать цифровой инструмент, мы можем воспользоваться простыми HTML, CSS и JavaScript без каких-либо библиотек или аудиосэмплов. К старту курса по Frontend-разработке делимся статьёй, автор которой рассказывает, как написать простой, но эффектный синтезатор.
Воспользуемся API AudioContext, чтобы создавать звуки в цифровом виде без сэмплов, но сначала поработаем над внешним видом клавиатуры.
Структура 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
Начнём с шаблона:
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<span class="token punctuation" style="box-sizing: border-box; color: rgb(114, 224, 209);">.</span><span class="token keyword" style="box-sizing: border-box; color: rgb(131, 186, 82);">delete</span><span class="token punctuation" style="box-sizing: border-box; color: rgb(114, 224, 209);">(</span>key<span class="token punctuation" style="box-sizing: border-box; color: rgb(114, 224, 209);">)</span><span class="token punctuation" style="box-sizing: border-box; color: rgb(114, 224, 209);">;</span> } };
Добавим слушателей событий:
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#
От основ — в глубину
А также:
