From e1dc242b1d077f52cf72dc57c91fa6bb4cf9e1e0 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 6 Feb 2026 11:08:41 -0500 Subject: [PATCH] better visualizer --- src/components/MergedWaveform.tsx | 4 +- src/components/Player.tsx | 35 ++- src/components/RealtimeWaveform.tsx | 263 ++++++++++++++++++ src/components/SettingsScreen.tsx | 10 +- src/components/VisualizerSettings.tsx | 141 ++++++++++ src/stores/app.ts | 18 +- src/types/settings.ts | 14 + src/utils/app-persistence.ts | 11 +- src/utils/audio-stream-reader.ts | 191 +++++++++++++ src/utils/cavacore.ts | 230 +++++++++++++++ tasks/real-time-audio-visualization/README.md | 10 +- 11 files changed, 908 insertions(+), 19 deletions(-) create mode 100644 src/components/RealtimeWaveform.tsx create mode 100644 src/components/VisualizerSettings.tsx create mode 100644 src/utils/audio-stream-reader.ts create mode 100644 src/utils/cavacore.ts diff --git a/src/components/MergedWaveform.tsx b/src/components/MergedWaveform.tsx index 3a64dbb..e9babde 100644 --- a/src/components/MergedWaveform.tsx +++ b/src/components/MergedWaveform.tsx @@ -24,8 +24,8 @@ type MergedWaveformProps = { onSeek?: (seconds: number) => void; }; -/** Block characters for waveform amplitude levels */ -const BARS = [".", "-", "~", "=", "#"]; +/** Unicode lower block elements: space (silence) through full block (max) */ +const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"]; export function MergedWaveform(props: MergedWaveformProps) { const resolution = () => props.resolution ?? 64; diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 466f889..74c614a 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,7 +1,9 @@ import { useKeyboard } from "@opentui/solid" import { PlaybackControls } from "./PlaybackControls" import { MergedWaveform } from "./MergedWaveform" +import { RealtimeWaveform, isCavacoreAvailable } from "./RealtimeWaveform" import { useAudio } from "../hooks/useAudio" +import { useAppStore } from "../stores/app" import type { Episode } from "../types/episode" type PlayerProps = { @@ -97,13 +99,32 @@ export function Player(props: PlayerProps) { {episode().description} - audio.seek(next)} - /> + {isCavacoreAvailable() ? ( + audio.seek(next)} + visualizerConfig={(() => { + const viz = useAppStore().state().settings.visualizer + return { + bars: viz.bars, + noiseReduction: viz.noiseReduction, + lowCutOff: viz.lowCutOff, + highCutOff: viz.highCutOff, + } + })()} + /> + ) : ( + audio.seek(next)} + /> + )} void + /** Visualizer configuration overrides */ + visualizerConfig?: Partial +} + +/** Unicode lower block elements: space (silence) through full block (max) */ +const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"] + +/** Target frame interval in ms (~30 fps) */ +const FRAME_INTERVAL = 33 + +/** Number of PCM samples to read per frame (512 is a good FFT window) */ +const SAMPLES_PER_FRAME = 512 + +// ── Component ──────────────────────────────────────────────────────── + +export function RealtimeWaveform(props: RealtimeWaveformProps) { + const resolution = () => props.resolution ?? 32 + + // Frequency bar values (0.0–1.0 per bar) + const [barData, setBarData] = createSignal([]) + + // Track whether cavacore is available + const [available, setAvailable] = createSignal(false) + + let cava: CavaCore | null = null + let reader: AudioStreamReader | null = null + let frameTimer: ReturnType | null = null + let sampleBuffer: Float64Array | null = null + + // ── Lifecycle: init cavacore once ────────────────────────────────── + + const initCava = () => { + if (cava) return true + + cava = loadCavaCore() + if (!cava) { + setAvailable(false) + return false + } + + setAvailable(true) + return true + } + + // ── Start/stop the visualization pipeline ────────────────────────── + + const startVisualization = (url: string, position: number) => { + stopVisualization() + + if (!url || !initCava() || !cava) return + + // Initialize cavacore with current resolution + any overrides + const config: CavaCoreConfig = { + bars: resolution(), + sampleRate: 44100, + channels: 1, + ...props.visualizerConfig, + } + cava.init(config) + + // Pre-allocate sample read buffer + sampleBuffer = new Float64Array(SAMPLES_PER_FRAME) + + // Start ffmpeg decode stream + reader = new AudioStreamReader({ url }) + reader.start(position) + + // Start render loop + frameTimer = setInterval(renderFrame, FRAME_INTERVAL) + } + + const stopVisualization = () => { + if (frameTimer) { + clearInterval(frameTimer) + frameTimer = null + } + if (reader) { + reader.stop() + reader = null + } + if (cava?.isReady) { + cava.destroy() + } + sampleBuffer = null + } + + // ── Render loop (called at ~30fps) ───────────────────────────────── + + const renderFrame = () => { + if (!cava?.isReady || !reader?.running || !sampleBuffer) return + + // Read available PCM samples from the stream + const count = reader.read(sampleBuffer) + if (count === 0) return + + // Feed samples to cavacore → get frequency bars + const input = count < sampleBuffer.length + ? sampleBuffer.subarray(0, count) + : sampleBuffer + const output = cava.execute(input) + + // Copy bar values to a new array for the signal + setBarData(Array.from(output)) + } + + // ── Reactive effects: respond to prop changes ────────────────────── + + // Start/stop based on isPlaying and audioUrl + createEffect( + on( + () => [props.isPlaying, props.audioUrl] as const, + ([playing, url]) => { + if (playing && url) { + startVisualization(url, props.position) + } else { + stopVisualization() + // Keep last bar data visible (freeze frame) when paused + } + }, + ), + ) + + // Handle seeks: restart the ffmpeg stream at the new position + // We track position and restart only on significant jumps (>2s delta) + let lastSyncPosition = 0 + createEffect( + on( + () => props.position, + (pos) => { + if (!props.isPlaying || !reader?.running) return + + const delta = Math.abs(pos - lastSyncPosition) + // Only restart on seeks (>2s jump), not normal playback drift + if (delta > 2) { + reader.restart(pos) + lastSyncPosition = pos + } else { + lastSyncPosition = pos + } + }, + ), + ) + + // Re-init cavacore if resolution changes + createEffect( + on(resolution, (bars) => { + if (props.isPlaying && props.audioUrl && cava) { + // Restart with new bar count + startVisualization(props.audioUrl, props.position) + } + }), + ) + + // Cleanup on unmount + onCleanup(() => { + stopVisualization() + // Don't null cava itself — it can be reused. But do destroy its plan. + if (cava?.isReady) { + cava.destroy() + } + }) + + // ── Rendering ────────────────────────────────────────────────────── + + const playedRatio = () => + props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration) + + const renderLine = () => { + const bars = barData() + const numBars = resolution() + + // If no data yet, show empty placeholder + if (bars.length === 0) { + const placeholder = ".".repeat(numBars) + return ( + + {placeholder} + + ) + } + + const played = Math.floor(numBars * playedRatio()) + const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590" + const futureColor = "#3b4252" + + const playedChars = bars + .slice(0, played) + .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) + .join("") + + const futureChars = bars + .slice(played) + .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) + .join("") + + return ( + + {playedChars || " "} + {futureChars || " "} + + ) + } + + const handleClick = (event: { x: number }) => { + const numBars = resolution() + const ratio = numBars === 0 ? 0 : event.x / numBars + const next = Math.max( + 0, + Math.min(props.duration, Math.round(props.duration * ratio)), + ) + props.onSeek?.(next) + } + + return ( + + {renderLine()} + + ) +} + +/** + * Check if cavacore is available on this system. + * Useful for deciding whether to show RealtimeWaveform or MergedWaveform. + */ +let _cavacoreAvailable: boolean | null = null +export function isCavacoreAvailable(): boolean { + if (_cavacoreAvailable === null) { + const cava = loadCavaCore() + _cavacoreAvailable = cava !== null + } + return _cavacoreAvailable +} diff --git a/src/components/SettingsScreen.tsx b/src/components/SettingsScreen.tsx index 969df48..b10e926 100644 --- a/src/components/SettingsScreen.tsx +++ b/src/components/SettingsScreen.tsx @@ -4,6 +4,7 @@ import { SourceManager } from "./SourceManager" import { useTheme } from "../context/ThemeContext" import { PreferencesPanel } from "./PreferencesPanel" import { SyncPanel } from "./SyncPanel" +import { VisualizerSettings } from "./VisualizerSettings" type SettingsScreenProps = { accountLabel: string @@ -12,12 +13,13 @@ type SettingsScreenProps = { onExit?: () => void } -type SectionId = "sync" | "sources" | "preferences" | "account" +type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account" const SECTIONS: Array<{ id: SectionId; label: string }> = [ { id: "sync", label: "Sync" }, { id: "sources", label: "Sources" }, { id: "preferences", label: "Preferences" }, + { id: "visualizer", label: "Visualizer" }, { id: "account", label: "Account" }, ] @@ -43,7 +45,8 @@ export function SettingsScreen(props: SettingsScreenProps) { if (key.name === "1") setActiveSection("sync") if (key.name === "2") setActiveSection("sources") if (key.name === "3") setActiveSection("preferences") - if (key.name === "4") setActiveSection("account") + if (key.name === "4") setActiveSection("visualizer") + if (key.name === "5") setActiveSection("account") }) return ( @@ -52,7 +55,7 @@ export function SettingsScreen(props: SettingsScreenProps) { Settings - [Tab] Switch section | 1-4 jump | Esc up + [Tab] Switch section | 1-5 jump | Esc up @@ -76,6 +79,7 @@ export function SettingsScreen(props: SettingsScreenProps) { {activeSection() === "sync" && } {activeSection() === "sources" && } {activeSection() === "preferences" && } + {activeSection() === "visualizer" && } {activeSection() === "account" && ( Account diff --git a/src/components/VisualizerSettings.tsx b/src/components/VisualizerSettings.tsx new file mode 100644 index 0000000..80cc72d --- /dev/null +++ b/src/components/VisualizerSettings.tsx @@ -0,0 +1,141 @@ +/** + * VisualizerSettings — settings panel for the real-time audio visualizer. + * + * Allows adjusting bar count, noise reduction, sensitivity, and + * frequency cutoffs. All changes persist via the app store. + */ + +import { createSignal } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { useAppStore } from "../stores/app" +import { useTheme } from "../context/ThemeContext" +import { isCavacoreAvailable } from "./RealtimeWaveform" + +type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut" + +const FIELDS: FocusField[] = ["bars", "sensitivity", "noise", "lowCut", "highCut"] + +export function VisualizerSettings() { + const appStore = useAppStore() + const { theme } = useTheme() + const [focusField, setFocusField] = createSignal("bars") + + const viz = () => appStore.state().settings.visualizer + + const handleKey = (key: { name: string; shift?: boolean }) => { + if (key.name === "tab") { + const idx = FIELDS.indexOf(focusField()) + const next = key.shift + ? (idx - 1 + FIELDS.length) % FIELDS.length + : (idx + 1) % FIELDS.length + setFocusField(FIELDS[next]) + return + } + + if (key.name === "left" || key.name === "h") { + stepValue(-1) + } + if (key.name === "right" || key.name === "l") { + stepValue(1) + } + } + + const stepValue = (delta: number) => { + const field = focusField() + const v = viz() + + switch (field) { + case "bars": { + // Step by 8: 8, 16, 24, 32, ..., 128 + const next = Math.min(128, Math.max(8, v.bars + delta * 8)) + appStore.updateVisualizer({ bars: next }) + break + } + case "sensitivity": { + // Toggle: 0 (manual) or 1 (auto) + appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 }) + break + } + case "noise": { + // Step by 0.05: 0.0 – 1.0 + const next = Math.min(1, Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2)))) + appStore.updateVisualizer({ noiseReduction: next }) + break + } + case "lowCut": { + // Step by 10: 20 – 500 Hz + const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10)) + appStore.updateVisualizer({ lowCutOff: next }) + break + } + case "highCut": { + // Step by 500: 1000 – 20000 Hz + const next = Math.min(20000, Math.max(1000, v.highCutOff + delta * 500)) + appStore.updateVisualizer({ highCutOff: next }) + break + } + } + } + + useKeyboard(handleKey) + + const cavacoreStatus = isCavacoreAvailable() + + return ( + + Visualizer + + {!cavacoreStatus && ( + + cavacore not available — using static waveform + + )} + + + + Bars: + + {viz().bars} + + [Left/Right +/-8] + + + + Auto Sensitivity: + + + {viz().sensitivity === 1 ? "On" : "Off"} + + + [Left/Right] + + + + Noise Reduction: + + {viz().noiseReduction.toFixed(2)} + + [Left/Right +/-0.05] + + + + Low Cutoff: + + {viz().lowCutOff} Hz + + [Left/Right +/-10] + + + + High Cutoff: + + {viz().highCutOff} Hz + + [Left/Right +/-500] + + + + Tab to move focus, Left/Right to adjust + + ) +} diff --git a/src/stores/app.ts b/src/stores/app.ts index c99b485..973aca6 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js" import { DEFAULT_THEME, THEME_JSON } from "../constants/themes" -import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings" +import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences, VisualizerSettings } from "../types/settings" import { resolveTheme } from "../utils/theme-resolver" import type { ThemeJson } from "../types/theme-schema" import { @@ -9,11 +9,20 @@ import { migrateAppStateFromLocalStorage, } from "../utils/app-persistence" +const defaultVisualizerSettings: VisualizerSettings = { + bars: 32, + sensitivity: 1, + noiseReduction: 0.77, + lowCutOff: 50, + highCutOff: 10000, +} + const defaultSettings: AppSettings = { theme: "system", fontSize: 14, playbackSpeed: 1, downloadPath: "", + visualizer: defaultVisualizerSettings, } const defaultPreferences: UserPreferences = { @@ -72,6 +81,12 @@ export function createAppStore() { updateState(next) } + const updateVisualizer = (updates: Partial) => { + updateSettings({ + visualizer: { ...state().settings.visualizer, ...updates }, + }) + } + const setTheme = (theme: ThemeName) => { updateSettings({ theme }) } @@ -90,6 +105,7 @@ export function createAppStore() { updateSettings, updatePreferences, updateCustomTheme, + updateVisualizer, setTheme, resolveTheme: resolveThemeColors, } diff --git a/src/types/settings.ts b/src/types/settings.ts index edb45bd..fc03a17 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -46,11 +46,25 @@ export type DesktopTheme = { tokens: ThemeToken } +export type VisualizerSettings = { + /** Number of frequency bars (8–128, default: 32) */ + bars: number + /** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */ + sensitivity: 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 +} + export type AppSettings = { theme: ThemeName fontSize: number playbackSpeed: number downloadPath: string + visualizer: VisualizerSettings } export type UserPreferences = { diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 56819f0..4b80ba6 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -7,7 +7,7 @@ import { ensureConfigDir, getConfigFilePath } from "./config-dir" import { backupConfigFile } from "./config-backup" -import type { AppState, AppSettings, UserPreferences, ThemeColors } from "../types/settings" +import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings" import { DEFAULT_THEME } from "../constants/themes" const APP_STATE_FILE = "app-state.json" @@ -18,11 +18,20 @@ const LEGACY_PROGRESS_KEY = "podtui_progress" // --- Defaults --- +const defaultVisualizerSettings: VisualizerSettings = { + bars: 32, + sensitivity: 1, + noiseReduction: 0.77, + lowCutOff: 50, + highCutOff: 10000, +} + const defaultSettings: AppSettings = { theme: "system", fontSize: 14, playbackSpeed: 1, downloadPath: "", + visualizer: defaultVisualizerSettings, } const defaultPreferences: UserPreferences = { diff --git a/src/utils/audio-stream-reader.ts b/src/utils/audio-stream-reader.ts new file mode 100644 index 0000000..65c843b --- /dev/null +++ b/src/utils/audio-stream-reader.ts @@ -0,0 +1,191 @@ +/** + * Real-time audio stream reader for visualization. + * + * Spawns a separate ffmpeg process that decodes the same audio URL + * the player is using and outputs raw PCM data (signed 16-bit LE, mono, + * 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer + * and provides them to the caller on demand. + * + * This is independent from the actual playback backend — it's a + * read-only "tap" on the audio for FFT analysis purposes. + */ + +/** PCM output format constants */ +const SAMPLE_RATE = 44100 +const CHANNELS = 1 +const BYTES_PER_SAMPLE = 2 // s16le + +/** How many samples to buffer (≈1 second) */ +const RING_BUFFER_SAMPLES = SAMPLE_RATE + +export interface AudioStreamReaderOptions { + /** Audio URL or file path to decode */ + url: string + /** Start position in seconds (for seeking sync) */ + startPosition?: number + /** Sample rate (default: 44100) */ + sampleRate?: number +} + +export class AudioStreamReader { + private proc: ReturnType | null = null + private ringBuffer: Float64Array + private writePos = 0 + private totalSamplesWritten = 0 + private _running = false + private readPromise: Promise | null = null + private url: string + private sampleRate: number + + constructor(options: AudioStreamReaderOptions) { + this.url = options.url + this.sampleRate = options.sampleRate ?? SAMPLE_RATE + this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES) + } + + /** Whether the reader is actively reading samples. */ + get running(): boolean { + return this._running + } + + /** Total number of samples written since start(). */ + get samplesWritten(): number { + return this.totalSamplesWritten + } + + /** + * Start the ffmpeg decode process and begin reading PCM data. + * @param startPosition Seek position in seconds (default: 0). + */ + start(startPosition = 0): void { + if (this._running) return + if (!Bun.which("ffmpeg")) { + throw new Error("ffmpeg not found — required for audio visualization") + } + + const args = [ + "ffmpeg", + "-loglevel", "quiet", + ] + + // Seek before input for network efficiency + if (startPosition > 0) { + args.push("-ss", String(startPosition)) + } + + args.push( + "-i", this.url, + "-ac", String(CHANNELS), + "-ar", String(this.sampleRate), + "-f", "s16le", // raw signed 16-bit little-endian PCM + "-acodec", "pcm_s16le", + "-", // output to stdout + ) + + this.proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }) + + this._running = true + this.writePos = 0 + this.totalSamplesWritten = 0 + + // Start async reading loop + this.readPromise = this.readLoop() + + // Detect process exit + this.proc.exited.then(() => { + this._running = false + }).catch(() => { + this._running = false + }) + } + + /** + * Read available samples into the provided buffer. + * Returns the number of samples actually copied. + * + * @param out - Float64Array to fill with samples (scaled ~±32768 for cavacore). + * @returns Number of samples written to `out`. + */ + read(out: Float64Array): number { + const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length) + if (available <= 0) return 0 + + // Read the most recent `available` samples from the ring buffer + const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length + + if (readStart + available <= this.ringBuffer.length) { + // Contiguous read + out.set(this.ringBuffer.subarray(readStart, readStart + available)) + } else { + // Wraps around + const firstChunk = this.ringBuffer.length - readStart + out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length)) + out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk) + } + + return available + } + + /** + * Stop the ffmpeg process and clean up. + * Safe to call multiple times. + */ + stop(): void { + this._running = false + if (this.proc) { + try { this.proc.kill() } catch { /* ignore */ } + this.proc = null + } + this.writePos = 0 + this.totalSamplesWritten = 0 + } + + /** + * Restart the reader at a new position (e.g. after a seek). + */ + restart(startPosition = 0): void { + this.stop() + this.start(startPosition) + } + + /** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */ + private async readLoop(): Promise { + const stdout = this.proc?.stdout + if (!stdout || typeof stdout === "number") return + + const reader = (stdout as ReadableStream).getReader() + try { + while (this._running) { + const { done, value } = await reader.read() + if (done || !this._running) break + if (!value || value.byteLength === 0) continue + + // Convert raw s16le bytes → Float64Array scaled for cavacore + // Ensure we have an even number of bytes (each sample = 2 bytes) + const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE) + if (sampleCount === 0) continue + + const int16View = new Int16Array( + value.buffer, + value.byteOffset, + sampleCount, + ) + + // Write samples into ring buffer (as doubles, preserving int16 scale) + for (let i = 0; i < sampleCount; i++) { + this.ringBuffer[this.writePos] = int16View[i] // ±32768 range + this.writePos = (this.writePos + 1) % this.ringBuffer.length + this.totalSamplesWritten++ + } + } + } catch { + // Stream ended or process killed — expected during stop() + } finally { + try { reader.releaseLock() } catch { /* ignore */ } + } + } +} diff --git a/src/utils/cavacore.ts b/src/utils/cavacore.ts new file mode 100644 index 0000000..39bdc77 --- /dev/null +++ b/src/utils/cavacore.ts @@ -0,0 +1,230 @@ +/** + * 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 +} + +const DEFAULTS: Required = { + bars: 32, + sampleRate: 44100, + channels: 1, + autosens: 1, + noiseReduction: 0.77, + lowCutOff: 50, + highCutOff: 10000, +} + +// 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, + ) + + 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 + ], + 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 + } +} diff --git a/tasks/real-time-audio-visualization/README.md b/tasks/real-time-audio-visualization/README.md index 3991bf0..657a665 100644 --- a/tasks/real-time-audio-visualization/README.md +++ b/tasks/real-time-audio-visualization/README.md @@ -6,11 +6,11 @@ Status legend: [ ] todo, [~] in-progress, [x] done Tasks - [x] 01 — Copy cavacore library files to project → `01-copy-cavacore-files.md` -- [ ] 02 — Integrate cavacore library for audio analysis → `02-integrate-cavacore-library.md` -- [ ] 03 — Create audio stream reader for real-time data → `03-create-audio-stream-reader.md` -- [ ] 04 — Create realtime waveform component → `04-create-realtime-waveform-component.md` -- [ ] 05 — Update Player component to use realtime visualization → `05-update-player-visualization.md` -- [ ] 06 — Add visualizer controls and settings → `06-add-visualizer-controls.md` +- [x] 02 — Integrate cavacore library for audio analysis → `02-integrate-cavacore-library.md` +- [x] 03 — Create audio stream reader for real-time data → `03-create-audio-stream-reader.md` +- [x] 04 — Create realtime waveform component → `04-create-realtime-waveform-component.md` +- [x] 05 — Update Player component to use realtime visualization → `05-update-player-visualization.md` +- [x] 06 — Add visualizer controls and settings → `06-add-visualizer-controls.md` Dependencies - 01 depends on (none)