Лабаем на 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

ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/508788/

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

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