better visualizer
This commit is contained in:
@@ -24,8 +24,8 @@ type MergedWaveformProps = {
|
||||
onSeek?: (seconds: number) => void;
|
||||
};
|
||||
|
||||
/** Block characters for waveform amplitude levels */
|
||||
const BARS = [".", "-", "~", "=", "#"];
|
||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
||||
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
||||
|
||||
export function MergedWaveform(props: MergedWaveformProps) {
|
||||
const resolution = () => props.resolution ?? 64;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { PlaybackControls } from "./PlaybackControls"
|
||||
import { MergedWaveform } from "./MergedWaveform"
|
||||
import { RealtimeWaveform, isCavacoreAvailable } from "./RealtimeWaveform"
|
||||
import { useAudio } from "../hooks/useAudio"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import type { Episode } from "../types/episode"
|
||||
|
||||
type PlayerProps = {
|
||||
@@ -97,13 +99,32 @@ export function Player(props: PlayerProps) {
|
||||
</text>
|
||||
<text fg="gray">{episode().description}</text>
|
||||
|
||||
<MergedWaveform
|
||||
audioUrl={episode().audioUrl}
|
||||
position={audio.position()}
|
||||
duration={dur()}
|
||||
isPlaying={audio.isPlaying()}
|
||||
onSeek={(next: number) => audio.seek(next)}
|
||||
/>
|
||||
{isCavacoreAvailable() ? (
|
||||
<RealtimeWaveform
|
||||
audioUrl={episode().audioUrl}
|
||||
position={audio.position()}
|
||||
duration={dur()}
|
||||
isPlaying={audio.isPlaying()}
|
||||
onSeek={(next: number) => audio.seek(next)}
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer
|
||||
return {
|
||||
bars: viz.bars,
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
) : (
|
||||
<MergedWaveform
|
||||
audioUrl={episode().audioUrl}
|
||||
position={audio.position()}
|
||||
duration={dur()}
|
||||
isPlaying={audio.isPlaying()}
|
||||
onSeek={(next: number) => audio.seek(next)}
|
||||
/>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<PlaybackControls
|
||||
|
||||
263
src/components/RealtimeWaveform.tsx
Normal file
263
src/components/RealtimeWaveform.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
||||
*
|
||||
* Replaces MergedWaveform during playback. Spawns an independent ffmpeg
|
||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
||||
* for FFT analysis, and renders frequency bars as colored terminal
|
||||
* characters at ~30fps.
|
||||
*
|
||||
* Falls back gracefully if cavacore is unavailable (loadCavaCore returns null).
|
||||
* Same prop interface as MergedWaveform for drop-in replacement.
|
||||
*/
|
||||
|
||||
import { createSignal, createEffect, onCleanup, on } from "solid-js"
|
||||
import { loadCavaCore, type CavaCore, type CavaCoreConfig } from "../utils/cavacore"
|
||||
import { AudioStreamReader } from "../utils/audio-stream-reader"
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type RealtimeWaveformProps = {
|
||||
/** Audio URL — used to start the ffmpeg decode stream */
|
||||
audioUrl: string
|
||||
/** Current playback position in seconds */
|
||||
position: number
|
||||
/** Total duration in seconds */
|
||||
duration: number
|
||||
/** Whether audio is currently playing */
|
||||
isPlaying: boolean
|
||||
/** Number of frequency bars / columns */
|
||||
resolution?: number
|
||||
/** Callback when user clicks to seek */
|
||||
onSeek?: (seconds: number) => void
|
||||
/** Visualizer configuration overrides */
|
||||
visualizerConfig?: Partial<CavaCoreConfig>
|
||||
}
|
||||
|
||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
||||
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"]
|
||||
|
||||
/** Target frame interval in ms (~30 fps) */
|
||||
const FRAME_INTERVAL = 33
|
||||
|
||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||
const SAMPLES_PER_FRAME = 512
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
const resolution = () => props.resolution ?? 32
|
||||
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([])
|
||||
|
||||
// Track whether cavacore is available
|
||||
const [available, setAvailable] = createSignal(false)
|
||||
|
||||
let cava: CavaCore | null = null
|
||||
let reader: AudioStreamReader | null = null
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sampleBuffer: Float64Array | null = null
|
||||
|
||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||
|
||||
const initCava = () => {
|
||||
if (cava) return true
|
||||
|
||||
cava = loadCavaCore()
|
||||
if (!cava) {
|
||||
setAvailable(false)
|
||||
return false
|
||||
}
|
||||
|
||||
setAvailable(true)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||
|
||||
const startVisualization = (url: string, position: number) => {
|
||||
stopVisualization()
|
||||
|
||||
if (!url || !initCava() || !cava) return
|
||||
|
||||
// Initialize cavacore with current resolution + any overrides
|
||||
const config: CavaCoreConfig = {
|
||||
bars: resolution(),
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
...props.visualizerConfig,
|
||||
}
|
||||
cava.init(config)
|
||||
|
||||
// Pre-allocate sample read buffer
|
||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME)
|
||||
|
||||
// Start ffmpeg decode stream
|
||||
reader = new AudioStreamReader({ url })
|
||||
reader.start(position)
|
||||
|
||||
// Start render loop
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL)
|
||||
}
|
||||
|
||||
const stopVisualization = () => {
|
||||
if (frameTimer) {
|
||||
clearInterval(frameTimer)
|
||||
frameTimer = null
|
||||
}
|
||||
if (reader) {
|
||||
reader.stop()
|
||||
reader = null
|
||||
}
|
||||
if (cava?.isReady) {
|
||||
cava.destroy()
|
||||
}
|
||||
sampleBuffer = null
|
||||
}
|
||||
|
||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return
|
||||
|
||||
// Read available PCM samples from the stream
|
||||
const count = reader.read(sampleBuffer)
|
||||
if (count === 0) return
|
||||
|
||||
// Feed samples to cavacore → get frequency bars
|
||||
const input = count < sampleBuffer.length
|
||||
? sampleBuffer.subarray(0, count)
|
||||
: sampleBuffer
|
||||
const output = cava.execute(input)
|
||||
|
||||
// Copy bar values to a new array for the signal
|
||||
setBarData(Array.from(output))
|
||||
}
|
||||
|
||||
// ── Reactive effects: respond to prop changes ──────────────────────
|
||||
|
||||
// Start/stop based on isPlaying and audioUrl
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.isPlaying, props.audioUrl] as const,
|
||||
([playing, url]) => {
|
||||
if (playing && url) {
|
||||
startVisualization(url, props.position)
|
||||
} else {
|
||||
stopVisualization()
|
||||
// Keep last bar data visible (freeze frame) when paused
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Handle seeks: restart the ffmpeg stream at the new position
|
||||
// We track position and restart only on significant jumps (>2s delta)
|
||||
let lastSyncPosition = 0
|
||||
createEffect(
|
||||
on(
|
||||
() => props.position,
|
||||
(pos) => {
|
||||
if (!props.isPlaying || !reader?.running) return
|
||||
|
||||
const delta = Math.abs(pos - lastSyncPosition)
|
||||
// Only restart on seeks (>2s jump), not normal playback drift
|
||||
if (delta > 2) {
|
||||
reader.restart(pos)
|
||||
lastSyncPosition = pos
|
||||
} else {
|
||||
lastSyncPosition = pos
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Re-init cavacore if resolution changes
|
||||
createEffect(
|
||||
on(resolution, (bars) => {
|
||||
if (props.isPlaying && props.audioUrl && cava) {
|
||||
// Restart with new bar count
|
||||
startVisualization(props.audioUrl, props.position)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
stopVisualization()
|
||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
||||
if (cava?.isReady) {
|
||||
cava.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
const playedRatio = () =>
|
||||
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration)
|
||||
|
||||
const renderLine = () => {
|
||||
const bars = barData()
|
||||
const numBars = resolution()
|
||||
|
||||
// If no data yet, show empty placeholder
|
||||
if (bars.length === 0) {
|
||||
const placeholder = ".".repeat(numBars)
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg="#3b4252">{placeholder}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const played = Math.floor(numBars * playedRatio())
|
||||
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
|
||||
const futureColor = "#3b4252"
|
||||
|
||||
const playedChars = bars
|
||||
.slice(0, played)
|
||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||
.join("")
|
||||
|
||||
const futureChars = bars
|
||||
.slice(played)
|
||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||
.join("")
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg={playedColor}>{playedChars || " "}</text>
|
||||
<text fg={futureColor}>{futureChars || " "}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = (event: { x: number }) => {
|
||||
const numBars = resolution()
|
||||
const ratio = numBars === 0 ? 0 : event.x / numBars
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(props.duration, Math.round(props.duration * ratio)),
|
||||
)
|
||||
props.onSeek?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<box border padding={1} onMouseDown={handleClick}>
|
||||
{renderLine()}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cavacore is available on this system.
|
||||
* Useful for deciding whether to show RealtimeWaveform or MergedWaveform.
|
||||
*/
|
||||
let _cavacoreAvailable: boolean | null = null
|
||||
export function isCavacoreAvailable(): boolean {
|
||||
if (_cavacoreAvailable === null) {
|
||||
const cava = loadCavaCore()
|
||||
_cavacoreAvailable = cava !== null
|
||||
}
|
||||
return _cavacoreAvailable
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { SourceManager } from "./SourceManager"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { PreferencesPanel } from "./PreferencesPanel"
|
||||
import { SyncPanel } from "./SyncPanel"
|
||||
import { VisualizerSettings } from "./VisualizerSettings"
|
||||
|
||||
type SettingsScreenProps = {
|
||||
accountLabel: string
|
||||
@@ -12,12 +13,13 @@ type SettingsScreenProps = {
|
||||
onExit?: () => void
|
||||
}
|
||||
|
||||
type SectionId = "sync" | "sources" | "preferences" | "account"
|
||||
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account"
|
||||
|
||||
const SECTIONS: Array<{ id: SectionId; label: string }> = [
|
||||
{ id: "sync", label: "Sync" },
|
||||
{ id: "sources", label: "Sources" },
|
||||
{ id: "preferences", label: "Preferences" },
|
||||
{ id: "visualizer", label: "Visualizer" },
|
||||
{ id: "account", label: "Account" },
|
||||
]
|
||||
|
||||
@@ -43,7 +45,8 @@ export function SettingsScreen(props: SettingsScreenProps) {
|
||||
if (key.name === "1") setActiveSection("sync")
|
||||
if (key.name === "2") setActiveSection("sources")
|
||||
if (key.name === "3") setActiveSection("preferences")
|
||||
if (key.name === "4") setActiveSection("account")
|
||||
if (key.name === "4") setActiveSection("visualizer")
|
||||
if (key.name === "5") setActiveSection("account")
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -52,7 +55,7 @@ export function SettingsScreen(props: SettingsScreenProps) {
|
||||
<text>
|
||||
<strong>Settings</strong>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>[Tab] Switch section | 1-4 jump | Esc up</text>
|
||||
<text fg={theme.textMuted}>[Tab] Switch section | 1-5 jump | Esc up</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -76,6 +79,7 @@ export function SettingsScreen(props: SettingsScreenProps) {
|
||||
{activeSection() === "sync" && <SyncPanel />}
|
||||
{activeSection() === "sources" && <SourceManager focused />}
|
||||
{activeSection() === "preferences" && <PreferencesPanel />}
|
||||
{activeSection() === "visualizer" && <VisualizerSettings />}
|
||||
{activeSection() === "account" && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.textMuted}>Account</text>
|
||||
|
||||
141
src/components/VisualizerSettings.tsx
Normal file
141
src/components/VisualizerSettings.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* VisualizerSettings — settings panel for the real-time audio visualizer.
|
||||
*
|
||||
* Allows adjusting bar count, noise reduction, sensitivity, and
|
||||
* frequency cutoffs. All changes persist via the app store.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { isCavacoreAvailable } from "./RealtimeWaveform"
|
||||
|
||||
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut"
|
||||
|
||||
const FIELDS: FocusField[] = ["bars", "sensitivity", "noise", "lowCut", "highCut"]
|
||||
|
||||
export function VisualizerSettings() {
|
||||
const appStore = useAppStore()
|
||||
const { theme } = useTheme()
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("bars")
|
||||
|
||||
const viz = () => appStore.state().settings.visualizer
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const idx = FIELDS.indexOf(focusField())
|
||||
const next = key.shift
|
||||
? (idx - 1 + FIELDS.length) % FIELDS.length
|
||||
: (idx + 1) % FIELDS.length
|
||||
setFocusField(FIELDS[next])
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1)
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1)
|
||||
}
|
||||
}
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField()
|
||||
const v = viz()
|
||||
|
||||
switch (field) {
|
||||
case "bars": {
|
||||
// Step by 8: 8, 16, 24, 32, ..., 128
|
||||
const next = Math.min(128, Math.max(8, v.bars + delta * 8))
|
||||
appStore.updateVisualizer({ bars: next })
|
||||
break
|
||||
}
|
||||
case "sensitivity": {
|
||||
// Toggle: 0 (manual) or 1 (auto)
|
||||
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 })
|
||||
break
|
||||
}
|
||||
case "noise": {
|
||||
// Step by 0.05: 0.0 – 1.0
|
||||
const next = Math.min(1, Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))))
|
||||
appStore.updateVisualizer({ noiseReduction: next })
|
||||
break
|
||||
}
|
||||
case "lowCut": {
|
||||
// Step by 10: 20 – 500 Hz
|
||||
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10))
|
||||
appStore.updateVisualizer({ lowCutOff: next })
|
||||
break
|
||||
}
|
||||
case "highCut": {
|
||||
// Step by 500: 1000 – 20000 Hz
|
||||
const next = Math.min(20000, Math.max(1000, v.highCutOff + delta * 500))
|
||||
appStore.updateVisualizer({ highCutOff: next })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKey)
|
||||
|
||||
const cavacoreStatus = isCavacoreAvailable()
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.textMuted}>Visualizer</text>
|
||||
|
||||
{!cavacoreStatus && (
|
||||
<text fg={theme.warning}>
|
||||
cavacore not available — using static waveform
|
||||
</text>
|
||||
)}
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>Bars:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().bars}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "sensitivity" ? theme.primary : theme.textMuted}>Auto Sensitivity:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}>
|
||||
{viz().sensitivity === 1 ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>Noise Reduction:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}>Low Cutoff:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "highCut" ? theme.primary : theme.textMuted}>High Cutoff:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
|
||||
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings"
|
||||
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences, VisualizerSettings } from "../types/settings"
|
||||
import { resolveTheme } from "../utils/theme-resolver"
|
||||
import type { ThemeJson } from "../types/theme-schema"
|
||||
import {
|
||||
@@ -9,11 +9,20 @@ import {
|
||||
migrateAppStateFromLocalStorage,
|
||||
} from "../utils/app-persistence"
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
bars: 32,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
visualizer: defaultVisualizerSettings,
|
||||
}
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
@@ -72,6 +81,12 @@ export function createAppStore() {
|
||||
updateState(next)
|
||||
}
|
||||
|
||||
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
||||
updateSettings({
|
||||
visualizer: { ...state().settings.visualizer, ...updates },
|
||||
})
|
||||
}
|
||||
|
||||
const setTheme = (theme: ThemeName) => {
|
||||
updateSettings({ theme })
|
||||
}
|
||||
@@ -90,6 +105,7 @@ export function createAppStore() {
|
||||
updateSettings,
|
||||
updatePreferences,
|
||||
updateCustomTheme,
|
||||
updateVisualizer,
|
||||
setTheme,
|
||||
resolveTheme: resolveThemeColors,
|
||||
}
|
||||
|
||||
@@ -46,11 +46,25 @@ export type DesktopTheme = {
|
||||
tokens: ThemeToken
|
||||
}
|
||||
|
||||
export type VisualizerSettings = {
|
||||
/** Number of frequency bars (8–128, default: 32) */
|
||||
bars: number
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
sensitivity: number
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction: number
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff: number
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff: number
|
||||
}
|
||||
|
||||
export type AppSettings = {
|
||||
theme: ThemeName
|
||||
fontSize: number
|
||||
playbackSpeed: number
|
||||
downloadPath: string
|
||||
visualizer: VisualizerSettings
|
||||
}
|
||||
|
||||
export type UserPreferences = {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
|
||||
import { backupConfigFile } from "./config-backup"
|
||||
import type { AppState, AppSettings, UserPreferences, ThemeColors } from "../types/settings"
|
||||
import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings"
|
||||
import { DEFAULT_THEME } from "../constants/themes"
|
||||
|
||||
const APP_STATE_FILE = "app-state.json"
|
||||
@@ -18,11 +18,20 @@ const LEGACY_PROGRESS_KEY = "podtui_progress"
|
||||
|
||||
// --- Defaults ---
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
bars: 32,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
visualizer: defaultVisualizerSettings,
|
||||
}
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
|
||||
191
src/utils/audio-stream-reader.ts
Normal file
191
src/utils/audio-stream-reader.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Real-time audio stream reader for visualization.
|
||||
*
|
||||
* Spawns a separate ffmpeg process that decodes the same audio URL
|
||||
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
|
||||
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
|
||||
* and provides them to the caller on demand.
|
||||
*
|
||||
* This is independent from the actual playback backend — it's a
|
||||
* read-only "tap" on the audio for FFT analysis purposes.
|
||||
*/
|
||||
|
||||
/** PCM output format constants */
|
||||
const SAMPLE_RATE = 44100
|
||||
const CHANNELS = 1
|
||||
const BYTES_PER_SAMPLE = 2 // s16le
|
||||
|
||||
/** How many samples to buffer (≈1 second) */
|
||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE
|
||||
|
||||
export interface AudioStreamReaderOptions {
|
||||
/** Audio URL or file path to decode */
|
||||
url: string
|
||||
/** Start position in seconds (for seeking sync) */
|
||||
startPosition?: number
|
||||
/** Sample rate (default: 44100) */
|
||||
sampleRate?: number
|
||||
}
|
||||
|
||||
export class AudioStreamReader {
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null
|
||||
private ringBuffer: Float64Array
|
||||
private writePos = 0
|
||||
private totalSamplesWritten = 0
|
||||
private _running = false
|
||||
private readPromise: Promise<void> | null = null
|
||||
private url: string
|
||||
private sampleRate: number
|
||||
|
||||
constructor(options: AudioStreamReaderOptions) {
|
||||
this.url = options.url
|
||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE
|
||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES)
|
||||
}
|
||||
|
||||
/** Whether the reader is actively reading samples. */
|
||||
get running(): boolean {
|
||||
return this._running
|
||||
}
|
||||
|
||||
/** Total number of samples written since start(). */
|
||||
get samplesWritten(): number {
|
||||
return this.totalSamplesWritten
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ffmpeg decode process and begin reading PCM data.
|
||||
* @param startPosition Seek position in seconds (default: 0).
|
||||
*/
|
||||
start(startPosition = 0): void {
|
||||
if (this._running) return
|
||||
if (!Bun.which("ffmpeg")) {
|
||||
throw new Error("ffmpeg not found — required for audio visualization")
|
||||
}
|
||||
|
||||
const args = [
|
||||
"ffmpeg",
|
||||
"-loglevel", "quiet",
|
||||
]
|
||||
|
||||
// Seek before input for network efficiency
|
||||
if (startPosition > 0) {
|
||||
args.push("-ss", String(startPosition))
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-i", this.url,
|
||||
"-ac", String(CHANNELS),
|
||||
"-ar", String(this.sampleRate),
|
||||
"-f", "s16le", // raw signed 16-bit little-endian PCM
|
||||
"-acodec", "pcm_s16le",
|
||||
"-", // output to stdout
|
||||
)
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
})
|
||||
|
||||
this._running = true
|
||||
this.writePos = 0
|
||||
this.totalSamplesWritten = 0
|
||||
|
||||
// Start async reading loop
|
||||
this.readPromise = this.readLoop()
|
||||
|
||||
// Detect process exit
|
||||
this.proc.exited.then(() => {
|
||||
this._running = false
|
||||
}).catch(() => {
|
||||
this._running = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read available samples into the provided buffer.
|
||||
* Returns the number of samples actually copied.
|
||||
*
|
||||
* @param out - Float64Array to fill with samples (scaled ~±32768 for cavacore).
|
||||
* @returns Number of samples written to `out`.
|
||||
*/
|
||||
read(out: Float64Array): number {
|
||||
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length)
|
||||
if (available <= 0) return 0
|
||||
|
||||
// Read the most recent `available` samples from the ring buffer
|
||||
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length
|
||||
|
||||
if (readStart + available <= this.ringBuffer.length) {
|
||||
// Contiguous read
|
||||
out.set(this.ringBuffer.subarray(readStart, readStart + available))
|
||||
} else {
|
||||
// Wraps around
|
||||
const firstChunk = this.ringBuffer.length - readStart
|
||||
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length))
|
||||
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk)
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the ffmpeg process and clean up.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
stop(): void {
|
||||
this._running = false
|
||||
if (this.proc) {
|
||||
try { this.proc.kill() } catch { /* ignore */ }
|
||||
this.proc = null
|
||||
}
|
||||
this.writePos = 0
|
||||
this.totalSamplesWritten = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the reader at a new position (e.g. after a seek).
|
||||
*/
|
||||
restart(startPosition = 0): void {
|
||||
this.stop()
|
||||
this.start(startPosition)
|
||||
}
|
||||
|
||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
||||
private async readLoop(): Promise<void> {
|
||||
const stdout = this.proc?.stdout
|
||||
if (!stdout || typeof stdout === "number") return
|
||||
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
|
||||
try {
|
||||
while (this._running) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !this._running) break
|
||||
if (!value || value.byteLength === 0) continue
|
||||
|
||||
// Convert raw s16le bytes → Float64Array scaled for cavacore
|
||||
// Ensure we have an even number of bytes (each sample = 2 bytes)
|
||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE)
|
||||
if (sampleCount === 0) continue
|
||||
|
||||
const int16View = new Int16Array(
|
||||
value.buffer,
|
||||
value.byteOffset,
|
||||
sampleCount,
|
||||
)
|
||||
|
||||
// Write samples into ring buffer (as doubles, preserving int16 scale)
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
this.ringBuffer[this.writePos] = int16View[i] // ±32768 range
|
||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length
|
||||
this.totalSamplesWritten++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or process killed — expected during stop()
|
||||
} finally {
|
||||
try { reader.releaseLock() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/utils/cavacore.ts
Normal file
230
src/utils/cavacore.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* TypeScript FFI bindings for libcavacore.
|
||||
*
|
||||
* Wraps cava's frequency-analysis engine (cavacore) via Bun's dlopen.
|
||||
* The precompiled shared library ships in src/native/ (dev) and dist/ (prod)
|
||||
* with fftw3 statically linked — zero native dependencies for end users.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const cava = loadCavaCore()
|
||||
* if (cava) {
|
||||
* cava.init({ bars: 32, sampleRate: 44100 })
|
||||
* const freqs = cava.execute(pcmSamples)
|
||||
* cava.destroy()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
||||
import { existsSync } from "fs"
|
||||
import { join, dirname } from "path"
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CavaCoreConfig {
|
||||
/** Number of frequency bars (default: 32) */
|
||||
bars?: number
|
||||
/** Audio sample rate in Hz (default: 44100) */
|
||||
sampleRate?: number
|
||||
/** Number of audio channels (default: 1 = mono) */
|
||||
channels?: number
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
autosens?: number
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction?: number
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff?: number
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff?: number
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<CavaCoreConfig> = {
|
||||
bars: 32,
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
autosens: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void }
|
||||
|
||||
// ── Library resolution ───────────────────────────────────────────────
|
||||
|
||||
function findLibrary(): string | null {
|
||||
const platform = process.platform
|
||||
const libName = platform === "darwin"
|
||||
? "libcavacore.dylib"
|
||||
: platform === "win32"
|
||||
? "cavacore.dll"
|
||||
: "libcavacore.so"
|
||||
|
||||
// Candidate paths, in priority order:
|
||||
// 1. src/native/ (development)
|
||||
// 2. Same directory as the running executable (dist bundle)
|
||||
// 3. dist/ relative to cwd
|
||||
const candidates = [
|
||||
join(import.meta.dir, "..", "native", libName),
|
||||
join(dirname(process.execPath), libName),
|
||||
join(process.cwd(), "dist", libName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── CavaCore class ───────────────────────────────────────────────────
|
||||
|
||||
export class CavaCore {
|
||||
private lib: CavaLib
|
||||
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null
|
||||
private inputBuffer: Float64Array | null = null
|
||||
private outputBuffer: Float64Array | null = null
|
||||
private _bars = 0
|
||||
private _channels = 1
|
||||
private _destroyed = false
|
||||
|
||||
/** Use loadCavaCore() instead of constructing directly. */
|
||||
constructor(lib: CavaLib) {
|
||||
this.lib = lib
|
||||
}
|
||||
|
||||
/** Number of frequency bars configured. */
|
||||
get bars(): number {
|
||||
return this._bars
|
||||
}
|
||||
|
||||
/** Whether this instance has been initialized (and not yet destroyed). */
|
||||
get isReady(): boolean {
|
||||
return this.plan !== null && !this._destroyed
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cavacore engine with the given configuration.
|
||||
* Must be called before execute(). Can be called again after destroy()
|
||||
* to reinitialize with different parameters.
|
||||
*/
|
||||
init(config: CavaCoreConfig = {}): void {
|
||||
if (this.plan) {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
this._bars = cfg.bars
|
||||
this._channels = cfg.channels
|
||||
|
||||
this.plan = this.lib.symbols.cava_init(
|
||||
cfg.bars,
|
||||
cfg.sampleRate,
|
||||
cfg.channels,
|
||||
cfg.autosens,
|
||||
cfg.noiseReduction,
|
||||
cfg.lowCutOff,
|
||||
cfg.highCutOff,
|
||||
)
|
||||
|
||||
if (!this.plan) {
|
||||
throw new Error("cava_init returned null — initialization failed")
|
||||
}
|
||||
|
||||
// Pre-allocate output buffer (bars * channels)
|
||||
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels)
|
||||
this._destroyed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed PCM samples into cavacore and get frequency bar values back.
|
||||
*
|
||||
* @param samples - Float64Array of PCM samples (scaled ~±32768).
|
||||
* The array length determines the number of samples processed.
|
||||
* @returns Float64Array of bar values (0.0–1.0 range, length = bars * channels).
|
||||
* Returns the same buffer reference each call (overwritten in place).
|
||||
*/
|
||||
execute(samples: Float64Array): Float64Array {
|
||||
if (!this.plan || !this.outputBuffer) {
|
||||
throw new Error("CavaCore not initialized — call init() first")
|
||||
}
|
||||
|
||||
// Reuse input buffer if same size, otherwise allocate new
|
||||
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
||||
this.inputBuffer = new Float64Array(samples.length)
|
||||
}
|
||||
this.inputBuffer.set(samples)
|
||||
|
||||
this.lib.symbols.cava_execute(
|
||||
ptr(this.inputBuffer),
|
||||
samples.length,
|
||||
ptr(this.outputBuffer),
|
||||
this.plan,
|
||||
)
|
||||
|
||||
return this.outputBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all native resources. Safe to call multiple times.
|
||||
* After calling destroy(), init() can be called again to reuse the instance.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.plan && !this._destroyed) {
|
||||
this.lib.symbols.cava_destroy(this.plan)
|
||||
this.plan = null
|
||||
this._destroyed = true
|
||||
}
|
||||
this.inputBuffer = null
|
||||
this.outputBuffer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to load the cavacore shared library and return a CavaCore instance.
|
||||
* Returns null if the library cannot be found — callers should fall back
|
||||
* to the static waveform display.
|
||||
*/
|
||||
export function loadCavaCore(): CavaCore | null {
|
||||
try {
|
||||
const libPath = findLibrary()
|
||||
if (!libPath) return null
|
||||
|
||||
const lib = dlopen(libPath, {
|
||||
cava_init: {
|
||||
args: [
|
||||
FFIType.i32, // bars
|
||||
FFIType.u32, // rate
|
||||
FFIType.i32, // channels
|
||||
FFIType.i32, // autosens
|
||||
FFIType.double, // noise_reduction
|
||||
FFIType.i32, // low_cut_off
|
||||
FFIType.i32, // high_cut_off
|
||||
],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
cava_execute: {
|
||||
args: [
|
||||
FFIType.ptr, // cava_in (double*)
|
||||
FFIType.i32, // samples
|
||||
FFIType.ptr, // cava_out (double*)
|
||||
FFIType.ptr, // plan
|
||||
],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
cava_destroy: {
|
||||
args: [FFIType.ptr], // plan
|
||||
returns: FFIType.void,
|
||||
},
|
||||
})
|
||||
|
||||
return new CavaCore(lib as CavaLib)
|
||||
} catch {
|
||||
// Library load failed — missing dylib, wrong arch, etc.
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user