diff --git a/src/stores/visualizer.ts b/src/stores/visualizer.ts index 2b401ec..fb5b6bd 100644 --- a/src/stores/visualizer.ts +++ b/src/stores/visualizer.ts @@ -344,6 +344,7 @@ function createVisualizerStore(): VisualizerStore { // ── Render loop (called at ~30fps) ───────────────────────────────── + let lastBarWriteAt = 0; const renderFrame = () => { if (!cava?.isReady || !sampleBuffer || !pcm) return; @@ -374,11 +375,17 @@ function createVisualizerStore(): VisualizerStore { const count = pcm.readWindow(sampleBuffer, target); // Never feed a partial FFT window to cava. if (count < sampleBuffer.length) return; - const output = cava.execute(sampleBuffer); - // Normalize against the running peak and copy to a new array - setBarData(scaler(output)); + // Write the UI signal at ~10fps, not 30: cava already smooths + // (noise reduction + peak release), and each Solid write costs a + // renderer diff pass. 3 of every 4 frames update only the pipeline. + const nowMs = performance.now(); + if (nowMs - lastBarWriteAt >= 95) { + lastBarWriteAt = nowMs; + // Normalize against the running peak and copy to a new array + setBarData(scaler(output)); + } // Fresh frames only count once the position clock has MOVED from // the resume point: while the player is still re-buffering after a // long pause, the cache serves the same window and the spinner must diff --git a/tests/visualizer-throttle.test.ts b/tests/visualizer-throttle.test.ts new file mode 100644 index 0000000..8f371ab --- /dev/null +++ b/tests/visualizer-throttle.test.ts @@ -0,0 +1,41 @@ +/** bars signal writes are throttled to ~10fps; the render loop still runs 30fps. */ +import { test, expect } from "bun:test" +import { join } from "path" +import { tmpdir } from "os" + +process.env.XDG_CONFIG_HOME = join(tmpdir(), `podtui-th-${process.pid}`) +process.env.XDG_DATA_HOME = join(tmpdir(), `podtui-th-data-${process.pid}`) +process.env.PODTUI_AUDIO_BACKEND = "none" + +const { useVisualizer } = await import("../src/stores/visualizer") +const { setCurrentEpisode, setIsPlaying, setPosition } = await import("../src/utils/audio-signals") +import type { Episode } from "../src/types/episode" + +const wavPath = "/tmp/podtui-pause-cycle.wav" +const skip = !(Bun.which("ffmpeg") && Bun.file(wavPath).exists()) + +test.skipIf(skip)("barData updates at ~10fps while the loop runs at 30fps", async () => { + const viz = useVisualizer() + viz.setBarCount(64) + viz.setFocused(true) + setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode) + setIsPlaying(true) + setPosition(5) + for (let i = 0; i < 200 && !(viz.barData().length > 0); i++) await Bun.sleep(25) + + let writes = 0 + let prev = viz.barData() + // count distinct array references the signal produced over 1s + const check = setInterval(() => { + const cur = viz.barData() + if (cur !== prev) { + writes++ + prev = cur + } + }, 16) + await Bun.sleep(1000) + clearInterval(check) + console.log(`barData writes in 1s: ${writes} (30fps loop would be ~15-20 distinct seen at 16ms sampling)`) + expect(writes).toBeGreaterThan(3) + expect(writes).toBeLessThanOrEqual(14) +}, 30_000)