web-audio-peak-meter
Version:
A customizable peak meter using the web audio API
154 lines (144 loc) • 4.95 kB
text/typescript
import { PeakMeterConfig, defaultConfig } from './config';
import {
audioClipPath,
createContainerDiv,
createTicks,
createChannelElements,
createBars,
createPeakLabels,
} from './markup';
import { dbFromFloat } from './utils';
import peakSampleProcessor from './peak-sample-processor.txt';
import truePeakProcessor from './true-peak-processor.txt';
export class WebAudioPeakMeter {
channelCount: number;
srcNode: AudioNode;
node?: AudioWorkletNode;
config: PeakMeterConfig;
parent?: HTMLElement;
ticks?: Array<HTMLElement>;
channelElements?: Array<HTMLElement>;
bars?: Array<HTMLElement>;
peakLabels?: Array<HTMLElement>;
tempPeaks: Array<number>;
heldPeaks: Array<number>;
peakHoldTimeouts: Array<number>;
animationRequestId?: number;
constructor(src: AudioNode, ele: HTMLElement, options = {}) {
this.srcNode = src;
this.config = Object.assign({ ...defaultConfig }, options);
this.channelCount = src.channelCount;
this.tempPeaks = new Array(this.channelCount).fill(0.0);
this.heldPeaks = new Array(this.channelCount).fill(0.0);
this.peakHoldTimeouts = new Array(this.channelCount).fill(0);
if (ele) {
this.parent = createContainerDiv(ele, this.config);
this.channelElements = createChannelElements(this.parent, this.config, this.channelCount);
this.peakLabels = createPeakLabels(this.channelElements, this.config);
this.bars = createBars(this.channelElements, this.config);
this.ticks = createTicks(this.parent, this.config);
this.parent.addEventListener('click', this.clearPeaks.bind(this));
this.paintMeter();
}
this.initNode();
}
async initNode() {
const { audioMeterStandard } = this.config;
try {
this.node = new AudioWorkletNode(this.srcNode.context, `${audioMeterStandard}-processor`, {
parameterData: {},
});
} catch (err) {
const workletString =
audioMeterStandard === 'true-peak' ? truePeakProcessor : peakSampleProcessor;
const blob = new Blob([workletString], { type: 'application/javascript' });
const objectURL = URL.createObjectURL(blob);
await this.srcNode.context.audioWorklet.addModule(objectURL);
this.node = new AudioWorkletNode(this.srcNode.context, `${audioMeterStandard}-processor`, {
parameterData: {},
});
}
this.node.port.onmessage = (ev: MessageEvent) => this.handleNodePortMessage(ev);
this.srcNode.connect(this.node).connect(this.srcNode.context.destination);
}
handleNodePortMessage(ev: MessageEvent) {
if (ev.data.type === 'message') {
console.log(ev.data.message);
}
if (ev.data.type === 'peaks') {
const { peaks } = ev.data;
for (let i = 0; i < this.tempPeaks.length; i += 1) {
if (peaks.length > i) {
this.tempPeaks[i] = peaks[i];
} else {
this.tempPeaks[i] = 0.0;
}
}
if (peaks.length < this.channelCount) {
this.tempPeaks.fill(0.0, peaks.length);
}
for (let i = 0; i < peaks.length; i += 1) {
if (peaks[i] > this.heldPeaks[i]) {
this.heldPeaks[i] = peaks[i];
if (this.peakHoldTimeouts[i]) {
clearTimeout(this.peakHoldTimeouts[i]);
}
if (this.config.peakHoldDuration) {
this.peakHoldTimeouts[i] = window.setTimeout(() => {
this.clearPeak(i);
}, this.config.peakHoldDuration);
}
}
}
}
}
paintMeter() {
const { dbRangeMin, dbRangeMax, vertical } = this.config;
if (this.bars) {
this.bars.forEach((barDiv, i) => {
const tempPeak = dbFromFloat(this.tempPeaks[i]);
const clipPath = audioClipPath(tempPeak, dbRangeMin, dbRangeMax, vertical);
barDiv.style.clipPath = clipPath;
});
}
if (this.peakLabels) {
this.peakLabels.forEach((textLabel, i) => {
if (this.heldPeaks[i] === 0.0) {
textLabel.textContent = '-∞';
} else {
const heldPeak = dbFromFloat(this.heldPeaks[i]);
textLabel.textContent = heldPeak.toFixed(1);
}
});
}
this.animationRequestId = window.requestAnimationFrame(this.paintMeter.bind(this));
}
clearPeak(i: number) {
this.heldPeaks[i] = this.tempPeaks[i];
}
clearPeaks() {
for (let i = 0; i < this.heldPeaks.length; i += 1) {
this.clearPeak(i);
}
}
getPeaks() {
return {
current: this.tempPeaks,
maxes: this.heldPeaks,
currentDB: this.tempPeaks.map(dbFromFloat),
maxesDB: this.heldPeaks.map(dbFromFloat),
};
}
cleanup() {
if (this.node) {
this.node.disconnect();
}
if (this.parent) {
this.parent.removeEventListener('click', this.clearPeaks.bind(this));
if (this.animationRequestId !== undefined) {
window.cancelAnimationFrame(this.animationRequestId);
}
this.parent.remove();
}
}
}