Compare commits

...

4 Commits

Author SHA1 Message Date
920042ee2a fix stream multiplaction 2026-02-06 11:47:48 -05:00
e1dc242b1d better visualizer 2026-02-06 11:08:41 -05:00
8d6b19582c implementing cava for real time visualization 2026-02-06 10:11:51 -05:00
63ded34a6b basic clean 2026-02-06 09:57:33 -05:00
24 changed files with 1659 additions and 145 deletions

View File

@@ -44,4 +44,19 @@ if (platformPkg) {
} }
} }
// Copy cavacore native library to dist
const cavacoreLib = platform === "darwin"
? "libcavacore.dylib"
: platform === "win32"
? "cavacore.dll"
: "libcavacore.so"
const cavacoreSrc = join("src", "native", cavacoreLib)
if (existsSync(cavacoreSrc)) {
copyFileSync(cavacoreSrc, join("dist", cavacoreLib))
console.log(`Copied cavacore library: ${cavacoreLib}`)
} else {
console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`)
}
console.log("Build complete") console.log("Build complete")

View File

@@ -9,6 +9,7 @@
"scripts": { "scripts": {
"start": "bun src/index.tsx", "start": "bun src/index.tsx",
"dev": "bun --watch src/index.tsx", "dev": "bun --watch src/index.tsx",
"build:native": "bash scripts/build-cavacore.sh",
"build": "bun run build.ts", "build": "bun run build.ts",
"dist": "bun dist/index.js", "dist": "bun dist/index.js",
"test": "bun test", "test": "bun test",

79
scripts/build-cavacore.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Build cavacore as a shared library with fftw3 statically linked.
#
# Prerequisites:
# macOS: brew install fftw
# Linux: apt install libfftw3-dev (or equivalent)
#
# Output: src/native/libcavacore.{dylib,so}
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/cava/cavacore.c"
OUT_DIR="$ROOT/src/native"
mkdir -p "$OUT_DIR"
OS="$(uname -s)"
ARCH="$(uname -m)"
# Resolve fftw3 paths
if [ "$OS" = "Darwin" ]; then
if [ "$ARCH" = "arm64" ]; then
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}"
else
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}"
fi
LIB_EXT="dylib"
SHARED_FLAG="-dynamiclib"
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
else
FFTW_PREFIX="${FFTW_PREFIX:-/usr}"
LIB_EXT="so"
SHARED_FLAG="-shared"
INSTALL_NAME=""
fi
FFTW_INCLUDE="$FFTW_PREFIX/include"
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
if [ ! -f "$FFTW_STATIC" ]; then
echo "Error: libfftw3.a not found at $FFTW_STATIC"
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
exit 1
fi
if [ ! -f "$SRC" ]; then
echo "Error: cavacore.c not found at $SRC"
echo "Ensure the cava submodule is initialized: git submodule update --init"
exit 1
fi
OUT="$OUT_DIR/libcavacore.$LIB_EXT"
echo "Building libcavacore.$LIB_EXT ($OS $ARCH)"
echo " Source: $SRC"
echo " FFTW3: $FFTW_STATIC"
echo " Output: $OUT"
cc -O2 \
$SHARED_FLAG \
$INSTALL_NAME \
-fPIC \
-I"$FFTW_INCLUDE" \
-I"$ROOT/cava" \
-o "$OUT" \
"$SRC" \
"$FFTW_STATIC" \
-lm
echo "Built: $OUT"
# Verify exported symbols
if [ "$OS" = "Darwin" ]; then
echo ""
echo "Exported symbols:"
nm -gU "$OUT" | grep "cava_"
fi

View File

@@ -1,4 +1,4 @@
import { createSignal, ErrorBoundary } from "solid-js"; import { createSignal, createMemo, ErrorBoundary } from "solid-js";
import { useSelectionHandler } from "@opentui/solid"; import { useSelectionHandler } from "@opentui/solid";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { Navigation } from "./components/Navigation"; import { Navigation } from "./components/Navigation";
@@ -107,7 +107,7 @@ export function App() {
}).catch(() => {}) }).catch(() => {})
}) })
const getPanels = () => { const getPanels = createMemo(() => {
const tab = activeTab(); const tab = activeTab();
switch (tab) { switch (tab) {
@@ -305,7 +305,7 @@ export function App() {
hint: "", hint: "",
}; };
} }
}; });
return ( return (
<ErrorBoundary fallback={(err) => ( <ErrorBoundary fallback={(err) => (

View File

@@ -6,88 +6,92 @@
* separate progress bar. Click-to-seek is supported. * separate progress bar. Click-to-seek is supported.
*/ */
import { createSignal, createEffect, onCleanup } from "solid-js" import { createSignal, createEffect, onCleanup } from "solid-js";
import { getWaveformData, getWaveformDataSync } from "../utils/audio-waveform" import { getWaveformData } from "../utils/audio-waveform";
type MergedWaveformProps = { type MergedWaveformProps = {
/** Audio URL — used to generate or retrieve waveform data */ /** Audio URL — used to generate or retrieve waveform data */
audioUrl: string audioUrl: string;
/** Current playback position in seconds */ /** Current playback position in seconds */
position: number position: number;
/** Total duration in seconds */ /** Total duration in seconds */
duration: number duration: number;
/** Whether audio is currently playing */ /** Whether audio is currently playing */
isPlaying: boolean isPlaying: boolean;
/** Number of data points / columns */ /** Number of data points / columns */
resolution?: number resolution?: number;
/** Callback when user clicks to seek */ /** Callback when user clicks to seek */
onSeek?: (seconds: number) => void onSeek?: (seconds: number) => void;
} };
/** Block characters for waveform amplitude levels */ /** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [".", "-", "~", "=", "#"] const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
export function MergedWaveform(props: MergedWaveformProps) { export function MergedWaveform(props: MergedWaveformProps) {
const resolution = () => props.resolution ?? 64 const resolution = () => props.resolution ?? 64;
// Waveform data — start with sync/cached, kick off async extraction // Waveform data — start with sync/cached, kick off async extraction
const [data, setData] = createSignal<number[]>( const [data, setData] = createSignal<number[]>();
getWaveformDataSync(props.audioUrl, resolution()),
)
// When the audioUrl changes, attempt async extraction for real data // When the audioUrl changes, attempt async extraction for real data
createEffect(() => { createEffect(() => {
const url = props.audioUrl const url = props.audioUrl;
const res = resolution() const res = resolution();
if (!url) return if (!url) return;
let cancelled = false let cancelled = false;
getWaveformData(url, res).then((result) => { getWaveformData(url, res).then((result) => {
if (!cancelled) setData(result) if (!cancelled) setData(result);
}) });
onCleanup(() => { cancelled = true }) onCleanup(() => {
}) cancelled = true;
});
});
const playedRatio = () => const playedRatio = () =>
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration) props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
const renderLine = () => { const renderLine = () => {
const d = data() const d = data();
const played = Math.floor(d.length * playedRatio()) if (!d) {
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590" console.error("no data recieved");
const futureColor = "#3b4252" return;
}
const played = Math.floor(d.length * playedRatio());
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const playedChars = d const playedChars = d
.slice(0, played) .slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("") .join("");
const futureChars = d const futureChars = d
.slice(played) .slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("") .join("");
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text> <text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text> <text fg={futureColor}>{futureChars || " "}</text>
</box> </box>
) );
} };
const handleClick = (event: { x: number }) => { const handleClick = (event: { x: number }) => {
const d = data() const d = data();
const ratio = d.length === 0 ? 0 : event.x / d.length const ratio = !d || d.length === 0 ? 0 : event.x / d.length;
const next = Math.max( const next = Math.max(
0, 0,
Math.min(props.duration, Math.round(props.duration * ratio)), Math.min(props.duration, Math.round(props.duration * ratio)),
) );
props.onSeek?.(next) props.onSeek?.(next);
} };
return ( return (
<box border padding={1} onMouseDown={handleClick}> <box border padding={1} onMouseDown={handleClick}>
{renderLine()} {renderLine()}
</box> </box>
) );
} }

View File

@@ -1,7 +1,9 @@
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { PlaybackControls } from "./PlaybackControls" import { PlaybackControls } from "./PlaybackControls"
import { MergedWaveform } from "./MergedWaveform" import { MergedWaveform } from "./MergedWaveform"
import { RealtimeWaveform, isCavacoreAvailable } from "./RealtimeWaveform"
import { useAudio } from "../hooks/useAudio" import { useAudio } from "../hooks/useAudio"
import { useAppStore } from "../stores/app"
import type { Episode } from "../types/episode" import type { Episode } from "../types/episode"
type PlayerProps = { type PlayerProps = {
@@ -97,6 +99,25 @@ export function Player(props: PlayerProps) {
</text> </text>
<text fg="gray">{episode().description}</text> <text fg="gray">{episode().description}</text>
{isCavacoreAvailable() ? (
<RealtimeWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
speed={audio.speed()}
onSeek={(next: number) => audio.seek(next)}
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
}
})()}
/>
) : (
<MergedWaveform <MergedWaveform
audioUrl={episode().audioUrl} audioUrl={episode().audioUrl}
position={audio.position()} position={audio.position()}
@@ -104,6 +125,7 @@ export function Player(props: PlayerProps) {
isPlaying={audio.isPlaying()} isPlaying={audio.isPlaying()}
onSeek={(next: number) => audio.seek(next)} onSeek={(next: number) => audio.seek(next)}
/> />
)}
</box> </box>
<PlaybackControls <PlaybackControls

View File

@@ -0,0 +1,281 @@
/**
* RealtimeWaveform — live audio frequency visualization using cavacore.
*
* Replaces MergedWaveform during playback. Spawns an independent ffmpeg
* process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal
* characters at ~30fps.
*
* Falls back gracefully if cavacore is unavailable (loadCavaCore returns null).
* Same prop interface as MergedWaveform for drop-in replacement.
*/
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js"
import { loadCavaCore, type CavaCore, type CavaCoreConfig } from "../utils/cavacore"
import { AudioStreamReader } from "../utils/audio-stream-reader"
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
/** Audio URL — used to start the ffmpeg decode stream */
audioUrl: string
/** Current playback position in seconds */
position: number
/** Total duration in seconds */
duration: number
/** Whether audio is currently playing */
isPlaying: boolean
/** Playback speed multiplier (default: 1) */
speed?: number
/** Number of frequency bars / columns */
resolution?: number
/** Callback when user clicks to seek */
onSeek?: (seconds: number) => void
/** Visualizer configuration overrides */
visualizerConfig?: Partial<CavaCoreConfig>
}
/** 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.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([])
// Track whether cavacore is available
const [available, setAvailable] = createSignal(false)
let cava: CavaCore | null = null
let reader: AudioStreamReader | null = null
let frameTimer: ReturnType<typeof setInterval> | 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, speed: 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 (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop()
reader = new AudioStreamReader({ url })
}
reader.start(position, speed)
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL)
}
const stopVisualization = () => {
if (frameTimer) {
clearInterval(frameTimer)
frameTimer = null
}
if (reader) {
reader.stop()
// Don't null reader — we reuse it across start/stop cycles
}
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))
}
// ── Single unified effect: respond to all prop changes ─────────────
//
// Instead of three competing effects that each independently call
// startVisualization() and race against each other, we use ONE effect
// that tracks all relevant inputs. Position is read with untrack()
// so normal playback drift doesn't trigger restarts.
//
// SolidJS on() with an array of accessors compares each element
// individually, so the effect only fires when a value actually changes.
createEffect(
on(
[
() => props.isPlaying,
() => props.audioUrl,
() => props.speed ?? 1,
resolution,
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(() => props.position)
startVisualization(url, pos, speed)
} else {
stopVisualization()
}
},
),
)
// ── Seek detection: lightweight effect for position jumps ──────────
//
// Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0
createEffect(
on(
() => props.position,
(pos) => {
if (!props.isPlaying || !reader?.running) {
lastSyncPosition = pos
return
}
const delta = Math.abs(pos - lastSyncPosition)
lastSyncPosition = pos
if (delta > 2) {
const speed = props.speed ?? 1
reader.restart(pos, speed)
}
},
),
)
// Cleanup on unmount
onCleanup(() => {
stopVisualization()
if (reader) {
reader.stop()
reader = null
}
// 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 (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text>
</box>
)
}
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 (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text>
</box>
)
}
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 (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
)
}
/**
* 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
}

View File

@@ -4,6 +4,7 @@ import { SourceManager } from "./SourceManager"
import { useTheme } from "../context/ThemeContext" import { useTheme } from "../context/ThemeContext"
import { PreferencesPanel } from "./PreferencesPanel" import { PreferencesPanel } from "./PreferencesPanel"
import { SyncPanel } from "./SyncPanel" import { SyncPanel } from "./SyncPanel"
import { VisualizerSettings } from "./VisualizerSettings"
type SettingsScreenProps = { type SettingsScreenProps = {
accountLabel: string accountLabel: string
@@ -12,12 +13,13 @@ type SettingsScreenProps = {
onExit?: () => void onExit?: () => void
} }
type SectionId = "sync" | "sources" | "preferences" | "account" type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account"
const SECTIONS: Array<{ id: SectionId; label: string }> = [ const SECTIONS: Array<{ id: SectionId; label: string }> = [
{ id: "sync", label: "Sync" }, { id: "sync", label: "Sync" },
{ id: "sources", label: "Sources" }, { id: "sources", label: "Sources" },
{ id: "preferences", label: "Preferences" }, { id: "preferences", label: "Preferences" },
{ id: "visualizer", label: "Visualizer" },
{ id: "account", label: "Account" }, { id: "account", label: "Account" },
] ]
@@ -43,7 +45,8 @@ export function SettingsScreen(props: SettingsScreenProps) {
if (key.name === "1") setActiveSection("sync") if (key.name === "1") setActiveSection("sync")
if (key.name === "2") setActiveSection("sources") if (key.name === "2") setActiveSection("sources")
if (key.name === "3") setActiveSection("preferences") 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 ( return (
@@ -52,7 +55,7 @@ export function SettingsScreen(props: SettingsScreenProps) {
<text> <text>
<strong>Settings</strong> <strong>Settings</strong>
</text> </text>
<text fg={theme.textMuted}>[Tab] Switch section | 1-4 jump | Esc up</text> <text fg={theme.textMuted}>[Tab] Switch section | 1-5 jump | Esc up</text>
</box> </box>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -76,6 +79,7 @@ export function SettingsScreen(props: SettingsScreenProps) {
{activeSection() === "sync" && <SyncPanel />} {activeSection() === "sync" && <SyncPanel />}
{activeSection() === "sources" && <SourceManager focused />} {activeSection() === "sources" && <SourceManager focused />}
{activeSection() === "preferences" && <PreferencesPanel />} {activeSection() === "preferences" && <PreferencesPanel />}
{activeSection() === "visualizer" && <VisualizerSettings />}
{activeSection() === "account" && ( {activeSection() === "account" && (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Account</text> <text fg={theme.textMuted}>Account</text>

View File

@@ -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<FocusField>("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 (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Visualizer</text>
{!cavacoreStatus && (
<text fg={theme.warning}>
cavacore not available using static waveform
</text>
)}
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>Bars:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().bars}</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "sensitivity" ? theme.primary : theme.textMuted}>Auto Sensitivity:</text>
<box border padding={0}>
<text fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}>
{viz().sensitivity === 1 ? "On" : "Off"}
</text>
</box>
<text fg={theme.textMuted}>[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>Noise Reduction:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}>Low Cutoff:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "highCut" ? theme.primary : theme.textMuted}>High Cutoff:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().highCutOff} Hz</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
</box>
</box>
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box>
)
}

BIN
src/native/libcavacore.dylib Executable file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes" 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 { resolveTheme } from "../utils/theme-resolver"
import type { ThemeJson } from "../types/theme-schema" import type { ThemeJson } from "../types/theme-schema"
import { import {
@@ -9,11 +9,20 @@ import {
migrateAppStateFromLocalStorage, migrateAppStateFromLocalStorage,
} from "../utils/app-persistence" } from "../utils/app-persistence"
const defaultVisualizerSettings: VisualizerSettings = {
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
}
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings,
} }
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
@@ -72,6 +81,12 @@ export function createAppStore() {
updateState(next) updateState(next)
} }
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({
visualizer: { ...state().settings.visualizer, ...updates },
})
}
const setTheme = (theme: ThemeName) => { const setTheme = (theme: ThemeName) => {
updateSettings({ theme }) updateSettings({ theme })
} }
@@ -90,6 +105,7 @@ export function createAppStore() {
updateSettings, updateSettings,
updatePreferences, updatePreferences,
updateCustomTheme, updateCustomTheme,
updateVisualizer,
setTheme, setTheme,
resolveTheme: resolveThemeColors, resolveTheme: resolveThemeColors,
} }

View File

@@ -46,11 +46,25 @@ export type DesktopTheme = {
tokens: ThemeToken tokens: ThemeToken
} }
export type VisualizerSettings = {
/** Number of frequency bars (8128, default: 32) */
bars: number
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
sensitivity: number
/** Noise reduction factor 0.01.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 = { export type AppSettings = {
theme: ThemeName theme: ThemeName
fontSize: number fontSize: number
playbackSpeed: number playbackSpeed: number
downloadPath: string downloadPath: string
visualizer: VisualizerSettings
} }
export type UserPreferences = { export type UserPreferences = {

View File

@@ -7,7 +7,7 @@
import { ensureConfigDir, getConfigFilePath } from "./config-dir" import { ensureConfigDir, getConfigFilePath } from "./config-dir"
import { backupConfigFile } from "./config-backup" 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" import { DEFAULT_THEME } from "../constants/themes"
const APP_STATE_FILE = "app-state.json" const APP_STATE_FILE = "app-state.json"
@@ -18,11 +18,20 @@ const LEGACY_PROGRESS_KEY = "podtui_progress"
// --- Defaults --- // --- Defaults ---
const defaultVisualizerSettings: VisualizerSettings = {
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
}
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings,
} }
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {

View File

@@ -0,0 +1,249 @@
/**
* 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
/** Sample rate (default: 44100) */
sampleRate?: number
}
/**
* Monotonically increasing generation counter.
* Each start() increments this; the read loop checks it to know
* if it's been superseded and should bail out.
*/
let globalGeneration = 0
export class AudioStreamReader {
private proc: ReturnType<typeof Bun.spawn> | null = null
private ringBuffer: Float64Array
private writePos = 0
private totalSamplesWritten = 0
private _running = false
private generation = 0
readonly 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.
*
* If already running, the previous process is killed first.
* Uses a generation counter to guarantee that only one read loop
* is ever active — stale loops from killed processes bail out
* immediately.
*
* @param startPosition Seek position in seconds (default: 0).
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
* atempo filter so visualization stays in sync with audio.
*/
start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running
this.killProcess()
if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization")
}
// Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit.
this.generation = ++globalGeneration
const args = [
"ffmpeg",
"-loglevel", "quiet",
]
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition))
}
args.push("-i", this.url)
// Apply speed via atempo filter if not 1x.
// ffmpeg atempo only supports 0.5100.0; chain multiple for extremes.
if (speed !== 1 && speed > 0) {
const atempoFilters = buildAtempoChain(speed)
args.push("-af", atempoFilters)
}
args.push(
"-ac", String(CHANNELS),
"-ar", String(this.sampleRate),
"-f", "s16le",
"-acodec", "pcm_s16le",
"-",
)
this.proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
})
this._running = true
this.writePos = 0
this.totalSamplesWritten = 0
// Capture generation for this run
const myGeneration = this.generation
// Start async reading loop
this.readLoop(myGeneration)
// Detect process exit
this.proc.exited.then(() => {
// Only clear _running if this is still the current generation
if (this.generation === myGeneration) {
this._running = false
}
}).catch(() => {
if (this.generation === myGeneration) {
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) {
out.set(this.ringBuffer.subarray(readStart, readStart + available))
} else {
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. Guarantees the read loop exits.
*/
stop(): void {
// Bump generation to invalidate any running read loop
this.generation = ++globalGeneration
this._running = false
this.killProcess()
this.writePos = 0
this.totalSamplesWritten = 0
}
/**
* Restart the reader at a new position and/or speed.
*/
restart(startPosition = 0, speed = 1): void {
this.start(startPosition, speed)
}
/** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void {
if (this.proc) {
try { this.proc.kill() } catch { /* ignore */ }
this.proc = null
}
}
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
private async readLoop(myGeneration: number): Promise<void> {
const stdout = this.proc?.stdout
if (!stdout || typeof stdout === "number") return
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
try {
while (this.generation === myGeneration) {
const { done, value } = await reader.read()
if (done || this.generation !== myGeneration) break
if (!value || value.byteLength === 0) continue
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE)
if (sampleCount === 0) continue
const int16View = new Int16Array(
value.buffer,
value.byteOffset,
sampleCount,
)
for (let i = 0; i < sampleCount; i++) {
this.ringBuffer[this.writePos] = int16View[i]
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 */ }
}
}
}
/**
* Build an ffmpeg atempo filter chain for a given speed.
* atempo only accepts values in [0.5, 100.0], so we chain
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
*/
function buildAtempoChain(speed: number): string {
const parts: string[] = []
let remaining = Math.max(0.25, Math.min(4, speed))
while (remaining > 100) {
parts.push("atempo=100.0")
remaining /= 100
}
while (remaining < 0.5) {
parts.push("atempo=0.5")
remaining /= 0.5
}
parts.push(`atempo=${remaining}`)
return parts.join(",")
}

View File

@@ -2,148 +2,102 @@
* Audio waveform analysis for PodTUI * Audio waveform analysis for PodTUI
* *
* Extracts amplitude data from audio files using ffmpeg (when available) * Extracts amplitude data from audio files using ffmpeg (when available)
* or generates procedural waveform data as a fallback. Results are cached * Results are cache in-memory keyed by audio URL.
* in-memory keyed by audio URL.
*/ */
/** Number of amplitude data points to generate */ /** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128 const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */ /** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>() const waveformCache = new Map<string, number[]>();
/** /**
* Try to extract real waveform data from an audio URL using ffmpeg. * Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails. * Returns null if ffmpeg is not available or the extraction fails.
*/ */
async function extractWithFfmpeg(audioUrl: string, resolution: number): Promise<number[] | null> { async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try { try {
if (!Bun.which("ffmpeg")) return null if (!Bun.which("ffmpeg")) return null;
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points. // Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
// -t 300: read at most 5 minutes (enough data to fill the waveform) // -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn( const proc = Bun.spawn(
[ [
"ffmpeg", "ffmpeg",
"-i", audioUrl, "-i",
"-t", "300", audioUrl,
"-ac", "1", // mono "-t",
"-ar", "8000", // low sample rate to keep data small "300",
"-f", "s16le", // raw signed 16-bit PCM "-ac",
"-v", "quiet", "1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-", "-",
], ],
{ stdout: "pipe", stderr: "ignore" }, { stdout: "pipe", stderr: "ignore" },
) );
const output = await new Response(proc.stdout).arrayBuffer() const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited await proc.exited;
if (output.byteLength === 0) return null if (output.byteLength === 0) return null;
const samples = new Int16Array(output) const samples = new Int16Array(output);
if (samples.length === 0) return null if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude // Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket. // in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution)) const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [] const data: number[] = [];
for (let i = 0; i < resolution; i++) { for (let i = 0; i < resolution; i++) {
const start = i * bucketSize const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length) const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0 let maxAbs = 0;
for (let j = start; j < end; j++) { for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]) const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs if (abs > maxAbs) maxAbs = abs;
} }
// Normalise to 0-1 // Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3))) data.push(Number((maxAbs / 32768).toFixed(3)));
} }
return data return data;
} catch { } catch {
return null return null;
} }
} }
/**
* Generate a procedural (fake) waveform that looks plausible.
* Uses a combination of sine waves with different frequencies to
* simulate varying audio energy.
*/
function generateProcedural(resolution: number, seed: number): number[] {
const data: number[] = []
for (let i = 0; i < resolution; i++) {
const t = i + seed
const value =
0.15 +
Math.abs(Math.sin(t / 3.7)) * 0.35 +
Math.abs(Math.sin(t / 7.3)) * 0.25 +
Math.abs(Math.sin(t / 13.1)) * 0.15 +
(Math.random() * 0.1)
data.push(Number(Math.min(1, value).toFixed(3)))
}
return data
}
/**
* Simple numeric hash of a string, used to seed procedural generation
* so the same URL always produces the same waveform.
*/
function hashString(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0
}
return Math.abs(h)
}
/** /**
* Get waveform data for an audio URL. * Get waveform data for an audio URL.
* *
* Returns cached data if available, otherwise attempts ffmpeg extraction * Returns cached data if available, otherwise attempts ffmpeg extraction
* and falls back to procedural generation.
*/ */
export async function getWaveformData( export async function getWaveformData(
audioUrl: string, audioUrl: string,
resolution: number = DEFAULT_RESOLUTION, resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> { ): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}` const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey) const cached = waveformCache.get(cacheKey);
if (cached) return cached if (cached) return cached;
// Try real extraction first const real = await extractWithFfmpeg(audioUrl, resolution);
const real = await extractWithFfmpeg(audioUrl, resolution)
if (real) { if (real) {
waveformCache.set(cacheKey, real) waveformCache.set(cacheKey, real);
return real return real;
} else {
console.error("generation failure");
return [];
} }
// Fall back to procedural
const procedural = generateProcedural(resolution, hashString(audioUrl))
waveformCache.set(cacheKey, procedural)
return procedural
} }
/**
* Synchronous fallback: get a waveform immediately (from cache or procedural).
* Use this when you need data without waiting for async extraction.
*/
export function getWaveformDataSync(
audioUrl: string,
resolution: number = DEFAULT_RESOLUTION,
): number[] {
const cacheKey = `${audioUrl}:${resolution}`
const cached = waveformCache.get(cacheKey)
if (cached) return cached
const procedural = generateProcedural(resolution, hashString(audioUrl))
waveformCache.set(cacheKey, procedural)
return procedural
}
/** Clear the waveform cache (for memory management) */
export function clearWaveformCache(): void { export function clearWaveformCache(): void {
waveformCache.clear() waveformCache.clear();
} }

