Хабрахабр

Лабаем на MIDI-клавиатуре в Angular

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 одним из двух способов:

  1. Создать сервис, который наследуется от Observable, как мы делали в Geolocation API
  2. Создать токен с фабрикой, который будет транслировать этот 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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»