actual featured page

This commit is contained in:
2026-08-07 18:59:25 -04:00
parent 0cc15c8d90
commit 64d8b40e61
9 changed files with 1321 additions and 847 deletions

View File

@@ -65,6 +65,12 @@ function DiscoverPage() {
};
onMount(ensureFocus);
// Auto-fetch the featured-shows manifest on first mount (network failure is
// non-fatal — the list stays empty until the user hits refresh).
onMount(() => {
discoverStore.refresh().catch(() => {});
});
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id;
@@ -243,7 +249,9 @@ function DiscoverPage() {
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text fg={index() === lf() ? theme.surface : theme.success}>
<text
fg={index() === lf() ? theme.surface : theme.success}
>
[+]
</text>
</Show>

View File

@@ -9,9 +9,9 @@
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { useAudio } from "@/hooks/useAudio";
@@ -20,20 +20,20 @@ import { useTheme } from "@/context/ThemeContext";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
/** Target frame interval in ms (~30 fps) */
@@ -45,212 +45,221 @@ const SAMPLES_PER_FRAME = 512;
// ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
const { theme } = useTheme();
const audio = useAudio();
const { theme } = useTheme();
const audio = useAudio();
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// Track whether cavacore is available
const [available, setAvailable] = createSignal(false);
// 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;
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 ──────────────────────────────────
// Bar count comes from the visualizer config (set in Settings); default 64.
// Single source of truth used for cavacore init, rendering, and seek clicks.
const numBars = () => props.visualizerConfig?.bars ?? 64;
const initCava = () => {
if (cava) return true;
// ── Lifecycle: init cavacore once ──────────────────────────────────
cava = loadCavaCore();
if (!cava) {
setAvailable(false);
return false;
}
const initCava = () => {
if (cava) return true;
setAvailable(true);
return true;
};
cava = loadCavaCore();
if (!cava) {
setAvailable(false);
return false;
}
// ── Start/stop the visualization pipeline ──────────────────────────
setAvailable(true);
return true;
};
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
// ── Start/stop the visualization pipeline ──────────────────────────
if (!url || !initCava() || !cava) return;
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
// Initialize cavacore with current resolution + any overrides
const config: CavaCoreConfig = {
bars: 32,
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
};
cava.init(config);
if (!url || !initCava() || !cava) return;
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Initialize cavacore with current resolution + any overrides
const config: CavaCoreConfig = {
bars: numBars(),
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
};
cava.init(config);
// 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);
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
// 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);
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;
};
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
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;
};
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// ── Render loop (called at ~30fps) ─────────────────────────────────
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer);
if (count === 0) return;
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Feed samples to cavacore → get frequency bars
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer);
if (count === 0) return;
// Copy bar values to a new array for the signal
setBarData(Array.from(output));
};
// Feed samples to cavacore → get frequency bars
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
createEffect(
on(
[
audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here
audio.speed,
() => 32,
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(audio.position);
startVisualization(url, pos, speed);
} else {
stopVisualization();
}
},
),
);
// Copy bar values to a new array for the signal
setBarData(Array.from(output));
};
// ── 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.
createEffect(
on(
[
audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "",
audio.speed,
numBars,
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(audio.position);
startVisualization(url, pos, speed);
} else {
stopVisualization();
}
},
),
);
let lastSyncPosition = 0;
createEffect(
on(audio.position, (pos) => {
if (!audio.isPlaying || !reader?.running) {
lastSyncPosition = pos;
return;
}
// ── 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.
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
let lastSyncPosition = 0;
createEffect(
on(audio.position, (pos) => {
if (!audio.isPlaying || !reader?.running) {
lastSyncPosition = pos;
return;
}
if (delta > 2) {
reader.restart(pos, audio.speed() ?? 1);
}
}),
);
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
// 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();
}
});
if (delta > 2) {
reader.restart(pos, audio.speed() ?? 1);
}
}),
);
// ── Rendering ──────────────────────────────────────────────────────
// 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();
}
});
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
// ── Rendering ──────────────────────────────────────────────────────
const renderLine = () => {
const bars = barData();
const numBars = 32;
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
// 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 renderLine = () => {
const bars = barData();
const count = numBars();
const played = Math.floor(numBars * playedRatio());
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
// If no data yet, show empty placeholder
if (bars.length === 0) {
const placeholder = ".".repeat(count);
return (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text>
</box>
);
}
const playedChars = bars
.slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const played = Math.floor(count * playedRatio());
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const futureChars = bars
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const playedChars = bars
.slice(0, 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 futureChars = bars
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const handleClick = (event: { x: number }) => {
const numBars = 32;
const ratio = event.x / numBars;
const next = Math.max(
0,
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
);
audio.seek(next);
};
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text>
</box>
);
};
return (
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
);
}
const handleClick = (event: { x: number }) => {
const count = numBars();
const ratio = event.x / count;
const next = Math.max(
0,
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
);
audio.seek(next);
};
return (
<box
border
borderColor={theme.border}
padding={1}
onMouseDown={handleClick}
>
{renderLine()}
</box>
);
}

