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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

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/


Интересные статьи

Интересные статьи

Недавно мне надо было решить задачу по смене старого механизма для вывода всплывающих подсказок, реализованного средствами нашей библиотеки компонентов, на новый. Я, как всегда, решил не занимать...
Всем привет, меня зовут Владимир. Я занимаюсь фронтенд разработкой в Tinkoff.ru. В Ангуляре для передачи данных внутри приложения или для инкапсуляции бизнес-логики мы привыкли использовать серв...
Angular-разработчики в большом долгу перед библиотекой zone.js. Она, например, помогает достичь в работе с Angular почти волшебной лёгкости. На самом деле, практически всегда, когда нужно просто ...
Здравствуйте. Я уже давно не пишу на php, но то и дело натыкаюсь на интернет-магазины на системе управления сайтами Битрикс. И я вспоминаю о своих исследованиях. Битрикс не любят примерно так,...
Сегодня мы поговорим о перспективах становления Битрикс-разработчика и об этапах этого пути. Статья не претендует на абсолютную истину, но даёт жизненные ориентиры.