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)
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
@@ -79,6 +80,8 @@ export function PlayerPage() {
|
|||||||
{ep().description?.slice(0, 500) ?? "No description available."}
|
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
<ProgressBar />
|
||||||
|
|
||||||
<RealtimeWaveform
|
<RealtimeWaveform
|
||||||
visualizerConfig={(() => {
|
visualizerConfig={(() => {
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
|
|||||||
56
src/pages/Player/ProgressBar.tsx
Normal file
56
src/pages/Player/ProgressBar.tsx
Normal file
@@ -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 (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
padding={0}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={0}
|
||||||
|
onMouseDown={(e: { x: number }) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text>
|
||||||
|
<text fg={remainingColor}>
|
||||||
|
{"\u2591".repeat(width() - playedChars())}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type CavaCoreConfig,
|
type CavaCoreConfig,
|
||||||
} from "@/utils/cavacore";
|
} from "@/utils/cavacore";
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||||
|
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
@@ -25,19 +26,6 @@ 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",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Target frame interval in ms (~30 fps) */
|
/** Target frame interval in ms (~30 fps) */
|
||||||
const FRAME_INTERVAL = 33;
|
const FRAME_INTERVAL = 33;
|
||||||
|
|
||||||
@@ -53,6 +41,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
|
// 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 cava: CavaCore | null = null;
|
||||||
let reader: AudioStreamReader | null = null;
|
let reader: AudioStreamReader | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -91,10 +84,10 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// ── Smooth position clock ──────────────────────────────────────────
|
// ── Smooth position clock ──────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// audio.position() updates at the useAudio poll rate (~150ms). Between
|
// audio.position() updates at the useAudio poll rate (~150ms). Between
|
||||||
// polls, interpolate the position from wall time so the FFT window (and
|
// polls, interpolate the position from wall time so the FFT window
|
||||||
// the played/future split) tracks the audio continuously instead of
|
// tracks the audio continuously instead of stepping. The 0.5s cap
|
||||||
// stepping. The 0.5s cap prevents extrapolating far beyond reality when
|
// prevents extrapolating far beyond reality when the player stalls
|
||||||
// the player stalls (e.g. network re-buffering).
|
// (e.g. network re-buffering).
|
||||||
|
|
||||||
let lastPolledPosition = 0;
|
let lastPolledPosition = 0;
|
||||||
let lastPolledAt = 0;
|
let lastPolledAt = 0;
|
||||||
@@ -121,14 +114,26 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// Initialize cavacore with current resolution + any overrides.
|
// Initialize cavacore with current resolution + any overrides.
|
||||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
// 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 = {
|
const config: CavaCoreConfig = {
|
||||||
bars: numBars(),
|
bars: numBars(),
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
...props.visualizerConfig,
|
...props.visualizerConfig,
|
||||||
|
autosens: 0,
|
||||||
};
|
};
|
||||||
cava.init(config);
|
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
|
// Pre-allocate sample read buffer
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||||
|
|
||||||
@@ -167,16 +172,13 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// ties the bars to what's actually playing.
|
// ties the bars to what's actually playing.
|
||||||
const target = smoothPosition();
|
const target = smoothPosition();
|
||||||
const count = reader.read(sampleBuffer, target);
|
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 =
|
const output = cava.execute(sampleBuffer);
|
||||||
count < sampleBuffer.length
|
|
||||||
? sampleBuffer.subarray(0, count)
|
|
||||||
: sampleBuffer;
|
|
||||||
const output = cava.execute(input);
|
|
||||||
|
|
||||||
// Copy bar values to a new array for the signal
|
// Normalize against the running peak and copy to a new array
|
||||||
setBarData(Array.from(output as Float64Array));
|
setBarData(scaler(output));
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -236,11 +238,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const playedRatio = () =>
|
|
||||||
audio.duration() <= 0
|
|
||||||
? 0
|
|
||||||
: Math.min(1, smoothPosition() / audio.duration());
|
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = barData();
|
||||||
const count = numBars();
|
const count = numBars();
|
||||||
@@ -248,51 +245,27 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(count);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg="#3b4252">{placeholder}</text>
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const played = Math.floor(count * playedRatio());
|
const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
|
||||||
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
const top = pairs.map((pair) => pair.top).join("");
|
||||||
const futureColor = "#3b4252";
|
const bottom = pairs.map((pair) => pair.bottom).join("");
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg={playedColor}>{playedChars || " "}</text>
|
<text fg={theme.primary}>{top}</text>
|
||||||
<text fg={futureColor}>{futureChars || " "}</text>
|
<text fg={theme.primary}>{bottom}</text>
|
||||||
</box>
|
</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 (
|
return (
|
||||||
<box
|
<box border borderColor={theme.border} padding={1}>
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={handleClick}
|
|
||||||
>
|
|
||||||
{renderLine()}
|
{renderLine()}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
102
src/utils/bar-mapping.ts
Normal file
102
src/utils/bar-mapping.ts
Normal file
@@ -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>) => 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>): 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<number>(values.length);
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
output[i] = Math.pow(clamp01(values[i] * gain), curve);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
}
|
||||||
97
tests/bar-mapping.test.ts
Normal file
97
tests/bar-mapping.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user