Русский
Русский
English
Статистика
Реклама

Лабаем на 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: 128143 отвечают за нажатые клавиши (noteOn) на каждом из 16 каналов. 144159 за отпускание зажатых клавиш (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

Источник: habr.com
К списку статей
Опубликовано: 30.06.2020 14:13:12
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании tinkoff.ru

Angular

Javascript

Open source

Typescript

Музыка

Музыкальные инструменты

Музыкальные сервисы

Категории

Последние комментарии

© 2006-2020, personeltest.ru