230
src/utils/cavacore.ts Normal file
View File

@@ -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.01.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<CavaCoreConfig> = {
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<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,
)
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.01.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
}
}

View File

@@ -0,0 +1,57 @@
# 01. Copy cavacore library files to project
meta:
id: real-time-audio-visualization-01
feature: real-time-audio-visualization
priority: P0
depends_on: []
tags: [setup, build]
objective:
- Copy necessary cava library files from cava/ directory to src/utils/ for integration
deliverables:
- src/utils/cavacore.h - Header file with cavacore API
- src/utils/cavacore.c - Implementation of cavacore library
- src/utils/audio-stream.h - Audio stream reader header
- src/utils/audio-stream.c - Audio stream reader implementation
- src/utils/audio-input.h - Common audio input types
- src/utils/audio-input.c - Audio input buffer management
steps:
- Identify necessary files from cava/ directory:
- cavacore.h (API definition)
- cavacore.c (FFT processing implementation)
- input/common.h (common audio data structures)
- input/common.c (input buffer handling)
- input/fifo.h (FIFO input support - optional, for testing)
- input/fifo.c (FIFO input implementation - optional)
- Copy cavacore.h to src/utils/
- Copy cavacore.c to src/utils/
- Copy input/common.h to src/utils/
- Copy input/common.c to src/utils/
- Copy input/fifo.h to src/utils/ (optional)
- Copy input/fifo.c to src/utils/ (optional)
- Update file headers to indicate origin and licensing
- Note: Files from cava/ directory will be removed after integration
tests:
- Unit: Verify all files compile successfully
- Integration: Ensure no import errors in TypeScript/JavaScript files
- Manual: Check that files are accessible from src/utils/
acceptance_criteria:
- All required cava files are copied to src/utils/
- File headers include proper copyright and license information
- No compilation errors from missing dependencies
- Files are properly formatted for TypeScript/JavaScript integration
validation:
- Run: `bun run build` to verify compilation
- Check: `ls src/utils/*.c src/utils/*.h` to confirm file presence
notes:
- Only need cavacore.c, cavacore.h, and common.c/common.h for basic functionality
- input/fifo.c is optional - can be added later if needed
- FFTW library will need to be installed and linked separately
- The files will be integrated into the audio-waveform utility

