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: 128143 отвечают за
нажатые клавиши (noteOn
) на каждом из 16 каналов.
144159 за отпускание зажатых клавиш (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