Alvaros
.
- Регистрация
- 14.05.16
- Сообщения
- 21.452
- Реакции
- 101
- Репутация
- 204
Web MIDI API — интересный зверь. Хоть он и существует уже почти пять лет, его все еще поддерживает только Chromium. Но это не помешает нам создать полноценный синтезатор в Angular. Пора поднять Web Audio API на новый уровень!
Ранее я рассказывал про
Программировать музыку, конечно, весело, но что если мы хотим ее играть? В 80-е годы появился стандарт обмена сообщениями между электронными инструментами — MIDI. Он активно используется и по сей день, и Chrome поддерживает его на нативном уровне. Это значит, что, если у вас есть синтезатор или MIDI-клавиатура, вы можете подключить их к компьютеру и считывать то, что вы играете. Можно даже управлять устройствами с компьютера, посылая исходящие сообщения. Давайте разберемся, как это сделать по-хорошему в Angular.
Web MIDI API
В интернете не так много документации на тему этого API, не считая
Dependency Injection
Чтобы подписаться на события, мы сначала должны получить MIDIAccess-объект, чтобы добраться до портов. navigator вернет нам Promise, а RxJs превратит его для нас в Observable. Мы можем создать для этого InjectionToken, используя NAVIGATOR из
export const MIDI_ACCESS = new InjectionToken
>(
'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 одним из двух способов:
Поскольку в этот раз нам понадобится совсем немного преобразований, токен вполне подойдет. С обработкой отказа код подписки на все события выглядит так:
export const MIDI_MESSAGES = new InjectionToken>(
'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,
'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,
id: string,
): Promise {
return midiAccess.then(access => access.outputs.get(id));
}
providers: [
outputById(‘someId’),
ANOTHER_TOKEN,
SomeService,
]
Аналогичным образом можно добывать и входные порты, а также запрашивать их по имени.
Операторы
Для работы с потоком событий нам потребуется создать свои операторы. В конце концов, мы же не хотим каждый раз ковыряться в исходном массиве данных.
Операторы можно условно разделить на две группы:
Вот так мы можем слушать события с определенного канала:
export function filterByChannel(
channel: MidiChannel,
): MonoTypeOperatorFunction {
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 {
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;
}),
);
}
Теперь можно строить цепочки операторов, чтобы получить стрим, который нам нужен:
readonly notes$ = this.messages$.pipe(
catchError(() => EMPTY),
notes(),
toData(),
);
constructor(
@Inject(MIDI_MESSAGES)
private readonly messages$: Observable,
) {}
Пора применить все это на практике!
Создаем синтезатор
С небольшой помощью
В качестве отправной точки используем последний кусок кода. Чтобы синтезатор был полифоническим, нужно отслеживать все сыгранные ноты. Для этого воспользуемся оператором 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',
};
}
}
С таким пайпом мы можем набросать синтезатор в шаблоне:
Мы перебираем собранные ноты с помощью встроенного keyvalue пайпа, отслеживая их по номеру сыгранной клавиши. Затем у нас есть два осциллятора, играющих нужные частоты. А в конце — эффект реверберации с помощью ConvolverNode. Довольно нехитрая конструкция и совсем немного кода, но мы получим хорошо звучащий, готовый к использованию инструмент. Попробуйте демо в Chrome:
Если у вас нет MIDI клавиатуры — можете понажимать на ноты мышкой.
Заключение
В Angular мы привыкли работать с событиями с помощью RxJs. И Web MIDI API не сильно отличается от привычных DOM событий. С помощью пары токенов и архитектурных решений мы смогли с легкостью добавить поддержку MIDI в наше Angular приложение. Описанное решение доступно в виде open-source библиотеки
Если вам любопытно, что же такого интересного можно сделать на Angular при помощи Web MIDI API — приглашаю вас научиться играть на клавишах в личном проекте
Ранее я рассказывал про
You must be registered for see links
.Программировать музыку, конечно, весело, но что если мы хотим ее играть? В 80-е годы появился стандарт обмена сообщениями между электронными инструментами — MIDI. Он активно используется и по сей день, и Chrome поддерживает его на нативном уровне. Это значит, что, если у вас есть синтезатор или MIDI-клавиатура, вы можете подключить их к компьютеру и считывать то, что вы играете. Можно даже управлять устройствами с компьютера, посылая исходящие сообщения. Давайте разберемся, как это сделать по-хорошему в Angular.
Web MIDI API
В интернете не так много документации на тему этого API, не считая
You must be registered for see links
. Вы запрашиваете доступ к MIDI-устройствам через navigator и получаете Promise со всеми входами и выходами. Эти входы и выходы — еще их называют портами — являются нативными EventTargetами. Обмен данными осуществляется через MIDIMessageEventы, которые содержат Uint8Array сообщения. В каждом сообщении не более 3 байт. Первый элемент массива называется status byte. Каждое число означает конкретную роль сообщения, например нажатие клавиши или движение ползунка параметра. В случае нажатой клавиши второй байт отвечает за то, какая клавиша нажата, а третий — как громко нота была сыграна. Полное описание сообщений можно подсмотреть
You must be registered for see links
. В Angular мы работаем с событиями через Observable, так что первым шагом станет приведение Web MIDI API к RxJs.Dependency Injection
Чтобы подписаться на события, мы сначала должны получить MIDIAccess-объект, чтобы добраться до портов. navigator вернет нам Promise, а RxJs превратит его для нас в Observable. Мы можем создать для этого InjectionToken, используя NAVIGATOR из
You must be registered for see links
. Так мы не обращается к глобальному объекту напрямую:export const MIDI_ACCESS = new InjectionToken
>(
'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 одним из двух способов:
- Создать сервис, который наследуется от Observable, как мы делали в
You must be registered for see links
- Создать токен с фабрикой, который будет транслировать этот Promise в Observable событий
Поскольку в этот раз нам понадобится совсем немного преобразований, токен вполне подойдет. С обработкой отказа код подписки на все события выглядит так:
export const MIDI_MESSAGES = new InjectionToken>(
'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,
'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,
id: string,
): Promise {
return midiAccess.then(access => access.outputs.get(id));
}
Кстати, вы знали, что нет необходимости спрэдить массив Provider[], когда добавляете его в метаданные? Поле providers декоратора @Directive поддерживает многомерные массивы, так что можно писать просто:
providers: [
outputById(‘someId’),
ANOTHER_TOKEN,
SomeService,
]
Если вам интересны подобные практичные мелочи про Angular — приглашаю почитать нашу
You must be registered for see links
.Аналогичным образом можно добывать и входные порты, а также запрашивать их по имени.
Операторы
Для работы с потоком событий нам потребуется создать свои операторы. В конце концов, мы же не хотим каждый раз ковыряться в исходном массиве данных.
Операторы можно условно разделить на две группы:
- Фильтрующие. Они отсеивает события, которые нас не интересуют. Например, если мы хотим слушать только сыгранные клавиши или ползунок громкости.
- Преобразующие. Они будут преобразовывать значения для нас. Например, оставлять только массив данных сообщения, отбрасывая остальные поля события.
Вот так мы можем слушать события с определенного канала:
export function filterByChannel(
channel: MidiChannel,
): MonoTypeOperatorFunction {
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 {
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,
) {}
Пора применить все это на практике!
Создаем синтезатор
С небольшой помощью
You must be registered for see links
, которую мы
You must be registered for see links
создадим приятно звучащий синтезатор всего за пару директив. Затем мы скормим ему ноты, которые играем через описанный выше стрим.В качестве отправной точки используем последний кусок кода. Чтобы синтезатор был полифоническим, нужно отслеживать все сыгранные ноты. Для этого воспользуемся оператором 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.
С таким пайпом мы можем набросать синтезатор в шаблоне:
Мы перебираем собранные ноты с помощью встроенного keyvalue пайпа, отслеживая их по номеру сыгранной клавиши. Затем у нас есть два осциллятора, играющих нужные частоты. А в конце — эффект реверберации с помощью ConvolverNode. Довольно нехитрая конструкция и совсем немного кода, но мы получим хорошо звучащий, готовый к использованию инструмент. Попробуйте демо в Chrome:
You must be registered for see links
Если у вас нет MIDI клавиатуры — можете понажимать на ноты мышкой.
Живое демо доступно тут, однако браузер не позволит получить доступ к MIDI в iframe:
You must be registered for see links
Заключение
В Angular мы привыкли работать с событиями с помощью RxJs. И Web MIDI API не сильно отличается от привычных DOM событий. С помощью пары токенов и архитектурных решений мы смогли с легкостью добавить поддержку MIDI в наше Angular приложение. Описанное решение доступно в виде open-source библиотеки
You must be registered for see links
. Она является частью большого проекта, под названием Web APIs for Angular. Наша цель — создание легковесных качественных оберток для использования нативного API в Angular приложениях. Так что если вам нужен, к примеру,
You must be registered for see links
или Intersection Observer — посмотрите
You must be registered for see links
.Если вам любопытно, что же такого интересного можно сделать на Angular при помощи Web MIDI API — приглашаю вас научиться играть на клавишах в личном проекте
You must be registered for see links