View File

@@ -0,0 +1,61 @@
# 02. Integrate cavacore library for audio analysis
meta:
id: real-time-audio-visualization-02
feature: real-time-audio-visualization
priority: P0
depends_on: [real-time-audio-visualization-01]
tags: [integration, audio-processing]
objective:
- Create a TypeScript binding for the cavacore C library
- Provide async API for real-time audio frequency analysis
deliverables:
- src/utils/cavacore.ts - TypeScript bindings for cavacore API
- src/utils/audio-visualizer.ts - High-level audio visualizer class
- Updated package.json with FFTW dependency
steps:
- Review cavacore.h API and understand the interface:
- cava_init() - Initialize with parameters
- cava_execute() - Process samples and return frequencies
- cava_destroy() - Clean up
- Create cavacore.ts wrapper with TypeScript types:
- Define C-style structs as TypeScript interfaces
- Create bind() function to load shared library
- Implement async wrappers for init, execute, destroy
- Create audio-visualizer.ts class:
- Handle initialization with configurable parameters (bars, sensitivity, noise reduction)
- Provide execute() method that accepts audio samples and returns frequency data
- Manage cleanup and error handling
- Update package.json:
- Add @types/fftw3 dependency (if available) or document manual installation
- Add build instructions for linking FFTW library
- Test basic initialization and execution with dummy data
tests:
- Unit: Test cavacore initialization with valid parameters
- Unit: Test cavacore execution with sample audio data
- Unit: Test cleanup and memory management
- Integration: Verify no memory leaks after multiple init/destroy cycles
- Integration: Test with actual audio data from ffmpeg
acceptance_criteria:
- cavacore.ts compiles without TypeScript errors
- audio-visualizer.ts can be imported and initialized
- execute() method returns frequency data array
- Proper error handling for missing FFTW library
- No memory leaks in long-running tests
validation:
- Run: `bun run build` to verify TypeScript compilation
- Run: `bun test` for unit tests
- Manual: Test with sample audio file and verify output
notes:
- FFTW library needs to be installed separately on the system
- On macOS: brew install fftw
- On Linux: apt install libfftw3-dev
- The C code will need to be compiled into a shared library (.so/.dylib/.dll)
- For Bun, we can use `Bun.native()` or `Bun.ffi` to call C functions

