/** * TypeScript FFI bindings for libcavacore. * * Wraps cava's frequency-analysis engine (cavacore) via Bun's dlopen. * The precompiled shared library ships in src/native/ (dev) and dist/ (prod) * with fftw3 statically linked — zero native dependencies for end users. * * Usage: * ```ts * const cava = loadCavaCore() * if (cava) { * cava.init({ bars: 32, sampleRate: 44100 }) * const freqs = cava.execute(pcmSamples) * cava.destroy() * } * ``` */ import { dlopen, FFIType, ptr } from "bun:ffi"; import { existsSync } from "fs"; import { join, dirname } from "path"; // ── Types ──────────────────────────────────────────────────────────── export interface CavaCoreConfig { /** Number of frequency bars (default: 32) */ bars?: number; /** Audio sample rate in Hz (default: 44100) */ sampleRate?: number; /** Number of audio channels (default: 1 = mono) */ channels?: number; /** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */ autosens?: number; /** Noise reduction factor 0.0–1.0 (default: 0.77) */ noiseReduction?: number; /** Low frequency cutoff in Hz (default: 50) */ lowCutOff?: number; /** High frequency cutoff in Hz (default: 10000) */ highCutOff?: number; /** Output scaling mode: 0 = linear (default), 1 = decibel */ scalingMode?: number; } const DEFAULTS: Required = { bars: 32, sampleRate: 44100, channels: 1, autosens: 1, noiseReduction: 0.77, lowCutOff: 50, highCutOff: 10000, scalingMode: 0, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any type CavaLib = { symbols: Record any>; close(): void; }; // ── Library resolution ─────────────────────────────────────────────── function findLibrary(): string | null { const platform = process.platform; const libName = platform === "darwin" ? "libcavacore.dylib" : platform === "win32" ? "cavacore.dll" : "libcavacore.so"; // Candidate paths, in priority order: // 1. src/native/ (development) // 2. Same directory as the running executable (dist bundle) // 3. dist/ relative to cwd const candidates = [ join(import.meta.dir, "..", "native", libName), join(dirname(process.execPath), libName), join(process.cwd(), "dist", libName), ]; for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } return null; } // ── CavaCore class ─────────────────────────────────────────────────── export class CavaCore { private lib: CavaLib; private plan: ReturnType | null = null; private inputBuffer: Float64Array | null = null; private outputBuffer: Float64Array | null = null; private _bars = 0; private _channels = 1; private _destroyed = false; /** Use loadCavaCore() instead of constructing directly. */ constructor(lib: CavaLib) { this.lib = lib; } /** Number of frequency bars configured. */ get bars(): number { return this._bars; } /** Whether this instance has been initialized (and not yet destroyed). */ get isReady(): boolean { return this.plan !== null && !this._destroyed; } /** * Initialize the cavacore engine with the given configuration. * Must be called before execute(). Can be called again after destroy() * to reinitialize with different parameters. */ init(config: CavaCoreConfig = {}): void { if (this.plan) { this.destroy(); } const cfg = { ...DEFAULTS, ...config }; this._bars = cfg.bars; this._channels = cfg.channels; this.plan = this.lib.symbols.cava_init( cfg.bars, cfg.sampleRate, cfg.channels, cfg.autosens, cfg.noiseReduction, cfg.lowCutOff, cfg.highCutOff, cfg.scalingMode, ); if (!this.plan) { throw new Error("cava_init returned null — initialization failed"); } // Pre-allocate output buffer (bars * channels) this.outputBuffer = new Float64Array(cfg.bars * cfg.channels); this._destroyed = false; } /** * Feed PCM samples into cavacore and get frequency bar values back. * * @param samples - Float64Array of PCM samples (scaled ~±32768). * The array length determines the number of samples processed. * @returns Float64Array of bar values (0.0–1.0 range, length = bars * channels). * Returns the same buffer reference each call (overwritten in place). */ execute(samples: Float64Array): Float64Array { if (!this.plan || !this.outputBuffer) { throw new Error("CavaCore not initialized — call init() first"); } // Reuse input buffer if same size, otherwise allocate new if (!this.inputBuffer || this.inputBuffer.length !== samples.length) { this.inputBuffer = new Float64Array(samples.length); } this.inputBuffer.set(samples); this.lib.symbols.cava_execute( ptr(this.inputBuffer), samples.length, ptr(this.outputBuffer), this.plan, ); return this.outputBuffer; } /** * Release all native resources. Safe to call multiple times. * After calling destroy(), init() can be called again to reuse the instance. */ destroy(): void { if (this.plan && !this._destroyed) { this.lib.symbols.cava_destroy(this.plan); this.plan = null; this._destroyed = true; } this.inputBuffer = null; this.outputBuffer = null; } } // ── Factory ────────────────────────────────────────────────────────── /** * Attempt to load the cavacore shared library and return a CavaCore instance. * Returns null if the library cannot be found — callers should fall back * to the static waveform display. */ export function loadCavaCore(): CavaCore | null { try { const libPath = findLibrary(); if (!libPath) return null; const lib = dlopen(libPath, { cava_init: { args: [ FFIType.i32, // bars FFIType.u32, // rate FFIType.i32, // channels FFIType.i32, // autosens FFIType.double, // noise_reduction FFIType.i32, // low_cut_off FFIType.i32, // high_cut_off FFIType.i32, // scaling_mode ], returns: FFIType.ptr, }, cava_execute: { args: [ FFIType.ptr, // cava_in (double*) FFIType.i32, // samples FFIType.ptr, // cava_out (double*) FFIType.ptr, // plan ], returns: FFIType.void, }, cava_destroy: { args: [FFIType.ptr], // plan returns: FFIType.void, }, }); return new CavaCore(lib as CavaLib); } catch { // Library load failed — missing dylib, wrong arch, etc. return null; } }