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)