View File

@@ -0,0 +1,72 @@
# 03. Create audio stream reader for real-time data
meta:
id: real-time-audio-visualization-03
feature: real-time-audio-visualization
priority: P1
depends_on: [real-time-audio-visualization-02]
tags: [audio-stream, real-time]
objective:
- Create a mechanism to read audio stream from mpv backend
- Convert audio data to format suitable for cavacore processing
- Implement efficient buffer management
deliverables:
- src/utils/audio-stream-reader.ts - Audio stream reader class
- src/utils/audio-stream-reader.test.ts - Unit tests
steps:
- Design audio stream reader interface:
- Constructor accepts audio URL and backend (mpv)
- Start() method initiates audio playback and stream capture
- readSamples() method returns next batch of audio samples
- stop() method terminates stream capture
- Implement stream reading for mpv backend:
- Use mpv IPC to query audio device parameters (sample rate, channels)
- Use ffmpeg or similar to pipe audio output to stdin
- Read PCM samples from the stream
- Convert audio samples to appropriate format:
- Handle different bit depths (16-bit, 32-bit)
- Handle different sample rates (44100, 48000, etc.)
- Interleave stereo channels if needed
- Implement buffer management:
- Circular buffer for efficient sample storage
- Non-blocking read with timeout
- Sample rate conversion if needed
- Handle errors:
- Invalid audio URL
- Backend connection failure
- Sample format mismatch
- Create unit tests:
- Mock mpv backend
- Test sample reading
- Test buffer management
- Test error conditions
tests:
- Unit: Test sample rate detection
- Unit: Test channel detection
- Unit: Test sample reading with valid data
- Unit: Test buffer overflow handling
- Unit: Test error handling for invalid audio
- Integration: Test with actual audio file and mpv
- Integration: Test with ffplay backend
acceptance_criteria:
- Audio stream reader successfully reads audio data from mpv
- Samples are converted to 16-bit PCM format
- Buffer management prevents overflow
- Error handling works for invalid audio
- No memory leaks in long-running tests
validation:
- Run: `bun test` for unit tests
- Manual: Play audio and verify stream reader captures data
- Manual: Test with different audio formats (mp3, wav, m4a)
notes:
- mpv can output audio via pipe to stdin using --audio-file-pipe
- Alternative: Use ffmpeg to re-encode audio to standard format
- Sample rate conversion may be needed for cavacore compatibility
- For simplicity, start with 16-bit PCM, single channel (mono)

