UNPKG

wavtools-patch

Version:

Record and stream WAV audio data in the browser across all platforms

204 lines (197 loc) 6.81 kB
import { noteFrequencies, noteFrequencyLabels, voiceFrequencies, voiceFrequencyLabels, } from './constants.js'; /** * Output of AudioAnalysis for the frequency domain of the audio * @typedef {Object} AudioAnalysisOutputType * @property {Float32Array} values Amplitude of this frequency between {0, 1} inclusive * @property {number[]} frequencies Raw frequency bucket values * @property {string[]} labels Labels for the frequency bucket values */ /** * Analyzes audio for visual output * @class */ export class AudioAnalysis { /** * Retrieves frequency domain data from an AnalyserNode adjusted to a decibel range * returns human-readable formatting and labels * @param {AnalyserNode} analyser * @param {number} sampleRate * @param {Float32Array} [fftResult] * @param {"frequency"|"music"|"voice"} [analysisType] * @param {number} [minDecibels] default -100 * @param {number} [maxDecibels] default -30 * @returns {AudioAnalysisOutputType} */ static getFrequencies( analyser, sampleRate, fftResult, analysisType = 'frequency', minDecibels = -100, maxDecibels = -30, ) { if (!fftResult) { fftResult = new Float32Array(analyser.frequencyBinCount); analyser.getFloatFrequencyData(fftResult); } const nyquistFrequency = sampleRate / 2; const frequencyStep = (1 / fftResult.length) * nyquistFrequency; let outputValues; let frequencies; let labels; if (analysisType === 'music' || analysisType === 'voice') { const useFrequencies = analysisType === 'voice' ? voiceFrequencies : noteFrequencies; const aggregateOutput = Array(useFrequencies.length).fill(minDecibels); for (let i = 0; i < fftResult.length; i++) { const frequency = i * frequencyStep; const amplitude = fftResult[i]; for (let n = useFrequencies.length - 1; n >= 0; n--) { if (frequency > useFrequencies[n]) { aggregateOutput[n] = Math.max(aggregateOutput[n], amplitude); break; } } } outputValues = aggregateOutput; frequencies = analysisType === 'voice' ? voiceFrequencies : noteFrequencies; labels = analysisType === 'voice' ? voiceFrequencyLabels : noteFrequencyLabels; } else { outputValues = Array.from(fftResult); frequencies = outputValues.map((_, i) => frequencyStep * i); labels = frequencies.map((f) => `${f.toFixed(2)} Hz`); } // We normalize to {0, 1} const normalizedOutput = outputValues.map((v) => { return Math.max( 0, Math.min((v - minDecibels) / (maxDecibels - minDecibels), 1), ); }); const values = new Float32Array(normalizedOutput); return { values, frequencies, labels, }; } /** * Creates a new AudioAnalysis instance for an HTMLAudioElement * @param {HTMLAudioElement} audioElement * @param {AudioBuffer|null} [audioBuffer] If provided, will cache all frequency domain data from the buffer * @returns {AudioAnalysis} */ constructor(audioElement, audioBuffer = null) { this.fftResults = []; if (audioBuffer) { /** * Modified from * https://stackoverflow.com/questions/75063715/using-the-web-audio-api-to-analyze-a-song-without-playing * * We do this to populate FFT values for the audio if provided an `audioBuffer` * The reason to do this is that Safari fails when using `createMediaElementSource` * This has a non-zero RAM cost so we only opt-in to run it on Safari, Chrome is better */ const { length, sampleRate } = audioBuffer; const offlineAudioContext = new OfflineAudioContext({ length, sampleRate, }); const source = offlineAudioContext.createBufferSource(); source.buffer = audioBuffer; const analyser = offlineAudioContext.createAnalyser(); analyser.fftSize = 8192; analyser.smoothingTimeConstant = 0.1; source.connect(analyser); // limit is :: 128 / sampleRate; // but we just want 60fps - cuts ~1s from 6MB to 1MB of RAM const renderQuantumInSeconds = 1 / 60; const durationInSeconds = length / sampleRate; const analyze = (index) => { const suspendTime = renderQuantumInSeconds * index; if (suspendTime < durationInSeconds) { offlineAudioContext.suspend(suspendTime).then(() => { const fftResult = new Float32Array(analyser.frequencyBinCount); analyser.getFloatFrequencyData(fftResult); this.fftResults.push(fftResult); analyze(index + 1); }); } if (index === 1) { offlineAudioContext.startRendering(); } else { offlineAudioContext.resume(); } }; source.start(0); analyze(1); this.audio = audioElement; this.context = offlineAudioContext; this.analyser = analyser; this.sampleRate = sampleRate; this.audioBuffer = audioBuffer; } else { const audioContext = new AudioContext(); const track = audioContext.createMediaElementSource(audioElement); const analyser = audioContext.createAnalyser(); analyser.fftSize = 8192; analyser.smoothingTimeConstant = 0.1; track.connect(analyser); analyser.connect(audioContext.destination); this.audio = audioElement; this.context = audioContext; this.analyser = analyser; this.sampleRate = this.context.sampleRate; this.audioBuffer = null; } } /** * Gets the current frequency domain data from the playing audio track * @param {"frequency"|"music"|"voice"} [analysisType] * @param {number} [minDecibels] default -100 * @param {number} [maxDecibels] default -30 * @returns {AudioAnalysisOutputType} */ getFrequencies( analysisType = 'frequency', minDecibels = -100, maxDecibels = -30, ) { let fftResult = null; if (this.audioBuffer && this.fftResults.length) { const pct = this.audio.currentTime / this.audio.duration; const index = Math.min( (pct * this.fftResults.length) | 0, this.fftResults.length - 1, ); fftResult = this.fftResults[index]; } return AudioAnalysis.getFrequencies( this.analyser, this.sampleRate, fftResult, analysisType, minDecibels, maxDecibels, ); } /** * Resume the internal AudioContext if it was suspended due to the lack of * user interaction when the AudioAnalysis was instantiated. * @returns {Promise<true>} */ async resumeIfSuspended() { if (this.context.state === 'suspended') { await this.context.resume(); } return true; } } globalThis.AudioAnalysis = AudioAnalysis;