View File

@@ -1,130 +1,130 @@
import { createSignal } from "solid-js";
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
import type {
AppSettings,
AppState,
ThemeColors,
ThemeName,
ThemeMode,
UserPreferences,
VisualizerSettings,
AppSettings,
AppState,
ThemeColors,
ThemeName,
ThemeMode,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { resolveTheme } from "../utils/theme-resolver";
import type { ThemeJson } from "../types/theme-schema";
import {
loadAppStateFromFile,
saveAppStateToFile,
loadAppStateFromFile,
saveAppStateToFile,
} from "../utils/app-persistence";
const defaultVisualizerSettings: VisualizerSettings = {
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
bars: 64,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
};
const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
visualizer: defaultVisualizerSettings,
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
showExplicit: false,
autoDownload: false,
};
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
};
export function createAppStore() {
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState);
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState);
// Fire-and-forget async initialisation
const init = async () => {
const loaded = await loadAppStateFromFile();
setState(loaded);
};
init();
// Fire-and-forget async initialisation
const init = async () => {
const loaded = await loadAppStateFromFile();
setState(loaded);
};
init();
const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {});
};
const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {});
};
const updateState = (next: AppState) => {
setState(next);
saveState(next);
};
const updateState = (next: AppState) => {
setState(next);
saveState(next);
};
const updateSettings = (updates: Partial<AppSettings>) => {
const next = {
...state(),
settings: { ...state().settings, ...updates },
};
updateState(next);
};
const updateSettings = (updates: Partial<AppSettings>) => {
const next = {
...state(),
settings: { ...state().settings, ...updates },
};
updateState(next);
};
const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = {
...state(),
preferences: { ...state().preferences, ...updates },
};
updateState(next);
};
const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = {
...state(),
preferences: { ...state().preferences, ...updates },
};
updateState(next);
};
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
const next = {
...state(),
customTheme: { ...state().customTheme, ...updates },
};
updateState(next);
};
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
const next = {
...state(),
customTheme: { ...state().customTheme, ...updates },
};
updateState(next);
};
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({
visualizer: { ...state().settings.visualizer, ...updates },
});
};
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({
visualizer: { ...state().settings.visualizer, ...updates },
});
};
const setTheme = (theme: ThemeName) => {
updateSettings({ theme });
};
const setTheme = (theme: ThemeName) => {
updateSettings({ theme });
};
const resolveThemeColors = (): ThemeColors => {
const theme = state().settings.theme;
if (theme === "custom") return state().customTheme;
if (theme === "system") return DEFAULT_THEME;
const json = THEME_JSON[theme];
if (!json) return DEFAULT_THEME;
return resolveTheme(
json as ThemeJson,
"dark" as ThemeMode,
) as unknown as ThemeColors;
};
const resolveThemeColors = (): ThemeColors => {
const theme = state().settings.theme;
if (theme === "custom") return state().customTheme;
if (theme === "system") return DEFAULT_THEME;
const json = THEME_JSON[theme];
if (!json) return DEFAULT_THEME;
return resolveTheme(
json as ThemeJson,
"dark" as ThemeMode,
) as unknown as ThemeColors;
};
return {
state,
updateSettings,
updatePreferences,
updateCustomTheme,
updateVisualizer,
setTheme,
resolveTheme: resolveThemeColors,
};
return {
state,
updateSettings,
updatePreferences,
updateCustomTheme,
updateVisualizer,
setTheme,
resolveTheme: resolveThemeColors,
};
}
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
export function useAppStore() {
if (!appStoreInstance) {
appStoreInstance = createAppStore();
}
return appStoreInstance;
if (!appStoreInstance) {
appStoreInstance = createAppStore();
}
return appStoreInstance;
}

View File