View File

@@ -0,0 +1,75 @@
# 04. Create realtime waveform component
meta:
id: real-time-audio-visualization-04
feature: real-time-audio-visualization
priority: P1
depends_on: [real-time-audio-visualization-03]
tags: [component, ui]
objective:
- Create a SolidJS component that displays real-time audio visualization
- Integrate audio-visualizer and audio-stream-reader
- Display frequency data as visual waveform bars
deliverables:
- src/components/RealtimeWaveform.tsx - Real-time waveform component
- src/components/RealtimeWaveform.test.tsx - Component tests
steps:
- Create RealtimeWaveform component:
- Accept props: audioUrl, position, duration, isPlaying, onSeek, resolution
- Initialize audio-visualizer with cavacore
- Initialize audio-stream-reader for mpv backend
- Create render loop that:
- Reads audio samples from stream reader
- Passes samples to cavacore execute()
- Gets frequency data back
- Maps frequency data to visual bars
- Renders bars with appropriate colors
- Implement rendering logic:
- Map frequency values to bar heights
- Color-code bars based on intensity
- Handle played vs unplayed portions
- Support click-to-seek
- Create visual style:
- Use terminal block characters for bars
- Apply colors based on frequency bands (bass, mid, treble)
- Add visual flair (gradients, glow effects if possible)
- Implement state management:
- Track current frequency data
- Track playback position
- Handle component lifecycle (cleanup)
- Create unit tests:
- Test component initialization
- Test render loop
- Test click-to-seek
- Test cleanup
tests:
- Unit: Test component props
- Unit: Test frequency data mapping
- Unit: Test visual bar rendering
- Integration: Test with mock audio data
- Integration: Test with actual audio playback
acceptance_criteria:
- Component renders without errors
- Visual bars update in real-time during playback
- Frequency data is correctly calculated from audio samples
- Click-to-seek works
- Component cleans up resources properly
- Visual style matches design requirements
validation:
- Run: `bun test` for unit tests
- Manual: Play audio and verify visualization updates
- Manual: Test seeking and verify visualization follows
- Performance: Monitor frame rate and CPU usage
notes:
- Use SolidJS createEffect for reactive updates
- Keep render loop efficient to maintain 60fps
- Consider debouncing if processing is too heavy
- May need to adjust sample rate for performance
- Visual style should complement existing MergedWaveform design

