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
ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/508788/