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);
+ });
+});