View File

@@ -0,0 +1,64 @@
# 05. Update Player component to use realtime visualization
meta:
id: real-time-audio-visualization-05
feature: real-time-audio-visualization
priority: P1
depends_on: [real-time-audio-visualization-04]
tags: [integration, player]
objective:
- Replace static waveform display with real-time visualization
- Update Player.tsx to use RealtimeWaveform component
- Ensure seamless transition and proper state management
deliverables:
- Updated src/components/Player.tsx
- Updated src/components/MergedWaveform.tsx (optional, for fallback)
- Documentation of changes
steps:
- Update Player.tsx:
- Import RealtimeWaveform component
- Replace MergedWaveform with RealtimeWaveform
- Pass same props (audioUrl, position, duration, isPlaying, onSeek)
- Remove audioUrl from props if no longer needed
- Test with different audio formats
- Add fallback handling:
- If realtime visualization fails, show static waveform
- Graceful degradation for systems without FFTW
- Update component documentation
- Test all player controls work with new visualization
- Verify keyboard shortcuts still work
- Test seek, pause, resume, volume, speed controls
tests:
- Unit: Test Player with RealtimeWaveform
- Integration: Test complete playback flow
- Integration: Test seek functionality
- Integration: Test pause/resume
- Integration: Test volume and speed changes
- Integration: Test with different audio formats
- Manual: Verify all player features work correctly
acceptance_criteria:
- Player displays real-time visualization during playback
- All player controls work correctly
- Seek functionality works with visualization
- Graceful fallback for systems without FFTW
- No regression in existing functionality
- Visual style matches design requirements
validation:
- Run: `bun run build` to verify compilation
- Run: `bun test` for integration tests
- Manual: Play various audio files
- Manual: Test all keyboard shortcuts
- Performance: Monitor frame rate and CPU usage
notes:
- Keep MergedWaveform as fallback option
- Consider showing a loading state while visualizer initializes
- May need to handle the case where mpv doesn't support audio pipe
- The visualizer should integrate smoothly with existing Player layout
- Consider adding a toggle to switch between static and realtime visualization

