company_banner

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

    Tinkoff
    it’s Tinkoff — просто о сложном

    Комментарии 7

    • НЛО прилетело и опубликовало эту надпись здесь
        +2
        Чувак с картинки умер на прошлой неделе
        –2
        Спасибо, познавательно!
          0

          Что насчет задержки? К сожалению сейчас нет миди-клавиатуры, чтобы попробовать в "реальных условиях", но тыканье мышкой создало ощущение, что где-то 50мс есть, что как бы проблема для нормальной игры в живую.

            0
            Да, что-то вроде того. На самом деле, довольно быстро привыкаешь. Эта задержка сравнима с задержкой, скажем, в игре Rocksmith, если слышали о такой. Профессиональных музыкантов напрягает, обычно от 20-30мс, тут уж ничего не поделаешь.
              0

              Как бывший профессиональный музыкант точно скажу — к 50 мс привыкнуть невозможно (

                +1
                Ну я на слух говорю. Померил через console.time — время, между регистрацией события и запуском аудио буфера на воспроизведение (т.е. накладные расходы от ангуляра с его байндингами, RxJS и декларативного веб аудио) обычно порядка 5мс:
                image
                Так что остальное — input latency, обусловленное реализацией Web MIDI API в Chromium. А сколько там его я затрудняюсь померить :)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое