Синтезаторная клавиатура с USB выходом стоит недорого, а привычные редакторы звука требуют установки, драйверов и пары гигабайт на диске. Браузер давно умеет другое: страница сама запрашивает доступ к MIDI устройству, ловит нажатия клавиш и генерирует звук прямо в динамики. Управляет всем небольшое семейство интерфейсов под общим названием Web MIDI API, и поддержка уже достаточна, чтобы не считать эти слова футурологией. Ниже разбор того, как устроен сам протокол, что именно браузер отдаёт разработчику, почему один браузер остаётся вне списка и как из нажатия клавиши получить слышимый звук.
Протокол MIDI появился в 1983 году и до сих пор живёт на трёх байтах
Идею протокола в начале восьмидесятых сформулировали инженеры Дэйв Смит и Икутаро Какэхаси, а в 2013 году оба получили техническую премию Грэмми за вклад в развитие музыкальных технологий. Смысл был простым: договориться о едином языке, на котором любые синтезаторы, драм-машины и контроллеры могли бы разговаривать между собой. Классический проводной MIDI гонит данные со скоростью 31250 бит в секунду, и этого хватает, потому что сообщение почти никогда не длиннее трёх байтов: один байт статуса и до двух байтов данных. Каналов шестнадцать, нумерация в протоколе идёт с нуля, а на панелях приборов принято считать с единицы.
Байт статуса несёт команду, и базовый набор невелик:
- 0x80 команда Note Off гасит ноту на выбранном канале;
- 0x90 команда Note On зажигает ноту, нулевая скорость заменяет гашение;
- 0xB0 команда Control Change управляет педалями и регуляторами;
- 0xC0 команда Program Change переключает тембр одним байтом данных;
- 0xE0 команда Pitch Bend изгибает высоту с точностью до 14 бит.
Отдельным семейством живут системные сообщения: тактовые импульсы 0xF8, которых на одну четверть приходится ровно двадцать четыре, и эксклюзивные пакеты SysEx, обёрнутые в байты 0xF0 и 0xF7. Эксклюзивные пакеты нужны для всего, что не влезло в стандарт: прошивки, патчи, фирменные форматы производителей. Простота протокола и оказалась секретом долголетия: сорок с лишним лет спустя MIDI по-прежнему работает, а адаптеры USB MIDI просто упаковывают те же байты в современные кабели, то же делают беспроводные варианты.
Стандарт General MIDI 1991 года добавил поверх протокола обязательную раскладку: программы тембров пронумерованы от акустического пианино до синтетических эффектов, ударные закреплены за десятым каналом, и любой инструмент, соблюдающий раскладку, звук в звук понимает чужие партитуры. Раскладка дала рынку совместимость, а браузерным проектам даёт предсказуемость: веб-синтезатор может смело отправить партию ударных на канал 10 и не гадать, какой тембр окажется на пятом месте.
Поддержка браузеров и правила доступа к миди устройствам
Список браузеров с Web MIDI API выглядит так: Chrome получил интерфейс в версии 43 ещё в 2015 году, Firefox добавил его в версии 108 в декабре 2022 года, при этом доступ выдаётся только после согласия человека установить специальное разрешающее дополнение браузера, а Safari остаётся вне списка, и в мобильной версии его тоже нет. Браузеры на движке Chromium наследуют поддержку вместе с остальным ядром, поэтому Edge и Opera работают из коробки. Отсутствие Safari закладывают в архитектуру заранее: музыкальной странице нужен запасной сценарий, при котором она остаётся полезной без железа.
Доступ выдают строго на защищённом соединении HTTPS и всегда с явного разрешения. Браузер показывает диалог, и только после согласия страница увидит список подключённых устройств. Текущее состояние разрешения можно спросить заранее через стандартный интерфейс разрешений:
const status = await navigator.permissions.query({
name: "midi",
sysex: false
});
Отдельного согласия заслуживают эксклюзивные пакеты SysEx: они запрашиваются параметром при подключении, потому что умеют перезаписывать память дорогих устройств. Политика разрешений на уровне страницы может вовсе запретить интерфейс, и такое ограничение пользовательским согласием не обходится. Перед любой работой с устройствами ставят проверку на существование метода:
if ("requestMIDIAccess" in navigator) {
const access = await navigator.requestMIDIAccess();
renderInputs(access);
} else {
showFallbackPage();
}
Такой экран честно делит аудиторию: тем, у кого есть браузер и клавиатура, достаётся полноценный инструмент, остальные получают страницу без сломанных кнопок.
Код подключения к устройству и чтение сообщений от клавиш
Точка входа одна: метод возвращает промис с объектом доступа, у которого есть карты входов и выходов. Каждому входу назначается обработчик, и сообщения приходят в виде короткого массива байтов с меткой времени.
const access = await navigator.requestMIDIAccess({ sysex: false });
for (const input of access.inputs.values()) {
input.onmidimessage = (event) => {
const [status, note, velocity] = event.data;
const command = status & 0xf0;
const channel = (status & 0x0f) + 1;
if (command === 0x90 && velocity > 0) {
synth.noteOn(note, velocity);
}
if (command === 0x80 || (command === 0x90 && velocity === 0)) {
synth.noteOff(note);
}
};
}
Маска 0xf0 выделяет команду, маска 0x0f канал, поэтому один и тот же обработчик обслуживает все шестнадцать каналов сразу. Тут прячется практическая ловушка: нота с нулевой скоростью это то же самое, что гашение, и многие недорогие клавиатуры экономят именно так, не посылая отдельное сообщение выключения. Массив event.data это тип Uint8Array, а метка event.timeStamp отсчитывается в миллисекундах от старта страницы и позволяет выстроить единую шкалу времени для событий с нескольких устройств.
Когда устройств несколько, проще всего назначить один и тот же обработчик всем входам сразу, а различать сообщения по свойству текущего порта:
function onMessage(event) {
const port = event.currentTarget.id;
const [status, note, velocity] = event.data;
route(port, status & 0xf0, note, velocity);
}
for (const input of access.inputs.values()) {
input.onmidimessage = onMessage;
}
Есть и тонкость старого протокола под названием running status: экономные устройства передают статус-байт один раз, а дальше гонят только байты данных. Парсер, читающий строгие тройки, на таком потоке спотыкается, обёртки из библиотек закрывают случай автоматически, и в самописном коде это стоит учесть с самого начала.
Устройства в реальной жизни втыкают и выдёргивают на ходу, и интерфейс сообщает об этом событием смены состояния:
access.onstatechange = (event) => {
if (event.port.state === "connected") {
console.log("подключился", event.port.name);
}
};
Обработчик держат коротким: поток нажатий при активной игре плотный, и тяжёлую работу лучше отдать аудио графу или фоновому вычислению. Спецификация описывает интерфейс как доступный и в потоках воркеров, что пригождается при обработке больших SysEx пакетов.
От нажатия клавиши до звука через аудио граф браузера
MIDI не содержит звука, только события, поэтому слышимую волну строит соседний интерфейс, Web Audio API. Схема классическая: контекст, осциллятор для формы волны, усилитель для громкости. Частота ноты считается из её номера по формуле 440 * 2^((note - 69) / 12), где нота 69 это ля первой октавы, а всего в MIDI их 128. Простейший полифонический синтезатор умещается в один класс:
const ctx = new AudioContext();
class Synth {
constructor() {
this.voices = new Map();
}
noteOn(note, velocity) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 440 * Math.pow(2, (note - 69) / 12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(velocity / 127, ctx.currentTime + 0.02);
this.voices.set(note, { osc, gain });
}
noteOff(note) {
const voice = this.voices.get(note);
if (!voice) return;
voice.gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.2);
voice.osc.stop(ctx.currentTime + 0.25);
this.voices.delete(note);
}
}
Пара экспоненциальных рамп на усилителе заменяет полноценную огибающую: атака за двадцать миллисекунд, затухание за двести. Карта активных голосов даёт полифонию: сколько нот зажато, столько осцилляторов звучит. Дальше начинается ремесло синтезостроения: вторые осцилляторы для богатого тона, фильтры, задержки, и всё это расписание можно строить заранее, опираясь на время аудио контекста, а не на момент прихода события.
Звук не пойдёт без ещё одной детали: браузер блокирует автозапуск аудио, и контекст рождается приостановленным до первого действия человека. Оживляют его по первому же клику, а обработчик с опцией once снимает себя после срабатывания:
document.addEventListener("click", () => ctx.resume(), { once: true });
Там, где к странице подключена клавиатура, первым действием часто становится именно нажатие её клавиши, и пауза уходит сама, без лишних экранов.
Обратный канал отправка команд из браузера на устройство
Вторая половина интерфейса отправляет байты обратно в железо. Это может быть подсветка пэдов на контроллере, смена тембра на синтезаторе или полностью браузерный редактор патчей. Метод отправки прост, а расписание передаётся вторым аргументом:
const output = access.outputs.values().next().value;
output.send([0x90, 60, 100]);
output.send([0x80, 60, 64], performance.now() + 500);
Нота 60 зазвучит сразу и погаснет через полсекунды, при этом основному коду не нужны таймеры. Для фирменных команд производителям хватает SysEx: последовательность байтов между 0xF0 и 0xF7 уходит устройству как есть, и раньше ради таких вещей ставили отдельные редакторы, а теперь хватает страницы в браузере. Стоит помнить, что эксклюзивные пакеты требуют отдельного разрешения при подключении, и это правильный барьер: одна неудачная команда способна стереть пользовательские пресеты.
Библиотеки и типичные проекты браузерных музыкальных приложений
Голый интерфейс многословен, поэтому на нём выросли обёртки. Самая известная библиотека WEBMIDI.js, актуальная версия 3.3.1 в реестре npm, превращает байтовые операции в осмысленные вызовы и работает не только в браузере, но и в Node.js на машине с подключённой клавиатурой:
import { WebMidi } from "webmidi";
await WebMidi.enable();
const synth = WebMidi.outputs[0];
synth.playNote("C4", { attack: 0.8 });
synth.sendPitchBend(0.5);
На этом фундаменте живут целые классы приложений. Браузерные синтезаторы и драм-машины, где железная клавиатура служит контроллером. Тренажёры аккордов и сольфеджио, которые проверяют правильность нажатий в реальном времени. Нотные редакторы с подсветкой играемых мест. Редакторы патчей и библиотек звуков для настольных синтезаторов, работающие без установки. Мониторы для отладки собственных контроллеров, показывающие каждый входящий байт. Плюс панели управления светом и сценическим оборудованием, потому что контроллеры там тоже говорят на миди.
Ограничения честны: браузер не гарантирует жёсткое реальное время, задержка зависит от операционной системы и драйверов, а для аккуратного звука важны короткие обработчики. Практика простая: не создавать объекты на каждое сообщение, не считать тяжёлую математику в обработчике нажатия, проверять результат на средних устройствах, а не только на быстрой рабочей машине.
Ограничения платформы и честный путь к совместимости
Главное ограничение уже названо: Safari не поддерживает интерфейс, и полифиллы на старых плагинах для него давно не работают. Отсюда правило архитектуры: миди это улучшение, а не фундамент. Страница должна проигрывать пробный звук от экранных кнопок, объяснять, что подключение клавиатуры даст больше, и не превращаться в белый экран без устройства.
Второе правило касается момента запроса. Диалог разрешения выскакивает тогда, когда его вызывает код, поэтому доступ просят по нажатию кнопки вроде "подключить клавиатуру", а не на загрузке страницы, когда человек ещё не понял, зачем этому сайту его инструменты. После согласия браузер запоминает выбор, и следующий визит стартует тише.
Третье правило про честность измерений: задержка нажатие-звук складывается из драйвера устройства, планировщика операционной системы и очереди самого браузера, и на разных машинах она разная. Прежде чем обещать музыкантам скорость, её измеряют на целевой аудитории устройств. При аккуратной сборке браузер закрывает большинство сценариев, для которых раньше требовалась связка редактора, драйвера и пары гигабайт на диске, и делает это одной строкой запроса доступа.