View File

@@ -0,0 +1,78 @@
# 06. Add visualizer controls and settings
meta:
id: real-time-audio-visualization-06
feature: real-time-audio-visualization
priority: P2
depends_on: [real-time-audio-visualization-05]
tags: [ui, controls, settings]
objective:
- Add user controls for visualizer settings
- Create settings panel for customization
- Allow users to adjust visualizer parameters
deliverables:
- src/components/VisualizerSettings.tsx - Settings component
- Updated src/components/Player.tsx - Settings panel integration
- src/types/settings.ts - Visualizer settings type definition
- src/stores/settings.ts - Settings state management
steps:
- Define visualizer settings types:
- Number of bars (resolution)
- Sensitivity (autosens toggle + manual value)
- Noise reduction level
- Frequency cutoffs (low/high)
- Bar width and spacing
- Color scheme options
- Create VisualizerSettings component:
- Display current settings
- Allow adjusting each parameter
- Show real-time feedback
- Save settings to app store
- Integrate with Player component:
- Add settings button
- Show settings panel when toggled
- Apply settings to RealtimeWaveform component
- Update settings state management:
- Load saved settings from app store
- Save settings on change
- Provide default values
- Create UI for settings:
- Keyboard shortcuts for quick adjustment
- Visual feedback for changes
- Help text for each setting
- Add settings persistence
- Create tests for settings component
tests:
- Unit: Test settings type definitions
- Unit: Test settings state management
- Unit: Test VisualizerSettings component
- Integration: Test settings apply to visualization
- Integration: Test settings persistence
- Manual: Test all settings controls
acceptance_criteria:
- VisualizerSettings component renders correctly
- All settings can be adjusted
- Changes apply in real-time
- Settings persist between sessions
- Keyboard shortcuts work
- Component handles invalid settings gracefully
validation:
- Run: `bun test` for unit tests
- Run: `bun test` for integration tests
- Manual: Test all settings
- Manual: Test keyboard shortcuts
- Manual: Test settings persistence
notes:
- Settings should have sensible defaults
- Some settings may require visualizer re-initialization
- Consider limiting certain settings to avoid performance issues
- Add tooltips or help text for complex settings
- Settings should be optional - users can start without them
- Keep UI simple and intuitive

