- Регистрация
- 14.05.16
- Сообщения
- 11.398
- Реакции
- 501
- Репутация
- 0
You must be registered for see links
существует уже давно, и про него есть немало статей. Поэтому про сам 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 тэг и, когда пользователь нажмет плей, он услышит свой аудиофайл с эффектом эха. Мы реализуем этот пример через директивы.
You must be registered for see links
будет браться из Dependency Injection.
You must be registered for see links
и
You must be registered for see links
имеют всего один параметр — уровень громкости и время задержки соответственно. Это не просто числовой инпут, а некий
You must be registered for see links
. Подробнее про него поговорим позже.@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 — так ноды будут доступны через
You must be registered for see links
. Теперь мы можем строить граф в шаблоне:Для вывода звука в конце цепочки создадим директиву 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 тэги, и она будет превращать их для нас в
You must be registered for see links
:@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,
) {
super(context, {mediaElement: nativeElement});
}
ngOnDestroy() {
this.disconnect();
}
}
Воссоздадим пример с эффектом эха через директивы:
В Web Audio API много различных нодов, но все их можно реализовать похожим образом. Из нодов-источников важными являются также осциллятор и аудиобуфер. Зачастую мы не хотим ничего добавлять в HTML и нам нет нужды давать пользователю контроль над запуском звука. В этом случае хорошо подойдет
You must be registered for see links
. Единственное неудобство — он не может сам использовать файл по ссылке, ему требуется готовый
You must be registered for see links
. Мы поможем ему с этим и создадим сервис для превращения аудиофайлов в AudioBuffer:@Injectable({
providedIn: 'root',
})
export class AudioBufferService {
private readonly cache = new Map();
constructor(
@Inject(AUDIO_CONTEXT) private readonly context: AudioContext
) {}
fetch(url: string): Promise {
return new Promise((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();
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 = (
target: AudioNodeWithParams,
propertyKey: string,
) => void;
export function audioParam(
param: K,
): AudioParamDecorator {
return (target, propertyKey) => {
Object.defineProperty(target, propertyKey, {
set(
this: AudioNode & Record,
value: AudioParamInput,
) {
processAudioParam(
this[param],
value,
this.context.currentTime,
);
},
});
};
}
Строгая типизация не позволит нам случайно повесить декоратор на несуществующий параметр. Что же представляет собой новый тип AudioParamInput? Кроме числа в него входит еще объект вида:
export type AudioParamAutomation = Readonly;
Функция 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 директивы:
Время ретровейва
Web Audio API подходит для самых разных целей: от обработки голоса в реальном времени для подкаста до проведения всевозможных вычислений, преобразований Фурье и прочего. С нашими директивами мы создадим небольшой музыкальный фрагмент:
You must be registered for see links
Начнем с простого — ровная ритм-секция. Для отсчета тактов создадим стрим и добавим его в DI:
export const TICK = new InjectionToken>('Ticks', {
factory: () => interval(250, animationFrameScheduler)
.pipe(share()),
});
В такте у нас будет 4 доли. Затем создадим компонент beat и преобразуем этот стрим:
kick$ = this.tick$.pipe(map(tick => tick % 4 < 2));
Он будет выдавать true на каждый такт и false на каждую середину такта. Этот поток мы используем для запуска звуковых фрагментов:
Теперь добавим мелодию. Мы запишем ноты в виде числового представления, где 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',
},
];
}
}
Теперь применим этот пайп для создания мелодии на синтезаторе:
Что же здесь происходит? У нас есть два осциллятора. Первый — синусоида с ADSR-пайпом. Во втором же случае мы видим уже знакомый цикл для создания эха, только звук еще проходит через
You must be registered for see links
. Это создает эффект акустики помещения на основе импульсной характеристики. О ConvolverNode и принципе его работы можно много и интересно говорить, но это тема для отдельной статьи. Остальные дорожки в примере работают аналогичным образом. Ноды соединяются друг с другом, изменения параметров автоматизируются через LFO или меняются плавно с использованием пайпа waAudioParam.Заключение
Я рассказал лишь малую часть, упростив некоторые моменты. Мы выпустили полный перевод Web Audio API под декларативный подход Angular — со всеми нодами и возможностями в виде open-source-библиотеки
You must be registered for see links
.Если сравнивать чистый Web Audio API с canvas и созданием графики с помощью императивных команд, то данная библиотека — это SVG.
Это часть проекта Web APIs for Angular, цель которого — создание идиоматических легковесных оберток нативного API для удобного использования в Angular. Если вас интересует, например,
You must be registered for see links
или вы хотите поиграть в браузере на вашей MIDI-клавиатуре — приглашаем вас посмотреть
You must be registered for see links
.