wavewave
Version:
Record and stream WAV audio data in the browser across all platforms
204 lines (197 loc) • 6.74 kB
JavaScript
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