View File

@@ -0,0 +1,30 @@
# Real-time Audio Visualization
Objective: Integrate cava library for real-time audio visualization in Player component
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [x] 01 — Copy cavacore library files to project → `01-copy-cavacore-files.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)
- 02 depends on 01
- 03 depends on 02
- 04 depends on 03
- 05 depends on 04
- 06 depends on 05
Exit criteria
- Audio visualization updates in real-time during playback
- Waveform bars respond to actual audio frequencies
- Visualizer controls (sensitivity, bar count) work
- Performance is smooth with 60fps updates
- All necessary cava files are integrated into project
Note: Files from cava/ directory will be removed after integration

58
tests/cavacore-smoke.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
* Run: bun tests/cavacore-smoke.ts
*/
import { dlopen, FFIType, ptr } from "bun:ffi"
import { join } from "path"
const libPath = join(import.meta.dir, "..", "src", "native", "libcavacore.dylib")
const lib = dlopen(libPath, {
cava_init: {
args: [FFIType.i32, FFIType.u32, FFIType.i32, FFIType.i32, FFIType.double, FFIType.i32, FFIType.i32],
returns: FFIType.ptr,
},
cava_execute: {
args: [FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.ptr],
returns: FFIType.void,
},
cava_destroy: {
args: [FFIType.ptr],
returns: FFIType.void,
},
})
const bars = 10
const rate = 44100
const channels = 1
// Init
const plan = lib.symbols.cava_init(bars, rate, channels, 1, 0.77, 50, 10000)
if (!plan) {
console.error("FAIL: cava_init returned null")
process.exit(1)
}
console.log("cava_init OK, plan pointer:", plan)
// Generate a 200Hz sine wave test signal
const bufferSize = 512
const cavaIn = new Float64Array(bufferSize)
const cavaOut = new Float64Array(bars * channels)
for (let k = 0; k < 100; k++) {
for (let n = 0; n < bufferSize; n++) {
cavaIn[n] = Math.sin(2 * Math.PI * 200 / rate * (n + k * bufferSize)) * 20000
}
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan)
}
console.log("cava_execute OK, output:", Array.from(cavaOut).map(v => v.toFixed(3)))
// Check that bar 2 (200Hz) has the peak
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut))
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`)
// Destroy
lib.symbols.cava_destroy(plan)
console.log("cava_destroy OK")
console.log("\nSMOKE TEST PASSED")