actual featured page
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([]);
|
||||
// Frequency bar values (0.0–1.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (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;
|
||||
/** Number of frequency bars (8–128, default: 64) */
|
||||
bars: number;
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
sensitivity: number;
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction: number;
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff: number;
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff: number;
|
||||
};
|
||||
|
||||
export type AppSettings = {
|
||||
theme: ThemeName;
|
||||
fontSize: number;
|
||||
playbackSpeed: number;
|
||||
downloadPath: string;
|
||||
visualizer: VisualizerSettings;
|
||||
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";
|
||||
|
||||
@@ -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.5–100.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.5–100.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(",");
|
||||
}
|
||||
|
||||
@@ -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.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
|
||||
/** 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;
|
||||
/** 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.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")
|
||||
}
|
||||
/**
|
||||
* 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)
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user