wavefft
Version:
High-performance FFT and CQT library for web audio applications using WebAssembly
561 lines (482 loc) • 19.9 kB
JavaScript
import Module from './dist/fft.js';
export default class WaveFFT {
/**
* Check if a number is a valid FFT size (power of two)
* @param {number} size - Size to check
* @returns {boolean} True if valid FFT size
*/
static isValidSize(size) {
return Number.isInteger(size) && size > 0 && (size & (size - 1)) === 0;
}
/**
* Create a new WaveFFT instance
* @param {number} size - FFT size (must be power of 2)
*/
constructor(size) {
this.size = size;
this.module = null;
this.realPtr = null;
this.imagPtr = null;
this.cqtOutputRealPtr = null;
this.cqtOutputImagPtr = null;
this.memoryGeneration = 0;
this._initialized = false;
this._cqtInitialized = false;
}
/**
* Initialize the WebAssembly module and allocate buffers
* @returns {Promise<void>} Resolves when initialization is complete
* @throws {Error} If FFT size is invalid or initialization fails
*/
async init() {
if (!Number.isInteger(this.size) || this.size <= 0) {
throw new Error("FFT size must be a positive integer");
}
if (!WaveFFT.isValidSize(this.size)) {
throw new Error("FFT size must be a power of two");
}
this.module = await Module();
this.HEAPF32 = this.module.HEAPF32;
if (!this.module._initFFT(this.size)) {
throw new Error("Failed to initialize FFT");
}
this._allocateBuffers();
this._initialized = true;
}
_allocateBuffers() {
this._freeBuffers();
this.realPtr = this.module._malloc(this.size * 4);
this.imagPtr = this.module._malloc(this.size * 4);
if (!this.realPtr || !this.imagPtr) {
this._freeBuffers();
throw new Error("Memory allocation failed");
}
this._updateArrayViews();
}
_freeBuffers() {
if (this.realPtr) {
this.module._free(this.realPtr);
this.realPtr = null;
}
if (this.imagPtr) {
this.module._free(this.imagPtr);
this.imagPtr = null;
}
}
_updateArrayViews() {
this.memoryGeneration = this.module.HEAPF32.buffer.byteLength;
this.real = new Float32Array(this.module.HEAPF32.buffer, this.realPtr, this.size);
this.imag = new Float32Array(this.module.HEAPF32.buffer, this.imagPtr, this.size);
if (this.cqtOutputRealPtr && this._cqtInitialized) {
this.cqtOutputReal = new Float32Array(this.module.HEAPF32.buffer, this.cqtOutputRealPtr, this.cqtTotalBins);
this.cqtOutputImag = new Float32Array(this.module.HEAPF32.buffer, this.cqtOutputImagPtr, this.cqtTotalBins);
}
}
_checkMemoryResize() {
// Use a local reference to avoid race conditions
const currentBuffer = this.module.HEAPF32.buffer;
const currentByteLength = currentBuffer.byteLength;
if (currentByteLength !== this.memoryGeneration) {
if (currentBuffer === this.module.HEAPF32.buffer) {
this._updateArrayViews();
} else {
// Buffer changed during check, recurse to handle it
this._checkMemoryResize();
}
}
}
/**
* Resize the FFT to a new size
* @param {number} newSize - New FFT size (must be power of two)
* @returns {Promise<void>} Resolves when resize is complete
* @throws {Error} If FFT not initialized, size invalid, or resize fails
*/
async resize(newSize) {
if (!this._initialized) {
throw new Error("FFT not initialized");
}
if (!Number.isInteger(newSize) || newSize <= 0) {
throw new Error("FFT size must be a positive integer");
}
if (!WaveFFT.isValidSize(newSize)) {
throw new Error("FFT size must be a power of two");
}
if (newSize === this.size) {
return;
}
// Store CQT parameters if initialized
let cqtParams = null;
if (this._cqtInitialized) {
cqtParams = {
binsPerOctave: this.cqtBinsPerOctave,
octaves: this.cqtOctaves,
sampleRate: this.cqtSampleRate,
minFreq: this.cqtMinFreq
};
}
this.size = newSize;
if (!this.module._initFFT(this.size)) {
throw new Error("Failed to resize FFT");
}
this._allocateBuffers();
// Re-initialize CQT if it was previously initialized
if (cqtParams) {
await this.initCQT(
cqtParams.binsPerOctave,
cqtParams.octaves,
cqtParams.sampleRate,
cqtParams.minFreq
);
}
}
/**
* Perform forward FFT on real-valued input data
* @param {Float32Array} inputReal - Real-valued input samples (length must equal FFT size)
* @param {Object} [output=null] - Optional pre-allocated output object with real and imag Float32Arrays
* @returns {Object} Object with real and imag Float32Arrays containing complex FFT result
* @throws {Error} If module not initialized or input size mismatch
*/
fft(inputReal, output = null) {
if (!this.module || !this._initialized) {
throw new Error("Module not initialized");
}
if (!inputReal || inputReal.length !== this.size) {
throw new Error("Input array must match FFT size");
}
this._checkMemoryResize();
this.real.set(inputReal);
this.imag.fill(0);
if (!this.module._fft(this.realPtr, this.imagPtr, this.size)) {
throw new Error("FFT computation failed");
}
if (output) {
output.real.set(this.real);
output.imag.set(this.imag);
return output;
}
return { real: new Float32Array(this.real), imag: new Float32Array(this.imag) };
}
/**
* Perform forward FFT on complex input data.
* @param {Float32Array} inputReal - Real part of input
* @param {Float32Array} inputImag - Imaginary part of input
* @param {Object} output - Optional output object with real and imag arrays
* @returns {Object} Object with real and imag Float32Arrays
*/
fftComplex(inputReal, inputImag, output = null) {
if (!this.module || !this._initialized) {
throw new Error("Module not initialized");
}
if (!inputReal || !inputImag || inputReal.length !== this.size || inputImag.length !== this.size) {
throw new Error("Input arrays must match FFT size");
}
this._checkMemoryResize();
this.real.set(inputReal);
this.imag.set(inputImag);
if (!this.module._fft(this.realPtr, this.imagPtr, this.size)) {
throw new Error("FFT computation failed");
}
if (output) {
output.real.set(this.real);
output.imag.set(this.imag);
return output;
}
return { real: new Float32Array(this.real), imag: new Float32Array(this.imag) };
}
/**
* Perform inverse FFT
* @param {Float32Array} inputReal - Real part of input
* @param {Float32Array} inputImag - Imaginary part of input
* @param {Object} output - Optional output object with real and imag arrays
* @returns {Object} Object with real and imag Float32Arrays
*/
ifft(inputReal, inputImag, output = null) {
if (!this.module || !this._initialized) {
throw new Error("Module not initialized");
}
if (!inputReal || !inputImag || inputReal.length !== this.size || inputImag.length !== this.size) {
throw new Error("Input arrays must match FFT size");
}
this._checkMemoryResize();
this.real.set(inputReal);
this.imag.set(inputImag);
if (!this.module._ifft(this.realPtr, this.imagPtr, this.size)) {
throw new Error("IFFT computation failed");
}
if (output) {
output.real.set(this.real);
output.imag.set(this.imag);
return output;
}
return { real: new Float32Array(this.real), imag: new Float32Array(this.imag) };
}
/**
* Compute magnitude spectrum from complex FFT result
* @param {Object} fftResult - Object with real and imag arrays
* @returns {Float32Array} Magnitude spectrum (length = size/2 + 1)
*/
getMagnitudeSpectrum(fftResult) {
if (!fftResult || !fftResult.real || !fftResult.imag) {
throw new Error("Invalid FFT result object");
}
const magnitude = new Float32Array(Math.floor(this.size / 2) + 1);
for (let i = 0; i < magnitude.length; i++) {
const re = fftResult.real[i];
const im = fftResult.imag[i];
magnitude[i] = Math.sqrt(re * re + im * im);
}
return magnitude;
}
/**
* Initialize Constant-Q Transform (CQT) for musical/pitch analysis
* @param {number} binsPerOctave - Number of frequency bins per octave (e.g., 12 for semitones)
* @param {number} octaves - Number of octaves to analyze
* @param {number} sampleRate - Sample rate of the audio in Hz (e.g., 44100)
* @param {number} minFreq - Minimum frequency in Hz (e.g., 27.5 for A0)
* @returns {Promise<void>} Resolves when CQT initialization is complete
* @throws {Error} If parameters are invalid or initialization fails
*/
async initCQT(binsPerOctave, octaves, sampleRate, minFreq) {
if (!this.module || !this._initialized) {
throw new Error("Module not initialized");
}
if (!Number.isInteger(binsPerOctave) || !Number.isInteger(octaves) ||
binsPerOctave <= 0 || octaves <= 0 || sampleRate <= 0 || minFreq <= 0) {
throw new Error("Invalid CQT parameters");
}
// Store parameters
this.cqtBinsPerOctave = binsPerOctave;
this.cqtOctaves = octaves;
this.cqtSampleRate = sampleRate;
this.cqtMinFreq = minFreq;
this.cqtTotalBins = binsPerOctave * octaves;
if (!this.module._initCQT(binsPerOctave, octaves, this.size, sampleRate, minFreq)) {
throw new Error("Failed to initialize CQT");
}
if (this.cqtOutputRealPtr) this.module._free(this.cqtOutputRealPtr);
if (this.cqtOutputImagPtr) this.module._free(this.cqtOutputImagPtr);
this.cqtOutputRealPtr = this.module._malloc(this.cqtTotalBins * 4);
this.cqtOutputImagPtr = this.module._malloc(this.cqtTotalBins * 4);
if (!this.cqtOutputRealPtr || !this.cqtOutputImagPtr) {
if (this.cqtOutputRealPtr) this.module._free(this.cqtOutputRealPtr);
if (this.cqtOutputImagPtr) this.module._free(this.cqtOutputImagPtr);
this.cqtOutputRealPtr = null;
this.cqtOutputImagPtr = null;
this._cqtInitialized = false;
throw new Error("Memory allocation failed in initCQT");
}
this._cqtInitialized = true;
this._updateArrayViews();
}
/**
* Perform Constant-Q Transform on real-valued input
* @param {Float32Array} inputReal - Real-valued input samples (length must equal FFT size)
* @param {Object} [output=null] - Optional pre-allocated output with real and imag Float32Arrays
* @returns {Object} Object with real and imag Float32Arrays containing CQT result
* @throws {Error} If CQT not initialized or input size mismatch
*/
cqt(inputReal, output = null) {
if (!this.module || !this._initialized || !this._cqtInitialized) {
throw new Error("CQT not initialized");
}
if (!inputReal || inputReal.length !== this.size) {
throw new Error("Input array must match FFT size");
}
this._checkMemoryResize();
this.real.set(inputReal);
this.imag.fill(0);
if (!this.module._fft(this.realPtr, this.imagPtr, this.size)) {
throw new Error("FFT computation failed");
}
if (!this.module._cqt(
this.realPtr,
this.imagPtr,
this.cqtOutputRealPtr,
this.cqtOutputImagPtr,
this.size,
this.cqtBinsPerOctave,
this.cqtOctaves
)) {
throw new Error("CQT computation failed");
}
if (output) {
output.real.set(this.cqtOutputReal);
output.imag.set(this.cqtOutputImag);
return output;
}
return {
real: new Float32Array(this.cqtOutputReal),
imag: new Float32Array(this.cqtOutputImag)
};
}
/**
* Generate a Hann window
* @param {number} N - Window size
* @returns {Float32Array} Window coefficients
*/
static hann(N) {
const w = new Float32Array(N);
for (let i = 0; i < N; i++) {
w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (N - 1)));
}
return w;
}
/**
* Generate a Hamming window
* @param {number} N - Window size
* @returns {Float32Array} Window coefficients
*/
static hamming(N) {
const w = new Float32Array(N);
for (let i = 0; i < N; i++) {
w[i] = 0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (N - 1));
}
return w;
}
/**
* Generate a Blackman window
* @param {number} N - Window size
* @returns {Float32Array} Window coefficients
*/
static blackman(N) {
const w = new Float32Array(N);
for (let i = 0; i < N; i++) {
w[i] = 0.42 - 0.5 * Math.cos((2 * Math.PI * i) / (N - 1))
+ 0.08 * Math.cos((4 * Math.PI * i) / (N - 1));
}
return w;
}
/**
* Perform Short-Time Fourier Transform (STFT)
* @param {Float32Array} samples - Input audio samples
* @param {Object} options - STFT options
* @param {number} options.fftSize - FFT size (must match this instance's size)
* @param {number} options.hopSize - Hop size between frames
* @param {Float32Array} options.window - Optional window function (defaults to Hann)
* @returns {Object} Object containing spectrogram data
*/
stft(samples, { fftSize = this.size, hopSize = Math.floor(fftSize / 2), window = null } = {}) {
if (!this.module || !this._initialized) {
throw new Error("Module not initialized");
}
if (fftSize !== this.size) {
throw new Error(`FFT size mismatch. Expected ${this.size}, got ${fftSize}`);
}
if (!samples || samples.length === 0) {
throw new Error("Input samples array is empty");
}
if (hopSize <= 0) {
throw new Error("Hop size must be positive");
}
const wnd = window || WaveFFT.hann(fftSize);
const length = samples.length;
let totalFrames = Math.floor((length - fftSize) / hopSize) + 1;
if (totalFrames < 1) {
// If input is too short, we'll do just one frame with zero-padding
totalFrames = 1;
}
const freqBins = Math.floor(fftSize / 2) + 1;
const magnitudes = new Array(totalFrames);
const complex = new Array(totalFrames);
let maxMagnitude = 0;
for (let frameIdx = 0, sampleOffset = 0; frameIdx < totalFrames; frameIdx++, sampleOffset += hopSize) {
this.real.fill(0);
this.imag.fill(0);
const frameLength = Math.min(fftSize, length - sampleOffset);
for (let i = 0; i < frameLength; i++) {
this.real[i] = samples[sampleOffset + i] * wnd[i];
}
if (!this.module._fft(this.realPtr, this.imagPtr, this.size)) {
throw new Error("FFT computation failed");
}
const frameComplex = new Float32Array(fftSize * 2);
for (let i = 0; i < fftSize; i++) {
frameComplex[i * 2] = this.real[i];
frameComplex[i * 2 + 1] = this.imag[i];
}
complex[frameIdx] = frameComplex;
const frameMagnitudes = new Float32Array(freqBins);
for (let bin = 0; bin < freqBins; bin++) {
const re = this.real[bin];
const im = this.imag[bin];
const mag = Math.sqrt(re * re + im * im);
if (mag > maxMagnitude) maxMagnitude = mag;
frameMagnitudes[bin] = mag;
}
magnitudes[frameIdx] = frameMagnitudes;
}
return {
magnitudes, // 2D array of magnitude spectra
complex, // 2D array of complex spectra
fftSize,
hopSize,
freqBins,
timeBins: totalFrames,
maxMagnitude,
sampleRate: null // Can be provided by caller if needed
};
}
/**
* Perform Inverse Short-Time Fourier Transform (ISTFT)
* @param {Array<Float32Array>} complexFrames - Complex spectra frames
* @param {Object} options - ISTFT options
* @param {number} options.fftSize - FFT size (must match this instance's size)
* @param {number} options.hopSize - Hop size between frames
* @param {Float32Array} options.window - Optional window function (defaults to Hann)
* @returns {Float32Array} Reconstructed audio samples
*/
istft(complexFrames, { fftSize = this.size, hopSize = Math.floor(fftSize / 2), window = null } = {}) {
if (!this.module || !this._initialized) {
throw new Error("Module not initialized");
}
if (fftSize !== this.size) {
throw new Error(`FFT size mismatch. Expected ${this.size}, got ${fftSize}`);
}
const wnd = window || WaveFFT.hann(fftSize);
const frameCount = complexFrames.length;
const outputLength = (frameCount - 1) * hopSize + fftSize;
const output = new Float32Array(outputLength);
const windowSum = new Float32Array(outputLength);
for (let frameIdx = 0; frameIdx < frameCount; frameIdx++) {
const frameComplex = complexFrames[frameIdx];
for (let i = 0; i < fftSize; i++) {
this.real[i] = frameComplex[i * 2];
this.imag[i] = frameComplex[i * 2 + 1];
}
if (!this.module._ifft(this.realPtr, this.imagPtr, this.size)) {
throw new Error("IFFT computation failed");
}
// Overlap-add with windowing
const offset = frameIdx * hopSize;
for (let i = 0; i < fftSize; i++) {
output[offset + i] += this.real[i] * wnd[i];
windowSum[offset + i] += wnd[i] * wnd[i];
}
}
// Normalize by window sum to maintain amplitude
for (let i = 0; i < outputLength; i++) {
if (windowSum[i] > 1e-9) {
output[i] /= windowSum[i];
}
}
return output;
}
/**
* Free all allocated WebAssembly memory and cleanup resources
* @returns {void}
*/
dispose() {
if (this.module) {
this._freeBuffers();
if (this.cqtOutputRealPtr) this.module._free(this.cqtOutputRealPtr);
if (this.cqtOutputImagPtr) this.module._free(this.cqtOutputImagPtr);
this.module._freeFFT();
this.module._freeCQT();
this.module = null;
this.realPtr = this.imagPtr = this.cqtOutputRealPtr = this.cqtOutputImagPtr = null;
this.real = this.imag = this.cqtOutputReal = this.cqtOutputImag = null;
this._initialized = false;
this._cqtInitialized = false;
}
}
}