Web MIDI API — интересный зверь. Хоть он и существует уже почти пять лет, его все еще поддерживает только Chromium. Но это не помешает нам создать полноценный синтезатор в Angular. Пора поднять Web Audio API на новый уровень!
Ранее я рассказывал про декларативную работу с Web Audio API в Angular.
Программировать музыку, конечно, весело, но что если мы хотим ее играть? В 80-е годы появился стандарт обмена сообщениями между электронными инструментами — MIDI. Он активно используется и по сей день, и Chrome поддерживает его на нативном уровне. Это значит, что, если у вас есть синтезатор или MIDI-клавиатура, вы можете подключить их к компьютеру и считывать то, что вы играете. Можно даже управлять устройствами с компьютера, посылая исходящие сообщения. Давайте разберемся, как это сделать по-хорошему в Angular.
Web MIDI API
В интернете не так много документации на тему этого API, не считая спецификации. Вы запрашиваете доступ к MIDI-устройствам через navigator
и получаете Promise
со всеми входами и выходами. Эти входы и выходы — еще их называют портами — являются нативными EventTarget
ами. Обмен данными осуществляется через MIDIMessageEvent
ы, которые содержат Uint8Array
сообщения. В каждом сообщении не более 3 байт. Первый элемент массива называется status byte. Каждое число означает конкретную роль сообщения, например нажатие клавиши или движение ползунка параметра. В случае нажатой клавиши второй байт отвечает за то, какая клавиша нажата, а третий — как громко нота была сыграна. Полное описание сообщений можно подсмотреть на официальном сайте MIDI. В Angular мы работаем с событиями через Observable
, так что первым шагом станет приведение Web MIDI API к RxJs.
Dependency Injection
Чтобы подписаться на события, мы сначала должны получить MIDIAccess
-объект, чтобы добраться до портов. navigator
вернет нам Promise
, а RxJs превратит его для нас в Observable
. Мы можем создать для этого InjectionToken
, используя NAVIGATOR
из @ng-web-apis/common. Так мы не обращается к глобальному объекту напрямую:
export const MIDI_ACCESS = new InjectionToken<Promise<MIDIAccess>>(
'Promise for MIDIAccess object',
{
factory: () => {
const navigatorRef = inject(NAVIGATOR);
return navigatorRef.requestMIDIAccess
? navigatorRef.requestMIDIAccess()
: Promise.reject(new Error('Web MIDI API is not supported'));
},
},
);
Теперь мы можем подписаться на все MIDI-события. Можно создать Observable
одним из двух способов:
- Создать сервис, который наследуется от
Observable
, как мы делали в Geolocation API - Создать токен с фабрикой, который будет транслировать этот
Promise
вObservable
событий
Поскольку в этот раз нам понадобится совсем немного преобразований, токен вполне подойдет. С обработкой отказа код подписки на все события выглядит так:
export const MIDI_MESSAGES = new InjectionToken<Observable<MIDIMessageEvent>>(
'All incoming MIDI messages stream',
{
factory: () =>
from(inject(MIDI_ACCESS).catch((e: Error) => e)).pipe(
switchMap(access =>
access instanceof Error
? throwError(access)
: merge(
...Array.from(access.inputs).map(([_, input]) =>
fromEvent(
input as FromEventTarget<MIDIMessageEvent>,
'midimessage',
),
),
),
),
share(),
),
},
);
Если нам нужен какой-то конкретный порт, например если мы хотим отправить исходящее сообщение, достанем его из MIDIAccess
. Для этого добавим еще один токен и подготовим фабрику для удобного использования:
export function outputById(id: string): Provider[] {
return [
{
provide: MIDI_OUTPUT_QUERY,
useValue: id,
},
{
provide: MIDI_OUTPUT,
deps: [MIDI_ACCESS, MIDI_OUTPUT_QUERY],
useFactory: outputByIdFactory,
},
];
}
export function outputByIdFactory(
midiAccess: Promise<MIDIAccess>,
id: string,
): Promise<MIDIOutput | undefined> {
return midiAccess.then(access => access.outputs.get(id));
}
Кстати, вы знали, что нет необходимости спрэдить массивProvider[]
, когда добавляете его в метаданные? Поле providers декоратора@Directive
поддерживает многомерные массивы, так что можно писать просто:
providers: [
outputById(‘someId’),
ANOTHER_TOKEN,
SomeService,
]
Если вам интересны подобные практичные мелочи про Angular — приглашаю почитать нашу серию твитов с полезными советами.
Аналогичным образом можно добывать и входные порты, а также запрашивать их по имени.
Операторы
Для работы с потоком событий нам потребуется создать свои операторы. В конце концов, мы же не хотим каждый раз ковыряться в исходном массиве данных.
Операторы можно условно разделить на две группы:
- Фильтрующие. Они отсеивает события, которые нас не интересуют. Например, если мы хотим слушать только сыгранные клавиши или ползунок громкости.
- Преобразующие. Они будут преобразовывать значения для нас. Например, оставлять только массив данных сообщения, отбрасывая остальные поля события.
Вот так мы можем слушать события с определенного канала:
export function filterByChannel(
channel: MidiChannel,
): MonoTypeOperatorFunction<MIDIMessageEvent> {
return source => source.pipe(filter(({data}) => data[0] % 16 === channel));
}
Status byte организован группами по 16: 128—143 отвечают за нажатые клавиши (noteOn
) на каждом из 16 каналов. 144—159 — за отпускание зажатых клавиш (noteOff
). Таким образом, если мы возьмем остаток от деления этого байта на 16 — получим номер канала.
Если нас интересуют только сыгранные ноты, поможет такой оператор:
export function notes(): MonoTypeOperatorFunction<MIDIMessageEvent> {
return source =>
source.pipe(
filter(({data}) => between(data[0], 128, 159)),
map(event => {
if (between(event.data[0], 128, 143)) {
event.data[0] += 16;
event.data[2] = 0;
}
return event;
}),
);
}
Некоторые MIDI-устройства отправляют явныеnoteOff
-сообщения, когда вы отпускаете клавишу. Но некоторые вместо этого отправляютnoteOn
сообщение с нулевой громкостью. Этот оператор нормализует такое поведение, приводя все сообщения кnoteOn
. Мы просто смещаем status byte на 16, чтобыnoteOff
-сообщения перешли на территориюnoteOn
, и задаем нулевую громкость.
Теперь можно строить цепочки операторов, чтобы получить стрим, который нам нужен:
readonly notes$ = this.messages$.pipe(
catchError(() => EMPTY),
notes(),
toData(),
);
constructor(
@Inject(MIDI_MESSAGES)
private readonly messages$: Observable<MIDIMessageEvent>,
) {}
Пора применить все это на практике!
Создаем синтезатор
С небольшой помощью библиотеки для Web Audio API, которую мы обсуждали ранеесоздадим приятно звучащий синтезатор всего за пару директив. Затем мы скормим ему ноты, которые играем через описанный выше стрим.
В качестве отправной точки используем последний кусок кода. Чтобы синтезатор был полифоническим, нужно отслеживать все сыгранные ноты. Для этого воспользуемся оператором scan:
readonly notes$ = this.messages$.pipe(
catchError(() => EMPTY),
notes(),
toData(),
scan(
(map, [_, note, volume]) => map.set(note, volume), new Map()
),
);
Чтобы звук не прерывался резко и не звучал всегда на одной громкости, создадим полноценный ADSR-пайп. В прошлой статье была его упрощенная версия. Напомню, идея ADSR — менять громкость звука следующим образом:
Чтобы нота начиналась не резко, удерживалась на определенной громкости, пока клавиша нажата, а потом плавно затухала.
@Pipe({
name: 'adsr',
})
export class AdsrPipe implements PipeTransform {
transform(
value: number,
attack: number,
decay: number,
sustain: number,
release: number,
): AudioParamInput {
return value
? [
{
value: 0,
duration: 0,
mode: 'instant',
},
{
value,
duration: attack,
mode: 'linear',
},
{
value: sustain,
duration: decay,
mode: 'linear',
},
]
: {
value: 0,
duration: release,
mode: 'linear',
};
}
}
Теперь, когда мы нажимаем клавишу, громкость будет линейно нарастать за время attack. Затем она убавится до уровня sustain за время decay. А когда мы отпустим клавишу, громкость упадет до нуля за время release.
С таким пайпом мы можем набросать синтезатор в шаблоне:
<ng-container
*ngFor="let note of notes | keyvalue; trackBy: noteKey"
>
<ng-container
waOscillatorNode
detune="5"
autoplay
[frequency]="toFrequency(note.key)"
>
<ng-container
waGainNode
gain="0"
[gain]="note.value | adsr: 0:0.1:0.02:1"
>
<ng-container waAudioDestinationNode></ng-container>
</ng-container>
</ng-container>
<ng-container
waOscillatorNode
type="sawtooth"
autoplay
[frequency]="toFrequency(note.key)"
>
<ng-container
waGainNode
gain="0"
[gain]="note.value | adsr: 0:0.1:0.02:1"
>
<ng-container waAudioDestinationNode></ng-container>
<ng-container [waOutput]="convolver"></ng-container>
</ng-container>
</ng-container>
</ng-container>
<ng-container
#convolver="AudioNode"
waConvolverNode
buffer="assets/audio/response.wav"
>
<ng-container waAudioDestinationNode></ng-container>
</ng-container>
Мы перебираем собранные ноты с помощью встроенного keyvalue
пайпа, отслеживая их по номеру сыгранной клавиши. Затем у нас есть два осциллятора, играющих нужные частоты. А в конце — эффект реверберации с помощью ConvolverNode
. Довольно нехитрая конструкция и совсем немного кода, но мы получим хорошо звучащий, готовый к использованию инструмент. Попробуйте демо в Chrome:
https://ng-web-apis.github.io/midi
Если у вас нет MIDI-клавиатуры — можете понажимать на ноты мышкой.
Живое демо доступно тут, однако браузер не позволит получить доступ к MIDI в iframe: https://stackblitz.com/edit/angular-midi
Заключение
В Angular мы привыкли работать с событиями с помощью RxJs. И Web MIDI API не сильно отличается от привычных DOM-событий. С помощью пары токенов и архитектурных решений мы смогли с легкостью добавить поддержку MIDI в наше Angular приложение. Описанное решение доступно в виде open-source библиотеки @ng-web-apis/midi. Она является частью большого проекта, под названием Web APIs for Angular. Наша цель — создание легковесных качественных оберток для использования нативного API в Angular приложениях. Так что если вам нужен, к примеру, Payment Request API или Intersection Observer — посмотрите все наши релизы.
Если вам любопытно, что же такого интересного можно сделать на Angular при помощи Web MIDI API — приглашаю вас научиться играть на клавишах в личном проекте Jamigo.app