better visualizer

This commit is contained in:
2026-02-06 11:08:41 -05:00
parent 8d6b19582c
commit e1dc242b1d
11 changed files with 908 additions and 19 deletions

View File

@@ -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;

View File

@@ -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

View 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.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([])
// Track whether cavacore is available
const [available, setAvailable] = createSignal(false)
let cava: CavaCore | null = null
let reader: AudioStreamReader | null = null
let frameTimer: ReturnType<typeof setInterval> | null = null
let sampleBuffer: Float64Array | null = null
// ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => {
if (cava) return true
cava = loadCavaCore()
if (!cava) {
setAvailable(false)
return false
}
setAvailable(true)
return true
}
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number) => {
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
}

View File

@@ -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>

View File

@@ -0,0 +1,141 @@
/**
* VisualizerSettings — settings panel for the real-time audio visualizer.
*
* Allows adjusting bar count, noise reduction, sensitivity, and
* frequency cutoffs. All changes persist via the app store.
*/
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useAppStore } from "../stores/app"
import { useTheme } from "../context/ThemeContext"
import { isCavacoreAvailable } from "./RealtimeWaveform"
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut"
const FIELDS: FocusField[] = ["bars", "sensitivity", "noise", "lowCut", "highCut"]
export function VisualizerSettings() {
const appStore = useAppStore()
const { theme } = useTheme()
const [focusField, setFocusField] = createSignal<FocusField>("bars")
const viz = () => appStore.state().settings.visualizer
const handleKey = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const idx = FIELDS.indexOf(focusField())
const next = key.shift
? (idx - 1 + FIELDS.length) % FIELDS.length
: (idx + 1) % FIELDS.length
setFocusField(FIELDS[next])
return
}
if (key.name === "left" || key.name === "h") {
stepValue(-1)
}
if (key.name === "right" || key.name === "l") {
stepValue(1)
}
}
const stepValue = (delta: number) => {
const field = focusField()
const v = viz()
switch (field) {
case "bars": {
// Step by 8: 8, 16, 24, 32, ..., 128
const next = Math.min(128, Math.max(8, v.bars + delta * 8))
appStore.updateVisualizer({ bars: next })
break
}
case "sensitivity": {
// Toggle: 0 (manual) or 1 (auto)
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 })
break
}
case "noise": {
// Step by 0.05: 0.0 1.0
const next = Math.min(1, Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))))
appStore.updateVisualizer({ noiseReduction: next })
break
}
case "lowCut": {
// Step by 10: 20 500 Hz
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10))
appStore.updateVisualizer({ lowCutOff: next })
break
}
case "highCut": {
// Step by 500: 1000 20000 Hz
const next = Math.min(20000, Math.max(1000, v.highCutOff + delta * 500))
appStore.updateVisualizer({ highCutOff: next })
break
}
}
}
useKeyboard(handleKey)
const cavacoreStatus = isCavacoreAvailable()
return (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Visualizer</text>
{!cavacoreStatus && (
<text fg={theme.warning}>
cavacore not available using static waveform
</text>
)}
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>Bars:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().bars}</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "sensitivity" ? theme.primary : theme.textMuted}>Auto Sensitivity:</text>
<box border padding={0}>
<text fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}>
{viz().sensitivity === 1 ? "On" : "Off"}
</text>
</box>
<text fg={theme.textMuted}>[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>Noise Reduction:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}>Low Cutoff:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "highCut" ? theme.primary : theme.textMuted}>High Cutoff:</text>
<box border padding={0}>
<text fg={theme.text}>{viz().highCutOff} Hz</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
</box>
</box>
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box>
)
}