fix(cava): identical init re-uses the live plan

cava_init/destroy churn leaks the old plan's FFTW work buffers —
upstream frees only its own struct. init() now serializes the config
and no-ops when unchanged, so pipeline restarts (focus/episode churn)
keep the live plan instead of leaking a new one each cycle.
This commit is contained in:
2026-09-06 18:46:42 -04:00
parent 9e2f232d27
commit 09d5732b55
2 changed files with 73 additions and 3 deletions

View File

@@ -95,6 +95,8 @@ export class CavaCore {
private _bars = 0;
private _channels = 1;
private _destroyed = false;
/** Serialized last init config — identical init() calls are no-ops. */
private lastConfigKey = "";
/** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) {
@@ -112,15 +114,25 @@ export class CavaCore {
/**
* Initialize the cavacore engine with the given configuration.
* Must be called before execute(). Can be called again after destroy()
* to reinitialize with different parameters.
* Must be called before execute(). Identical configs are a no-op:
* cava_init/destroy churn leaks the old plan's FFTW work buffers
* (upstream frees only its own struct), so a pipeline restart with
* unchanged bars/rate/cutoffs must re-USE the live plan.
*/
init(config: CavaCoreConfig = {}): void {
const cfg = { ...DEFAULTS, ...config };
if (
this.plan !== null &&
!this._destroyed &&
this.lastConfigKey === JSON.stringify(cfg)
) {
return;
}
this.lastConfigKey = JSON.stringify(cfg);
if (this.plan) {
this.destroy();
}
const cfg = { ...DEFAULTS, ...config };
this._bars = cfg.bars;
this._channels = cfg.channels;

View File

@@ -0,0 +1,58 @@
/**
* CavaCore.init() must be a no-op for an identical config: re-initializing
* the same plan leaks the old plan's native FFTW work buffers, so pipeline
* restarts (focus/episode churn) have to re-USE the live plan.
*/
import { test, expect } from "bun:test"
import { CavaCore } from "../src/utils/cavacore"
function stubLib() {
const calls = { init: 0, destroy: 0 }
let plans = 0
const lib = {
symbols: {
cava_init: () => {
calls.init++
return { p: ++plans }
},
cava_execute: () => {},
cava_destroy: () => {
calls.destroy++
},
},
close: () => {},
}
return { lib, calls }
}
test("identical init config reuses the plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
cava.init(cfg)
cava.init(cfg)
cava.init(cfg)
expect(calls.init).toBe(1)
expect(cava.isReady).toBe(true)
})
test("changed config re-inits, destroying the old plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
cava.init({ bars: 64, sampleRate: 22050, channels: 1, autosens: 0 })
cava.init({ bars: 32, sampleRate: 22050, channels: 1, autosens: 0 })
expect(calls.init).toBe(2)
expect(calls.destroy).toBe(1)
expect(cava.bars).toBe(32)
})
test("init after destroy creates a fresh plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
cava.init(cfg)
cava.destroy()
cava.init(cfg)
expect(calls.init).toBe(2)
expect(cava.isReady).toBe(true)
})