@@ -1,6 +1,12 @@
/**
* Discover store for PodTUI
* Manages trending/popular podcasts and category filtering
* Manages trending/popular podcasts and category filtering.
*
* The featured-shows list is fetched at runtime from a JSON file hosted in the
* GitHub repo (discover/featured.json on the `master` branch), so the list
* can be updated without shipping a new release. The feed URL, de-duped set,
* and version field act as the cache key — a fresh fetch only happens when the
* version bumps or the cache window (24h) expires.
*/
import { createSignal } from "solid-js";
@@ -27,125 +33,113 @@ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "arts", name: "Arts", icon: "@" },
];
/** Mock trending podcasts */
const TRENDING_PODCASTS: Podcast[] = [
{
id: "trend-1",
title: "AI Today",
description:
"The latest developments in artificial intelligence, machine learning, and their impact on society.",
feedUrl: "https://example.com/aitoday.rss",
author: "Tech Futures",
categories: ["Technology", "Science"],
// ── Remote featured-shows manifest ───────────────────────────────────────────
// The raw GitHub URL serving discover/featured.json from the master branch.
// Update this file in the repo (no release needed) to refresh the list.
const FEATURED_JSON_URL =
"https://raw.githubusercontent.com/mikefreno/PodTui/master/discover/featured.json";
/** Cache window for the remote featured list (24 hours) */
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** Shape of a single entry in the remote JSON */
interface FeaturedEntry {
id: string;
title: string;
description: string;
feedUrl: string;
author?: string;
categories?: string[];
}
/** Shape of the remote JSON manifest */
interface FeaturedManifest {
version: number;
podcasts: FeaturedEntry[];
}
/** Convert a JSON entry to a runtime Podcast (adding derived fields) */
function entryToPodcast(entry: FeaturedEntry): Podcast {
return {
id: entry.id,
title: entry.title,
description: entry.description,
feedUrl: entry.feedUrl,
author: entry.author,
categories: entry.categories ?? [],
coverUrl: undefined,
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-2",
title: "The History Hour",
description:
"Fascinating stories from history that shaped our world today.",
feedUrl: "https://example.com/historyhour.rss",
author: "History Channel",
categories: ["Education", "History"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-3",
title: "Comedy Gold",
description:
"Weekly stand-up comedy, sketches, and hilarious conversations.",
feedUrl: "https://example.com/comedygold.rss",
author: "Laugh Factory",
categories: ["Comedy", "Entertainment"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-4",
title: "Market Watch",
description: "Daily financial news, stock analysis, and investing tips.",
feedUrl: "https://example.com/marketwatch.rss",
author: "Finance Daily",
categories: ["Business", "News"],
lastUpdated: new Date(),
isSubscribed: true,
},
{
id: "trend-5",
title: "Science Weekly",
description:
"Breaking science news and in-depth analysis of the latest research.",
feedUrl: "https://example.com/scienceweekly.rss",
author: "Science Network",
categories: ["Science", "Education"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-6",
title: "True Crime Files",
description:
"Investigative journalism into real criminal cases and unsolved mysteries.",
feedUrl: "https://example.com/truecrime.rss",
author: "Crime Network",
categories: ["True Crime", "Documentary"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-7",
title: "Wellness Journey",
description:
"Tips for mental and physical health, meditation, and mindful living.",
feedUrl: "https://example.com/wellness.rss",
author: "Health Media",
categories: ["Health", "Self-Help"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-8",
title: "Sports Talk Live",
description:
"Live commentary, analysis, and interviews from the world of sports.",
feedUrl: "https://example.com/sportstalk.rss",
author: "Sports Network",
categories: ["Sports", "News"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-9",
title: "Creative Minds",
description:
"Interviews with artists, designers, and creative professionals.",
feedUrl: "https://example.com/creativeminds.rss",
author: "Arts Weekly",
categories: ["Arts", "Culture"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-10",
title: "Dev Talk",
description:
"Software development, programming tutorials, and tech career advice.",
feedUrl: "https://example.com/devtalk.rss",
author: "Code Academy",
categories: ["Technology", "Education"],
lastUpdated: new Date(),
isSubscribed: true,
},
];
};
}
/** Reconcile isSubscribed state across the discover list against the feed store */
function syncSubscriptionState(
podcasts: Podcast[],
subscribedUrls: Set<string>,
subscribedIds: Set<string>,
): Podcast[] {
return podcasts.map((p) => ({
...p,
isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
}));
}
/** Create discover store */
export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
let cachedAt = 0;
/** Reconcile local isSubscribed flags with the feed store */
const syncSubscriptions = () => {
const feedStore = useFeedStore();
const feeds = feedStore.feeds();
const urls = new Set(feeds.map((f) => f.podcast.feedUrl));
const ids = new Set(feeds.map((f) => f.podcast.id));
setPodcasts((prev) => syncSubscriptionState(prev, urls, ids));
};
/** Fetch the featured-shows manifest from GitHub if stale */
const refresh = async () => {
setIsLoading(true);
try {
// Skip if cache is still fresh
const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions();
return;
}
const resp = await fetch(FEATURED_JSON_URL, {
headers: { "User-Agent": "PodTUI/1.0" },
});
if (!resp.ok) {
syncSubscriptions();
return;
}
const manifest = (await resp.json()) as FeaturedManifest;
if (!manifest?.podcasts?.length) {
syncSubscriptions();
return;
}
// Build the podcast list from the manifest entries
const fetched = manifest.podcasts.map(entryToPodcast);
cachedAt = now;
setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions();
} catch {
// Network failure — keep whatever we have (stale or empty)
} finally {
setIsLoading(false);
}
};
/** Get filtered podcasts by category */
const filteredPodcasts = () => {
@@ -198,15 +192,6 @@ export function createDiscoverStore() {
}
};
/** Refresh trending podcasts (mock) */
const refresh = async () => {
setIsLoading(true);
// Simulate network delay
await new Promise((r) => setTimeout(r, 500));
// In real app, would fetch from API
setIsLoading(false);
};
return {
// State
selectedCategory,

View File

@@ -2,95 +2,95 @@ import type { RGBA } from "@opentui/core";
import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
export type ThemeName =
| "system"
| "catppuccin"
| "gruvbox"
| "tokyo"
| "nord"
| "custom";
| "system"
| "catppuccin"
| "gruvbox"
| "tokyo"
| "nord"
| "custom";
export type LayerBackgrounds = {
layer0: ColorValue;
layer1: ColorValue;
layer2: ColorValue;
layer3: ColorValue;
layer0: ColorValue;
layer1: ColorValue;
layer2: ColorValue;
layer3: ColorValue;
};
export type ThemeColors = {
background: ColorValue;
surface: ColorValue;
primary: ColorValue;
secondary: ColorValue;
accent: ColorValue;
text: ColorValue;
textPrimary?: ColorValue;
textSecondary?: ColorValue;
textTertiary?: ColorValue;
textSelectedPrimary?: ColorValue;
textSelectedSecondary?: ColorValue;
textSelectedTertiary?: ColorValue;
muted: ColorValue;
warning: ColorValue;
error: ColorValue;
success: ColorValue;
layerBackgrounds?: LayerBackgrounds;
_hasSelectedListItemText?: boolean;
thinkingOpacity?: number;
selectedListItemText?: ColorValue;
background: ColorValue;
surface: ColorValue;
primary: ColorValue;
secondary: ColorValue;
accent: ColorValue;
text: ColorValue;
textPrimary?: ColorValue;
textSecondary?: ColorValue;
textTertiary?: ColorValue;
textSelectedPrimary?: ColorValue;
textSelectedSecondary?: ColorValue;
textSelectedTertiary?: ColorValue;
muted: ColorValue;
warning: ColorValue;
error: ColorValue;
success: ColorValue;
layerBackgrounds?: LayerBackgrounds;
_hasSelectedListItemText?: boolean;
thinkingOpacity?: number;
selectedListItemText?: ColorValue;
};
export type ThemeVariant = {
name: string;
colors: ThemeColors;
name: string;
colors: ThemeColors;
};
export type ThemeToken = {
[key: string]: string;
[key: string]: string;
};
export type ResolvedTheme = Record<string, RGBA> & {
layerBackgrounds: Record<string, RGBA>;
_hasSelectedListItemText: boolean;
thinkingOpacity: number;
layerBackgrounds: Record<string, RGBA>;
_hasSelectedListItemText: boolean;
thinkingOpacity: number;
};
export type DesktopTheme = {
name: string;
variants: ThemeVariant[];
defaultVariant: string;
tokens: ThemeToken;
name: string;
variants: ThemeVariant[];
defaultVariant: string;
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;
/** Number of frequency bars (8128, default: 64) */
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 = {
theme: ThemeName;
fontSize: number;
playbackSpeed: number;
downloadPath: string;
visualizer: VisualizerSettings;
theme: ThemeName;
fontSize: number;
playbackSpeed: number;
downloadPath: string;
visualizer: VisualizerSettings;
};
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
showExplicit: boolean;
autoDownload: boolean;
};
export type AppState = {
settings: AppSettings;
preferences: UserPreferences;
customTheme: ThemeColors;
settings: AppSettings;
preferences: UserPreferences;
customTheme: ThemeColors;
};
export type ThemeMode = "dark" | "light";

View File

@@ -11,18 +11,18 @@
*/
/** PCM output format constants */
const SAMPLE_RATE = 44100
const CHANNELS = 1
const BYTES_PER_SAMPLE = 2 // s16le
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
const RING_BUFFER_SAMPLES = SAMPLE_RATE;
export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */
url: string
/** Sample rate (default: 44100) */
sampleRate?: number
/** Audio URL or file path to decode */
url: string;
/** Sample rate (default: 44100) */
sampleRate?: number;
}
/**
@@ -30,202 +30,232 @@ export interface AudioStreamReaderOptions {
* Each start() increments this; the read loop checks it to know
* if it's been superseded and should bail out.
*/
let globalGeneration = 0
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
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)
}
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
}
/** 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
}
/** 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()
/**
* 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")
}
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
// 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",
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
]
const args = [
"ffmpeg",
"-loglevel",
"quiet",
// Read input at native frame rate so decoded PCM stays in sync with
// real-time playback. Without -re, ffmpeg greedily decodes the whole
// file as fast as possible: the ring buffer fills with audio seconds
// ahead of the player (laggy bars), then the process exits when it
// hits EOF (bars freeze ~10s in).
"-re",
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
];
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition))
}
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition));
}
args.push("-i", this.url)
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) {
args.push("-af", buildAtempoChain(speed))
}
// Apply speed via atempo filter if not 1x.
// ffmpeg atempo only supports 0.5100.0; chain multiple for extremes.
if (speed !== 1 && speed > 0) {
args.push("-af", buildAtempoChain(speed));
}
args.push(
"-ac", String(CHANNELS),
"-ar", String(this.sampleRate),
"-f", "s16le",
"-acodec", "pcm_s16le",
"-",
)
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.proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
});
this._running = true
this.writePos = 0
this.totalSamplesWritten = 0
this._running = true;
this.writePos = 0;
this.totalSamplesWritten = 0;
// Capture generation for this run
const myGeneration = this.generation
// Capture generation for this run
const myGeneration = this.generation;
// Start async reading loop
this.readLoop(myGeneration)
// 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
}
})
}
// 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 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
// 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)
}
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
}
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
}
/**
* 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)
}
/**
* 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
}
}
/** 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
/** 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 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 sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
if (sampleCount === 0) continue;
const int16View = new Int16Array(
value.buffer,
value.byteOffset,
sampleCount,
)
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 */ }
}
}
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 */
}
}
}
}
/**
@@ -234,18 +264,18 @@ export class AudioStreamReader {
* 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))
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}`)
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(",")
return parts.join(",");
}

View File

@@ -16,170 +16,178 @@
* ```
*/
import { dlopen, FFIType, ptr } from "bun:ffi"
import { existsSync } from "fs"
import { join, dirname } from "path"
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
/** 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;
/** Output scaling mode: 0 = linear (default), 1 = decibel */
scalingMode?: number;
}
const DEFAULTS: Required<CavaCoreConfig> = {
bars: 32,
sampleRate: 44100,
channels: 1,
autosens: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
}
bars: 32,
sampleRate: 44100,
channels: 1,
autosens: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void }
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"
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),
]
// 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
}
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null
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
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
}
/** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars
}
/** 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
}
/** 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()
}
/**
* 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
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,
)
this.plan = this.lib.symbols.cava_init(
cfg.bars,
cfg.sampleRate,
cfg.channels,
cfg.autosens,
cfg.noiseReduction,
cfg.lowCutOff,
cfg.highCutOff,
cfg.scalingMode,
);
if (!this.plan) {
throw new Error("cava_init returned null — initialization failed")
}
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
}
// 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")
}
/**
* 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)
// 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,
)
this.lib.symbols.cava_execute(
ptr(this.inputBuffer),
samples.length,
ptr(this.outputBuffer),
this.plan,
);
return this.outputBuffer
}
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
}
/**
* 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 ──────────────────────────────────────────────────────────
@@ -190,41 +198,42 @@ export class CavaCore {
* to the static waveform display.
*/
export function loadCavaCore(): CavaCore | null {
try {
const libPath = findLibrary()
if (!libPath) return 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,
},
})
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
FFIType.i32, // scaling_mode
],
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
}
return new CavaCore(lib as CavaLib);
} catch {
// Library load failed — missing dylib, wrong arch, etc.
return null;
}
}