Пишем Ретровейв на Angular

от автора

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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *