Compare commits
4 Commits
0e4f47323f
...
920042ee2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 920042ee2a | |||
| e1dc242b1d | |||
| 8d6b19582c | |||
| 63ded34a6b |
15
build.ts
15
build.ts
@@ -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")
|
||||||
|
|||||||
@@ -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
79
scripts/build-cavacore.sh
Executable 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
|
||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
281
src/components/RealtimeWaveform.tsx
Normal file
281
src/components/RealtimeWaveform.tsx
Normal 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.0–1.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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
141
src/components/VisualizerSettings.tsx
Normal file
141
src/components/VisualizerSettings.tsx
Normal 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
BIN
src/native/libcavacore.dylib
Executable file
Binary file not shown.
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,11 +46,25 @@ export type DesktopTheme = {
|
|||||||
tokens: ThemeToken
|
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 = {
|
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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
249
src/utils/audio-stream-reader.ts
Normal file
249
src/utils/audio-stream-reader.ts
Normal 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.5–100.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(",")
|
||||||
|
}
|
||||||
@@ -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
230
src/utils/cavacore.ts
Normal 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.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<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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
30
tasks/real-time-audio-visualization/README.md
Normal file
30
tasks/real-time-audio-visualization/README.md
Normal 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
58
tests/cavacore-smoke.ts
Normal 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")
|
||||||
Reference in New Issue
Block a user