240 lines
6.7 KiB
TypeScript
240 lines
6.7 KiB
TypeScript
/**
|
||
* 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<CavaCoreConfig> = {
|
||
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<string, (...args: any[]) => 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<CavaLib["symbols"]["cava_init"]> | 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;
|
||
}
|
||
}
|