Web Audio API существует уже давно, и про него есть немало статей. Поэтому про сам API много говорить не будем. Расскажем, что Web Audio и Angular могут стать лучшими друзьями, если их правильно познакомить. Давайте сделаем это!
Работа с Web Audio API заключается в создании графа аудионод, через которые будет проходить звук, чтобы в конечном итоге оказаться на колонках. В звук можно вносить задержку, менять громкость, накладывать искажения. Для этого в браузерах есть специализированные ноды с набором параметров. Изначально ноды создавались с помощью методов-фабрик аудиоконтекста:
const context = new AudioContext(); const gainNode = context.createGain();
Но с некоторых пор они стали полноценными классами с конструктором, а значит, от них можно наследоваться. Это позволит нам красиво и декларативно использовать Web Audio API в Angular.
Директивы
В Angular директивы — это классы, и они могут наследоваться в том числе и от нативных классов. Типичная цепочка обратной связи, которая добавит к нашему сигналу эхо, выглядит так:
const context = new AudioContext(); const gainNode = new GainNode(context); const delayNode = new DelayNode(context); const audioSource = new MediaElementAudioSourceNode(context, { mediaElement: audioElement.nativeElement, }); gainNode.gain.value = 0.5; delayNode.delayTime.value = 0.2; audioSource.connect(gainNode); audioSource.connect(context.destination); gainNode.connect(delayNode); delayNode.connect(gainNode); delayNode.connect(context.destination);
Как видим, нативный код — сугубо императивный. Мы создаем объекты, задаем параметры, руками собираем граф через метод connect. В примере выше используется HTML audio тэг и, когда пользователь нажмет плей, он услышит свой аудиофайл с эффектом эха. Мы реализуем этот пример через директивы. AudioContext
будет браться из Dependency Injection.
GainNode
и DelayNode
имеют всего один параметр — уровень громкости и время задержки соответственно. Это не просто числовой инпут, а некий AudioParam
. Подробнее про него поговорим позже.
@Directive({ selector: '[waGainNode]', inputs: [ 'channelCount', 'channelCountMode', 'channelInterpretation' ], }) export class WebAudioGain extends GainNode { @Input('gain') set gainSetter(value: number) { this.gain.value = value; } constructor(@Inject(AUDIO_CONTEXT) context: AudioContext) { super(context); } }
Заметим, что у всех нод есть три параметра: channelCount
, channelCountMode
и channelInterpretation
. Благодаря inputs
из декоратора @Directive
мы можем просто перечислить их — и оно будет работать без единой строчки кода. DelayNode
будет выглядеть точно так же. Для декларативной связи нодов добавим новый токен AUDIO_NODE
, который будет предоставлять каждая наша директива:
@Directive({ selector: '[waGainNode]', inputs: [ 'channelCount', 'channelCountMode', 'channelInterpretation' ], exportAs: 'AudioNode', providers: [{ provide: AUDIO_NODE, useExisting: forwardRef(() => WebAudioGain), }], }) export class WebAudioGain extends GainNode implements OnDestroy { @Input('gain') set gainSetter(value: number) { this.gain.value = value; } constructor( @Inject(AUDIO_CONTEXT) context: BaseAudioContext, @SkipSelf() @Inject(AUDIO_NODE) node: AudioNode | null, ) { super(context); if (node) { node.connect(this); } } ngOnDestroy() { this.disconnect(); } }
Директивы берут из DI вышестоящий нод и соединяются с ним. Обратите внимание на появление в декораторе exportAs
— так ноды будут доступны через template reference variables. Теперь мы можем строить граф в шаблоне:
<ng-container waGainNode> <ng-container waDelayNode></ng-container> </ng-container>
Для вывода звука в конце цепочки создадим директиву waAudioDestination
:
@Directive({ selector: '[waAudioDestinationNode]', exportAs: 'AudioNode', }) export class WebAudioDestination extends GainNode implements OnDestroy { constructor( @Inject(AUDIO_CONTEXT) context: AudioContext, @Inject(AUDIO_NODE) node: AudioNode | null, ) { super(context); this.connect(context.destination); if (node) { node.connect(this); } } ngOnDestroy() { this.disconnect(); } }
Для создания петель, как в примере с обратной связью, недостаточно подключения через Dependency Injection. Мы сделаем специальную директиву. Она позволит нам передать нод как инпут, чтобы подключиться к нему:
@Directive({ selector: '[waOutput]', }) export class WebAudioOutput extends GainNode implements OnDestroy { @Input() set waOutput(destination: AudioNode | undefined) { this.disconnect(); if (destination) { this.connect(destination); } } constructor( @Inject(AUDIO_CONTEXT) context: AudioContext, @Inject(AUDIO_NODE) node: AudioNode | null, ) { super(context); if (node) { node.connect(this); } } ngOnDestroy() { this.disconnect(); } }
Обе эти директивы наследуются от GainNode
, что создает дополнительное звено в цепи. Это облегчает разъединение в ngOnDestroy
. Нам не нужно помнить, с чем связан текущий нод, достаточно просто отрезать this
от всего разом.
Источники
Последняя нужная нам директива отличается от остальных. Это нод-источник, он всегда находится на вершине дерева. Мы будем вешать директиву на audio
тэги, и она будет превращать их для нас в MediaElementAudioSourceNode
:
@Directive({ selector: 'audio[waMediaElementAudioSourceNode]', exportAs: 'AudioNode', providers: [ { provide: AUDIO_NODE, useExisting: forwardRef(() => WebAudioMediaSource), }, ], }) export class WebAudioMediaSource extends MediaElementAudioSourceNode implements OnDestroy { constructor( @Inject(AUDIO_CONTEXT) context: AudioContext, @Inject(ElementRef) {nativeElement}: ElementRef<HTMLMediaElement>, ) { super(context, {mediaElement: nativeElement}); } ngOnDestroy() { this.disconnect(); } }
Воссоздадим пример с эффектом эха через директивы:
<audio src="/demo.wav" waMediaElementAudioSourceNode> <ng-container #node="AudioNode" waDelayNode [delayTime]="0.2"> <ng-container waGainNode [gain]="0.5"> <ng-container [waOutput]="node"></ng-container> <ng-container waAudioDestinationNode></ng-container> </ng-container> </ng-container> <ng-container waAudioDestinationNode></ng-container> </audio>
В Web Audio API много различных нодов, но все их можно реализовать похожим образом. Из нодов-источников важными являются также осциллятор и аудиобуфер. Зачастую мы не хотим ничего добавлять в HTML и нам нет нужды давать пользователю контроль над запуском звука. В этом случае хорошо подойдет AudioBufferSourceNode
. Единственное неудобство — он не может сам использовать файл по ссылке, ему требуется готовый `AudioBuffer. Мы поможем ему с этим и создадим сервис для превращения аудиофайлов в AudioBuffer
:
@Injectable({ providedIn: 'root', }) export class AudioBufferService { private readonly cache = new Map<string, AudioBuffer>(); constructor( @Inject(AUDIO_CONTEXT) private readonly context: AudioContext ) {} fetch(url: string): Promise<AudioBuffer> { return new Promise<AudioBuffer>((resolve, reject) => { if (this.cache.has(url)) { resolve(this.cache.get(url)); return; } const request = new XMLHttpRequest(); request.open('GET', url, true); request.responseType = 'arraybuffer'; request.onerror = reject; request.onabort = reject; request.onload = () => { this.context.decodeAudioData( request.response, buffer => { this.cache.set(url, buffer); resolve(buffer); }, reject, ); }; request.send(); }); } }
Теперь мы создадим директиву для AudioBufferSourceNode
, которая принимает на вход и AudioBuffer
, и ссылку на аудиофайл:
export class WebAudioBufferSource extends AudioBufferSourceNode implements OnDestroy { @Input('buffer') set bufferSetter(source: AudioBuffer | null | string) { this.buffer$.next(source); } readonly buffer$ = new Subject<AudioBuffer | null | string>(); constructor( @Inject(AudioBufferService) service: AudioBufferService, @Inject(AUDIO_CONTEXT) context: AudioContext, @Attribute('autoplay') autoplay: string | null, ) { super(context); this.buffer$ .pipe( switchMap(source => typeof source === 'string' ? service.fetch(source) : of(source), ), ) .subscribe(buffer => { this.buffer = buffer; }); if (autoplay !== null) { this.start(); } } ngOnDestroy() { this.buffer$.complete(); try { this.stop(); } catch (_) {} this.disconnect(); } }
Отметим, что тут мы добавили поддержку атрибута autoplay
по аналогии с тэгом audio
, чтобы запускать звук сразу после создания директивы.
AudioParam
У нодов есть особый тип параметров — AudioParam
. У GainNode
таковым является громкость. Именно поэтому мы задавали его через сеттер. Значения такого параметра можно автоматизировать. Он может плавно меняться — линейно, по экспоненте или даже по массиву чисел за заданное время. Нам нужен обработчик для инпута, который позволил бы управлять поведением всех AudioParam
параметров наших директив. Для этого создадим специальный декоратор:
@Input('gain') @audioParam('gain') gainParam?: AudioParamInput;
Декоратор будет передавать обработку в специальную функцию:
export type AudioParamDecorator<K extends string> = ( target: AudioNodeWithParams<K>, propertyKey: string, ) => void; export function audioParam<K extends string>( param: K, ): AudioParamDecorator<K> { return (target, propertyKey) => { Object.defineProperty(target, propertyKey, { set( this: AudioNode & Record<K, AudioParam>, value: AudioParamInput, ) { processAudioParam( this[param], value, this.context.currentTime, ); }, }); }; }
Строгая типизация не позволит нам случайно повесить декоратор на несуществующий параметр. Что же представляет собой новый тип AudioParamInput
? Кроме числа в него входит еще объект вида:
export type AudioParamAutomation = Readonly<{ value: number; duration: number; mode: 'instant' | 'linear' | 'exponential'; }>;
Функция processAudioParam
транслирует эти значения в команды нативного API. Ее содержимое скучное, так что я опишу только принцип работы. Если значение параметра 0 и мы хотим, чтобы оно линейно изменилось до 1 за секунду, — передадим такой объект: {value: 1, duration: 1, mode: ‘linear’}
. Для сложных автоматизаций понадобится еще возможность передать массив таких объектов.
Мы будем передавать не число, а подобный объект с коротким duration
, чтобы задать параметр. Это позволит избежать слышимых скачков при моментальном изменении. Однако создавать его каждый раз самим неудобно. Напишем пайп, который будет принимать на вход значение и длительность, и опционально режим:
@Pipe({ name: 'waAudioParam', }) export class WebAudioParamPipe implements PipeTransform { transform( value: number, duration: number, mode: AudioParamAutomationMode = 'exponential', ): AudioParamAutomation { return { value, duration, mode, }; } }
Кроме всего этого AudioParam
можно автоматизировать подключением к нему осциллятора. Обычно используют частоты меньше 1, это называется LFO — Low Frequency Oscillator. Он создает плавающие эффекты в звуке. В примере ниже это придает протяжным аккордам движение — благодаря модуляции частоты срезающего фильтра. Для связи осциллятора и параметра годится все та же директива waOutput
, которую мы уже создали. Нод возьмем благодаря exportAs
директивы:
<ng-container waOscillatorNode frequency="0.2" autoplay> <ng-container waGainNode gain="3000"> <ng-container [waOutput]="filter.frequency"></ng-container> </ng-container> </ng-container> <ng-container waOscillatorNode autoplay [frequency]="note"> <ng-container #filter="AudioNode" waBiquadFilterNode type="bandpass" frequency="4000" > <ng-container waAudioDestinationNode></ng-container> </ng-container> </ng-container>
Время ретровейва
Web Audio API подходит для самых разных целей: от обработки голоса в реальном времени для подкаста до проведения всевозможных вычислений, преобразований Фурье и прочего. С нашими директивами мы создадим небольшой музыкальный фрагмент:
https://stackblitz.com/edit/angular-web-audio
Начнем с простого — ровная ритм-секция. Для отсчета тактов создадим стрим и добавим его в DI:
export const TICK = new InjectionToken<Observable<number>>('Ticks', { factory: () => interval(250, animationFrameScheduler) .pipe(share()), });
В такте у нас будет 4 доли. Затем создадим компонент beat
и преобразуем этот стрим:
kick$ = this.tick$.pipe(map(tick => tick % 4 < 2));
Он будет выдавать true
на каждый такт и false
на каждую середину такта. Этот поток мы используем для запуска звуковых фрагментов:
<ng-container *ngIf="kick$ | async; else snare" waAudioBufferSourceNode autoplay buffer="kick.wav" > <ng-container waAudioDestinationNode></ng-container> </ng-container> <ng-template #snare> <ng-container waAudioBufferSourceNode autoplay buffer="snare.wav" > <ng-container waAudioDestinationNode></ng-container> </ng-container> </ng-template>
Теперь добавим мелодию. Мы запишем ноты в виде числового представления, где 69 — средняя нота ля. Функцию перевода ноты в частоту для осцилляторов можно найти хоть в Википедии. Выглядят ноты так:
const LEAD = [ 70, 70, 70, 70, 70, 70, 70, 68, 68, 68, 68, 68, 75, 79, 80, 87, 87, 87, 87, 87, 87, 87, 87, 87, 87, 87, 87, 87, 84, 80, 79, 75, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 79, 75, 72, 70, 70, 70, 70, 70, 70, 70, 70, 68, 72, 75, 79, 80, 80, 79, 79, 75, ];
Компонент будет выдавать нужную частоту для ноты на каждой доле такта:
notes$ = this.tick$.pipe(map(note => toFrequency(LEAD[note % 64])));
А вот в шаблоне развернется настоящий синтезатор. Но прежде напишем еще один пайп — для автоматизации громкости в виде ADSR-огибающей. ADSR означает attack, decay, sustain, release, и график громкости выглядит следующим образом:
Нам нужно, чтобы звук начинался и потом быстро угасал. Пайп будет довольно незамысловатым:
@Pipe({ name: 'adsr', }) export class AdsrPipe implements PipeTransform { transform( value: number, attack: number, decay: number, sustain: number, release: number, ): AudioParamInput { return [ { value: 0, duration: 0, mode: 'instant', }, { value, duration: attack, mode: 'linear', }, { value: sustain, duration: decay, mode: 'linear', }, { value: 0, duration: release, mode: 'linear', }, ]; } }
Теперь применим этот пайп для создания мелодии на синтезаторе:
<ng-container *ngIf="notes$ | async as note"> <ng-container waOscillatorNode detune="5" autoplay [frequency]="note" > <ng-container waGainNode [gain]="0.1 | adsr : 0 : 0.1 : 0.02 : 0.5" > <ng-container waAudioDestinationNode></ng-container> </ng-container> </ng-container> <ng-container waOscillatorNode type="sawtooth" autoplay [frequency]="note" > <ng-container waGainNode [gain]="0.1 | adsr : 0 : 0.1 : 0.02 : 0.5" > <ng-container #feedback="AudioNode" waGainNode gain="0.7" > <ng-container waDelayNode delayTime="0.3"> <ng-container [waOutput]="feedback"></ng-container> </ng-container> <ng-container waConvolverNode buffer="response.m4a"> <ng-container waAudioDestinationNode></ng-container> </ng-container> </ng-container> <ng-container waAudioDestinationNode></ng-container> </ng-container> </ng-container> </ng-container>
Что же здесь происходит? У нас есть два осциллятора. Первый — синусоида с ADSR-пайпом. Во втором же случае мы видим уже знакомый цикл для создания эха, только звук еще проходит через ConvolverNode
. Это создает эффект акустики помещения на основе импульсной характеристики. О ConvolverNode
и принципе его работы можно много и интересно говорить, но это тема для отдельной статьи. Остальные дорожки в примере работают аналогичным образом. Ноды соединяются друг с другом, изменения параметров автоматизируются через LFO или меняются плавно с использованием пайпа waAudioParam
.
Заключение
Я рассказал лишь малую часть, упростив некоторые моменты. Мы выпустили полный перевод Web Audio API под декларативный подход Angular — со всеми нодами и возможностями в виде open-source-библиотеки @ng-web-apis/audio.
Если сравнивать чистый Web Audio API с canvas и созданием графики с помощью императивных команд, то данная библиотека — это SVG.
Это часть проекта Web APIs for Angular, цель которого — создание идиоматических легковесных оберток нативного API для удобного использования в Angular. Если вас интересует, например, Payment Request API или вы хотите поиграть в браузере на вашей MIDI-клавиатуре — приглашаем вас посмотреть все наши релизы.
ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/493622/
Добавить комментарий