From 67032460ff8c92d6d710338c32a25a1a7410d753 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 10 Aug 2026 16:49:57 -0400 Subject: [PATCH] feat(player): click-to-seek progress bar; 2-row waveform bars with peak normalization - ProgressBar: full-width played/remaining bar in the player pane, click-to-seek; waveform no longer handles seeking or the played/future color split (pure visual) - bars: 2 terminal rows per bar (16 levels) via barChars, partial block in the top row so the column renders continuously - fix bars maxing at audio start: disable cava autosens (silence gain-ramp), pre-warm the malloc'd FFT window with zeros, skip partial FFT windows - createBarScaler peak follower + power curve replaces cava autosens for level-to-height mapping (src/utils/bar-mapping.ts, unit-tested) --- src/pages/Player/PlayerPage.tsx | 3 + src/pages/Player/ProgressBar.tsx | 56 ++++++++++++++ src/pages/Player/RealtimeWaveform.tsx | 101 ++++++++++--------------- src/utils/bar-mapping.ts | 102 ++++++++++++++++++++++++++ tests/bar-mapping.test.ts | 97 ++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 64 deletions(-) create mode 100644 src/pages/Player/ProgressBar.tsx create mode 100644 src/utils/bar-mapping.ts create mode 100644 tests/bar-mapping.test.ts diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index cc93451..0a1b368 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -12,6 +12,7 @@ import { Show } from "solid-js"; import { PlaybackControls } from "./PlaybackControls"; +import { ProgressBar } from "./ProgressBar"; import { RealtimeWaveform } from "./RealtimeWaveform"; import { useAudio } from "@/hooks/useAudio"; import { useAppStore } from "@/stores/app"; @@ -79,6 +80,8 @@ export function PlayerPage() { {ep().description?.slice(0, 500) ?? "No description available."} + + { const viz = useAppStore().state().settings.visualizer; diff --git a/src/pages/Player/ProgressBar.tsx b/src/pages/Player/ProgressBar.tsx new file mode 100644 index 0000000..6122670 --- /dev/null +++ b/src/pages/Player/ProgressBar.tsx @@ -0,0 +1,56 @@ +/** + * ProgressBar — one-row, click-to-seek playback progress bar for the + * player pane. Played portion renders as full blocks (█) in the theme's + * primary color, the remainder as light shade blocks (░) in the muted + * color. The header time/percent text lives in PlayerPage — this is only + * the bar itself. + */ + +import { useTerminalDimensions } from "@opentui/solid"; +import { useAudio } from "@/hooks/useAudio"; +import { useTheme } from "@/context/ThemeContext"; + +// ── Component ──────────────────────────────────────────────────────── + +export function ProgressBar() { + const audio = useAudio(); + const { theme } = useTheme(); + const dimensions = useTerminalDimensions(); + + // Full content width of the player pane: the player is a 2-pane row + // (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars + // of border/padding chrome (same math as RealtimeWaveform's numBars). + const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8); + + const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); + + const playedChars = () => { + const duration = audio.duration(); + if (duration <= 0) return 0; + return Math.round(clamp01(audio.position() / duration) * width()); + }; + + const remainingColor = theme.muted || theme.text; + + return ( + { + const duration = audio.duration(); + if (duration <= 0) return; + // e.x = 0 is the box border; content starts at x = 1. + const ratio = Math.max(0, Math.min(1, (e.x - 1) / width())); + void audio.seek(ratio * duration); + }} + > + {"\u2588".repeat(playedChars())} + + {"\u2591".repeat(width() - playedChars())} + + + ); +} diff --git a/src/pages/Player/RealtimeWaveform.tsx b/src/pages/Player/RealtimeWaveform.tsx index ad16c33..4f302fc 100644 --- a/src/pages/Player/RealtimeWaveform.tsx +++ b/src/pages/Player/RealtimeWaveform.tsx @@ -15,6 +15,7 @@ import { type CavaCoreConfig, } from "@/utils/cavacore"; import { AudioStreamReader } from "@/utils/audio-stream-reader"; +import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping"; import { useAudio } from "@/hooks/useAudio"; import { useTheme } from "@/context/ThemeContext"; import { PANE_RATIO } from "@/utils/navigation"; @@ -25,19 +26,6 @@ export type RealtimeWaveformProps = { visualizerConfig?: Partial; }; -/** 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; @@ -53,6 +41,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // Frequency bar values (0.0–1.0 per bar) const [barData, setBarData] = createSignal([]); + // Peak-follower scaler replaces cava's autosens: normalizes each FFT + // frame against the running peak so a loud start can't pin every bar + // at full height and quiet content still gets normalized up. + const scaler = createBarScaler(); + let cava: CavaCore | null = null; let reader: AudioStreamReader | null = null; let frameTimer: ReturnType | null = null; @@ -91,10 +84,10 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // ── Smooth position clock ────────────────────────────────────────── // // audio.position() updates at the useAudio poll rate (~150ms). Between - // polls, interpolate the position from wall time so the FFT window (and - // the played/future split) tracks the audio continuously instead of - // stepping. The 0.5s cap prevents extrapolating far beyond reality when - // the player stalls (e.g. network re-buffering). + // polls, interpolate the position from wall time so the FFT window + // tracks the audio continuously instead of stepping. The 0.5s cap + // prevents extrapolating far beyond reality when the player stalls + // (e.g. network re-buffering). let lastPolledPosition = 0; let lastPolledAt = 0; @@ -121,14 +114,26 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // Initialize cavacore with current resolution + any overrides. // bars is width-derived (see numBars); visualizerConfig supplies the // audio-processing params (noise reduction, cutoffs, etc.). + // autosens is disabled (after the spread so it always wins): cava's + // autosens gain-ramps during silence then clips everything to 1.0 + // when audio arrives — the JS peak scaler handles dynamics instead. const config: CavaCoreConfig = { bars: numBars(), sampleRate: 44100, channels: 1, ...props.visualizerConfig, + autosens: 0, }; cava.init(config); + // Pre-warm the FFT window: libcavacore's window is malloc'd + // uninitialized, so the first real frame would FFT garbage and + // render full-scale bars. One zero frame the size of the whole + // input buffer clears it (at 44.1kHz mono the window is 8192 + // samples — FFTbassbufferSize × channels; a 512-sample frame would + // leave the tail garbage). + cava.execute(new Float64Array(8192)); + // Pre-allocate sample read buffer sampleBuffer = new Float64Array(SAMPLES_PER_FRAME); @@ -167,16 +172,13 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // ties the bars to what's actually playing. const target = smoothPosition(); const count = reader.read(sampleBuffer, target); - if (count === 0) return; + // Never feed a partial FFT window to cava. + if (count < sampleBuffer.length) return; - const input = - count < sampleBuffer.length - ? sampleBuffer.subarray(0, count) - : sampleBuffer; - const output = cava.execute(input); + const output = cava.execute(sampleBuffer); - // Copy bar values to a new array for the signal - setBarData(Array.from(output as Float64Array)); + // Normalize against the running peak and copy to a new array + setBarData(scaler(output)); }; createEffect( @@ -236,11 +238,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // ── Rendering ────────────────────────────────────────────────────── - const playedRatio = () => - audio.duration() <= 0 - ? 0 - : Math.min(1, smoothPosition() / audio.duration()); - const renderLine = () => { const bars = barData(); const count = numBars(); @@ -248,51 +245,27 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { if (bars.length === 0) { const placeholder = ".".repeat(count); return ( - - {placeholder} + + {placeholder} + {placeholder} ); } - const played = Math.floor(count * playedRatio()); - const playedColor = audio.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(""); + const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS))); + const top = pairs.map((pair) => pair.top).join(""); + const bottom = pairs.map((pair) => pair.bottom).join(""); return ( - - {playedChars || " "} - {futureChars || " "} + + {top} + {bottom} ); }; - 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 ( - + {renderLine()} ); diff --git a/src/utils/bar-mapping.ts b/src/utils/bar-mapping.ts new file mode 100644 index 0000000..b8f3d94 --- /dev/null +++ b/src/utils/bar-mapping.ts @@ -0,0 +1,102 @@ +/** + * Pure bar-scaling helpers for the terminal waveform. + * + * barChars maps a 0..16 level to the two characters of a 2-row bar built + * from Unicode lower block elements (U+2581..U+2588). The partial block + * sits in the TOP row (its glyph bottom edge = row bottom), so a full + * block below makes a visually continuous 2-cell column — the "double the + * default height" requirement (each bar = 2 terminal rows, 16 heights). + * + * createBarScaler is a stateful fast-attack / slow-release peak follower + * that replaces cava's autosens: a loud start cannot pin every bar at + * full height (the peak follower absorbs it) and quiet content gets + * normalized up. + */ + +// ── Types ──────────────────────────────────────────────────────────── + +export interface BarScalerOptions { + /** Peak follower decay per frame (default: 0.985) */ + release?: number; + /** Power curve applied after normalization (default: 0.7) */ + curve?: number; + /** Silence threshold — below this the input is treated as silent (default: 1e-6) */ + epsilon?: number; +} + +// ── Constants ──────────────────────────────────────────────────────── + +/** Number of discrete bar heights (2 rows × 8 block levels). */ +export const BAR_LEVELS = 16; + +/** Lower block elements, index 0 = space (silence) through full block (max). */ +const LOWER = [ + " ", + "\u2581", + "\u2582", + "\u2583", + "\u2584", + "\u2585", + "\u2586", + "\u2587", + "\u2588", +]; + +// ── Bar mapping ────────────────────────────────────────────────────── + +/** + * Map a bar level (0..16) to the two characters that render it as a + * 2-row column: top row + bottom row. + * + * level 0 → { top: " ", bottom: " " } + * level 1..8 → { top: " ", bottom: LOWER[level] } + * level 9..16 → { top: LOWER[level - 8], bottom: "\u2588" } + */ +export function barChars(level: number): { top: string; bottom: string } { + const raw = Math.floor(level); + const lvl = Number.isFinite(raw) + ? Math.max(0, Math.min(BAR_LEVELS, raw)) + : 0; + + if (lvl === 0) return { top: " ", bottom: " " }; + if (lvl <= 8) return { top: " ", bottom: LOWER[lvl] }; + return { top: LOWER[lvl - 8], bottom: "\u2588" }; +} + +// ── Peak-follower scaler ───────────────────────────────────────────── + +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); + +/** + * Create a stateful bar scaler. Each call normalizes its input against a + * peak follower (instant attack, multiplicative release), then applies a + * power curve so low-energy content remains visible. Returns a new + * number[] per call. + */ +export function createBarScaler( + opts?: BarScalerOptions, +): (values: ArrayLike) => number[] { + const release = opts?.release ?? 0.985; + const curve = opts?.curve ?? 0.7; + const epsilon = opts?.epsilon ?? 1e-6; + let peak = 0; + + return (values: ArrayLike): number[] => { + let frameMax = 0; + for (let i = 0; i < values.length; i++) { + const magnitude = Math.abs(values[i]); + if (magnitude > frameMax) frameMax = magnitude; + } + + // Fast attack, slow release + peak = frameMax > peak ? frameMax : peak * release; + + const gain = peak > epsilon ? 1 / peak : 0; + + const output = new Array(values.length); + for (let i = 0; i < values.length; i++) { + output[i] = Math.pow(clamp01(values[i] * gain), curve); + } + return output; + }; +} diff --git a/tests/bar-mapping.test.ts b/tests/bar-mapping.test.ts new file mode 100644 index 0000000..a33123a --- /dev/null +++ b/tests/bar-mapping.test.ts @@ -0,0 +1,97 @@ +/** + * bar-mapping tests — the pure waveform bar-scaling helpers. + * + * Two contracts are pinned: + * + * • barChars: the 2-row / 16-level bar rendering. The partial block + * always sits in the TOP row (glyph bottom edge = row bottom) so a + * full block below renders a visually continuous 2-cell column — + * this is the "double the default height" requirement. + * + * • createBarScaler: the peak-follower normalization that replaces + * cava's autosens. The regression this guards: bars suddenly maxing + * out when audio starts. A loud first frame must normalize to a + * single full bar (the peak), not pin every bar at full height, and + * quiet content after a loud passage must still recover (slow + * release) instead of staying dead. + */ + +import { describe, test, expect } from "bun:test"; +import { barChars, createBarScaler, BAR_LEVELS } from "../src/utils/bar-mapping"; + +describe("barChars", () => { + test("level 0 is two spaces (silence)", () => { + expect(barChars(0)).toEqual({ top: " ", bottom: " " }); + }); + + test("levels 1..8 fill the bottom row only, partial block on top row stays empty", () => { + expect(barChars(1)).toEqual({ top: " ", bottom: "\u2581" }); + expect(barChars(4)).toEqual({ top: " ", bottom: "\u2584" }); + expect(barChars(8)).toEqual({ top: " ", bottom: "\u2588" }); + }); + + test("levels 9..16 fill the bottom row and put the partial in the top row", () => { + expect(barChars(9)).toEqual({ top: "\u2581", bottom: "\u2588" }); + expect(barChars(12)).toEqual({ top: "\u2584", bottom: "\u2588" }); + expect(barChars(16)).toEqual({ top: "\u2588", bottom: "\u2588" }); + }); + + test("BAR_LEVELS is 16 (double the single-row 8 levels)", () => { + expect(BAR_LEVELS).toBe(16); + }); + + test("clamps out-of-range and NaN levels", () => { + expect(barChars(20)).toEqual(barChars(16)); + expect(barChars(-3)).toEqual(barChars(0)); + expect(barChars(Number.NaN)).toEqual(barChars(0)); + }); +}); + +describe("createBarScaler", () => { + test("a loud first frame normalizes to one full bar, not all bars", () => { + const scale = createBarScaler(); + const out = scale([0.9, 0.5, 0.1]); + expect(out[0]).toBeCloseTo(1, 5); // the peak maps to full height + expect(out[1]).toBeCloseTo(Math.pow(0.5 / 0.9, 0.7), 5); + expect(out[2]).toBeCloseTo(Math.pow(0.1 / 0.9, 0.7), 5); + }); + + test("quiet frame after a loud passage recovers via slow release", () => { + const scale = createBarScaler(); + scale([0.9]); + // peak decays multiplicatively (release 0.985), so 0.05 gets + // normalized up well past its raw value instead of rendering dead. + const out = scale([0.05]); + const expectedPeak = 0.9 * 0.985; + expect(out[0]).toBeCloseTo(Math.pow(0.05 / expectedPeak, 0.7), 5); + }); + + test("silence maps to zeros and never inflates the peak", () => { + const scale = createBarScaler(); + scale([0.8, 0.4]); + const out = scale([0, 0, 0]); + expect(out).toEqual([0, 0, 0]); + // peak keeps decaying toward silence + expect(scale([0])[0]).toBe(0); + }); + + test("negative values clamp to zero (no negative bars)", () => { + const scale = createBarScaler(); + const out = scale([-0.5]); + expect(out[0]).toBe(0); + }); + + test("empty input returns an empty array", () => { + const scale = createBarScaler(); + expect(scale([])).toEqual([]); + }); + + test("returns a fresh array each call (no aliasing of cava's buffer)", () => { + const scale = createBarScaler(); + const a = scale([0.5]); + const b = scale([0.5]); + expect(a).not.toBe(b); + a[0] = 0; + expect(b[0]).not.toBe(0); + }); +});