refactor(lib,playback,whatsNew): home waveform→lib, timeline utils→playback, releaseNotes+changelog→whatsNew

This commit is contained in:
Psychotoxical
2026-06-30 20:26:34 +02:00
parent 695462ed87
commit aea8b7750b
37 changed files with 52 additions and 52 deletions
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { coerceWaveformBins, waveformBlobLenOk } from '@/lib/waveform/waveformParse';
describe('waveformBlobLenOk', () => {
it('accepts the legacy single-curve length (500)', () => {
expect(waveformBlobLenOk(500)).toBe(true);
});
it('accepts the v4 dual-curve length (1000)', () => {
expect(waveformBlobLenOk(1000)).toBe(true);
});
it('rejects every other length', () => {
expect(waveformBlobLenOk(0)).toBe(false);
expect(waveformBlobLenOk(499)).toBe(false);
expect(waveformBlobLenOk(501)).toBe(false);
expect(waveformBlobLenOk(999)).toBe(false);
expect(waveformBlobLenOk(1001)).toBe(false);
});
});
describe('coerceWaveformBins', () => {
it('passes a 500-length number[] through with byte-mask', () => {
const input = new Array(500).fill(0).map((_, i) => i);
const out = coerceWaveformBins(input);
expect(out).not.toBeNull();
expect(out!.length).toBe(500);
expect(out![0]).toBe(0);
expect(out![255]).toBe(255);
// index 256 wraps to 0 because of the & 255 mask
expect(out![256]).toBe(0);
});
it('passes a 1000-length Uint8Array through unchanged', () => {
const u8 = new Uint8Array(1000);
u8[42] = 200;
const out = coerceWaveformBins(u8);
expect(out).not.toBeNull();
expect(out!.length).toBe(1000);
expect(out![42]).toBe(200);
});
it('coerces a generic ArrayLike (Tauri serializes Vec<u8> as object)', () => {
// Fill remaining slots with zeros to match expected shape
const proxy: ArrayLike<number> = {
length: 500,
...Object.fromEntries(new Array(500).fill(0).map((_, i) => [i, i === 0 ? 10 : i === 1 ? 20 : 0])),
} as ArrayLike<number>;
const out = coerceWaveformBins(proxy);
expect(out).not.toBeNull();
expect(out![0]).toBe(10);
expect(out![1]).toBe(20);
expect(out![2]).toBe(0);
});
it('returns null for null / undefined', () => {
expect(coerceWaveformBins(null)).toBeNull();
expect(coerceWaveformBins(undefined)).toBeNull();
});
it('returns null for empty arrays', () => {
expect(coerceWaveformBins([])).toBeNull();
expect(coerceWaveformBins(new Uint8Array(0))).toBeNull();
expect(coerceWaveformBins({ length: 0 })).toBeNull();
});
it('returns null when length is not 500 or 1000', () => {
expect(coerceWaveformBins(new Array(750).fill(0))).toBeNull();
expect(coerceWaveformBins(new Uint8Array(123))).toBeNull();
});
it('returns null for unsupported shapes (string, plain object without length)', () => {
expect(coerceWaveformBins('hello')).toBeNull();
expect(coerceWaveformBins({ a: 1 })).toBeNull();
expect(coerceWaveformBins(42)).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Parse the waveform-bin payload Rust hands us. Two on-disk shapes survive:
* the v4 dual-curve (500 bytes peak + 500 bytes mean-abs = 1000 total) and
* the legacy single curve (500 bytes, treated as both peak and mean).
*
* `bins` may arrive as a real `number[]`, a `Uint8Array`, or any other
* `ArrayLike<number>` depending on Tauri's serialization path — coerce to a
* plain `number[]` clamped to a single byte, or return null when the shape
* doesn't match an accepted curve length.
*/
export function waveformBlobLenOk(len: number): boolean {
return len === 500 || len === 1000;
}
export function coerceWaveformBins(bins: unknown): number[] | null {
if (bins == null) return null;
let raw: number[];
if (Array.isArray(bins)) {
if (bins.length === 0) return null;
raw = bins.map(x => Number(x) & 255);
} else if (bins instanceof Uint8Array) {
if (bins.length === 0) return null;
raw = Array.from(bins);
} else if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') {
const len = (bins as { length: number }).length;
if (len === 0) return null;
try {
raw = Array.from(bins as ArrayLike<number>).map(x => Number(x) & 255);
} catch {
return null;
}
} else {
return null;
}
if (!waveformBlobLenOk(raw.length)) return null;
return raw;
}
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import { analyzeBoundary, computeWaveformSilence, planCrossfadeTransition } from '@/lib/waveform/waveformSilence';
/** Build a 500-bin peak curve: `lead` silent bins, a loud middle, `trail` silent bins. */
function curve(lead: number, mid: number, trail: number, loud = 200, quiet = 4): number[] {
return [
...Array(lead).fill(quiet),
...Array(mid).fill(loud),
...Array(trail).fill(quiet),
];
}
/** Linear ramp of `n` values from `from`→`to` (inclusive), rounded to ints. */
function ramp(n: number, from: number, to: number): number[] {
return Array.from({ length: n }, (_, i) => Math.round(from + ((to - from) * i) / Math.max(1, n - 1)));
}
describe('computeWaveformSilence', () => {
it('returns no trim for null bins or invalid duration', () => {
expect(computeWaveformSilence(null, 200)).toEqual({
leadSilenceSec: 0, trailSilenceSec: 0, contentStartSec: 0, contentEndSec: 200,
});
expect(computeWaveformSilence([0, 200, 0], 0).contentEndSec).toBe(0);
expect(computeWaveformSilence([0, 200, 0], NaN).contentEndSec).toBe(0);
});
it('does not trim a loud-throughout track', () => {
const bins = Array(500).fill(180);
const r = computeWaveformSilence(bins, 240);
expect(r.leadSilenceSec).toBe(0);
expect(r.trailSilenceSec).toBe(0);
expect(r.contentStartSec).toBe(0);
expect(r.contentEndSec).toBe(240);
});
it('trims leading and trailing silence and maps bins to seconds', () => {
// 500 bins over 250 s → 0.5 s/bin. 20 lead silent bins = 10 s,
// capped to 5 s; 10 trail silent bins = 5 s (exactly at cap).
const bins = curve(20, 470, 10);
const r = computeWaveformSilence(bins, 250);
expect(r.leadSilenceSec).toBeCloseTo(5, 5); // 10 s raw, capped to 5
expect(r.trailSilenceSec).toBeCloseTo(5, 5);
expect(r.contentStartSec).toBeCloseTo(5, 5);
expect(r.contentEndSec).toBeCloseTo(245, 5);
});
it('maps small silences below the cap precisely', () => {
// 100 bins over 100 s → 1 s/bin. 3 lead silent, 2 trail silent.
const bins = curve(3, 95, 2);
const r = computeWaveformSilence(bins, 100);
expect(r.leadSilenceSec).toBeCloseTo(3, 5);
expect(r.trailSilenceSec).toBeCloseTo(2, 5);
expect(r.contentStartSec).toBeCloseTo(3, 5);
expect(r.contentEndSec).toBeCloseTo(98, 5);
});
it('respects a custom cap', () => {
const bins = curve(50, 400, 50); // 100 bins over 100 s → 50 s each side raw
const r = computeWaveformSilence(bins, 100, { maxTrimSec: 8 });
expect(r.leadSilenceSec).toBe(8);
expect(r.trailSilenceSec).toBe(8);
});
it('never trims a fully-silent curve to nothing', () => {
const bins = Array(500).fill(3);
const r = computeWaveformSilence(bins, 120);
expect(r.leadSilenceSec).toBe(0);
expect(r.trailSilenceSec).toBe(0);
expect(r.contentEndSec).toBe(120);
});
it('uses only the peak half of a dual-curve (1000-byte) payload', () => {
// Peak half: 5 lead silent + loud. Mean half differs (all loud) — must be ignored.
const peak = curve(5, 495, 0);
const mean = Array(500).fill(150);
const bins = [...peak, ...mean];
const r = computeWaveformSilence(bins, 500); // 500 bins → 1 s/bin
expect(r.leadSilenceSec).toBeCloseTo(5, 5);
expect(r.trailSilenceSec).toBe(0);
});
it('honours a custom cut threshold', () => {
// Intro bins at 30 are "loud" by default (cut 12) but silent at cut 40.
const bins = [...Array(4).fill(30), ...Array(96).fill(200)];
expect(computeWaveformSilence(bins, 100).leadSilenceSec).toBe(0);
expect(computeWaveformSilence(bins, 100, { cut: 40 }).leadSilenceSec).toBeCloseTo(4, 5);
});
});
describe('analyzeBoundary', () => {
it('reports ~0 rise/fade for a hard-cut, loud-throughout track', () => {
const r = analyzeBoundary(Array(100).fill(200), 100); // 1 s/bin
expect(r.introRiseSec).toBeCloseTo(0, 5);
expect(r.outroFadeSec).toBeCloseTo(0, 5);
});
it('measures a long trailing fade-out', () => {
// 80 loud bins + 20-bin decay to near-silence over 100 s (1 s/bin).
const bins = [...Array(80).fill(200), ...ramp(20, 200, 20)];
const r = analyzeBoundary(bins, 100);
expect(r.outroFadeSec).toBeGreaterThan(2);
expect(r.introRiseSec).toBeCloseTo(0, 5); // loud from the very start
});
it('measures a long quiet buildup intro', () => {
const bins = [...ramp(20, 20, 200), ...Array(80).fill(200)];
const r = analyzeBoundary(bins, 100);
expect(r.introRiseSec).toBeGreaterThan(2);
expect(r.outroFadeSec).toBeCloseTo(0, 5);
});
});
describe('planCrossfadeTransition', () => {
it('uses a standard ~2s blend for two hard-edged (loud) tracks', () => {
// No fade-out, no buildup, but both edges known → standard blend (not a cut).
const a = Array(100).fill(200);
const b = Array(100).fill(200);
const plan = planCrossfadeTransition(a, 100, b, 100);
expect(plan.overlapSec).toBeCloseTo(2, 5);
expect(plan.bStartSec).toBeCloseTo(0, 5);
// A has no natural fade → engine supplies one (== the overlap).
expect(plan.outgoingFadeSec).toBeCloseTo(2, 5);
});
it('uses a long, content-driven overlap when a fade-out meets a buildup', () => {
const a = [...Array(80).fill(200), ...ramp(20, 200, 20)]; // fade-out tail
const b = [...ramp(20, 20, 200), ...Array(80).fill(200)]; // quiet buildup head
const plan = planCrossfadeTransition(a, 100, b, 100);
// Spans the gentle regions — far longer than the hard-cut case, ≤ engine cap.
expect(plan.overlapSec).toBeGreaterThan(3);
expect(plan.overlapSec).toBeLessThanOrEqual(12);
});
it('extends the overlap to cover a long fade-out even against a hard start', () => {
const a = [...Array(80).fill(200), ...ramp(20, 200, 20)]; // long fade-out
const b = Array(100).fill(200); // hard, loud start
const plan = planCrossfadeTransition(a, 100, b, 100);
expect(plan.overlapSec).toBeGreaterThan(3);
});
it('lets A ride its own recorded fade-out (scenario A): no engine fade on A', () => {
const a = [...Array(80).fill(200), ...ramp(20, 200, 20)]; // long fade-out tail
const b = Array(100).fill(200); // hard, loud start (no buildup)
const plan = planCrossfadeTransition(a, 100, b, 100);
// A's own fade dominates → engine fade-out suppressed (0); B still fades in.
expect(plan.outgoingFadeSec).toBe(0);
expect(plan.overlapSec).toBeGreaterThan(3);
});
it('keeps an engine fade on A when A is a hard cut into a quiet buildup', () => {
const a = Array(100).fill(200); // hard end, no fade
const b = [...ramp(20, 20, 200), ...Array(80).fill(200)]; // long quiet buildup
const plan = planCrossfadeTransition(a, 100, b, 100);
// Overlap is driven by B's rise, A has no fade → engine must fade A.
expect(plan.overlapSec).toBeGreaterThan(3);
expect(plan.outgoingFadeSec).toBeCloseTo(plan.overlapSec, 5);
});
it('starts the incoming track past its leading silence', () => {
const a = Array(100).fill(200);
const b = [...Array(5).fill(4), ...Array(95).fill(200)]; // 5 s true silence, then loud
const plan = planCrossfadeTransition(a, 100, b, 100);
expect(plan.bStartSec).toBeGreaterThanOrEqual(5);
});
it('falls back to the minimum overlap when an envelope is missing', () => {
const plan = planCrossfadeTransition(null, 100, Array(100).fill(200), 100);
expect(plan.overlapSec).toBeCloseTo(0.5, 5);
expect(plan.bStartSec).toBeCloseTo(0, 5);
});
});
+261
View File
@@ -0,0 +1,261 @@
/**
* Derive leading / trailing "empty tail" offsets for a track straight from the
* cached waveform bins we already have — no extra analysis pass, no new cache
* fields. The bins are the peak (+ mean) curve produced by the analysis decode
* and are **percentile-normalised** (silence floors near the bottom of the
* 0…255 range, ~8 on the PCM path / 0 on the byte-envelope fallback), so we use
* a low absolute cut that catches both. Bin → seconds uses the known track
* duration (`sec_per_bin = duration / bins`).
*
* Granularity is one bin (~0.5 s for a 4-min track at 500 bins) — by design;
* this is for trimming dead air between crossfaded tracks, not sample-accurate
* editing. The per-side trim is capped so a long musical fade-out cannot be
* mistaken for silence and eaten whole.
*/
export interface WaveformSilenceBounds {
/** Seconds of leading silence to skip (0 when none / unknown). */
leadSilenceSec: number;
/** Seconds of trailing silence to skip (0 when none / unknown). */
trailSilenceSec: number;
/** Playback start offset past the leading silence. */
contentStartSec: number;
/** End of musical content (track end minus trailing silence). */
contentEndSec: number;
}
export interface WaveformSilenceOptions {
/** Bins at/below this 0…255 value count as silence. Default 12. */
cut?: number;
/** Hard cap on trim per side, in seconds. Default 5. */
maxTrimSec?: number;
}
const DEFAULT_SILENCE_CUT = 12;
const DEFAULT_MAX_TRIM_SEC = 5;
/**
* Dual-curve payload is peak ++ mean; use the peak half. Legacy single curve
* (length === peak length) is used as-is.
*/
function peakHalf(bins: number[]): number[] {
return bins.length >= 1000 ? bins.slice(0, Math.floor(bins.length / 2)) : bins;
}
/** High-percentile ("plateau") level of `peak[startBin..endBin)` above the cut. */
function plateauLevel(peak: number[], startBin: number, endBin: number, cut: number): number {
const loud: number[] = [];
for (let i = Math.max(0, startBin); i < Math.min(peak.length, endBin); i++) {
if (peak[i] > cut) loud.push(peak[i]);
}
if (loud.length === 0) return 0;
loud.sort((a, b) => a - b);
return loud[Math.min(loud.length - 1, Math.floor(loud.length * 0.75))];
}
/**
* Compute silence bounds for `bins` over a track of `durationSec`.
* Returns a no-trim result (`lead/trail = 0`, content = full track) whenever the
* input is missing, the duration is invalid, or the track is effectively silent.
*/
export function computeWaveformSilence(
bins: number[] | null | undefined,
durationSec: number,
opts: WaveformSilenceOptions = {},
): WaveformSilenceBounds {
const dur = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 0;
const none: WaveformSilenceBounds = {
leadSilenceSec: 0,
trailSilenceSec: 0,
contentStartSec: 0,
contentEndSec: dur,
};
if (!bins || dur <= 0) return none;
const peak = peakHalf(bins);
const n = peak.length;
if (n === 0) return none;
const cut = opts.cut ?? DEFAULT_SILENCE_CUT;
const maxTrimSec = opts.maxTrimSec ?? DEFAULT_MAX_TRIM_SEC;
// Guard against an all-quiet curve (silent / undecoded track): never trim a
// whole track to nothing.
let anyLoud = false;
for (let i = 0; i < n; i++) {
if (peak[i] > cut) { anyLoud = true; break; }
}
if (!anyLoud) return none;
let leadBins = 0;
while (leadBins < n && peak[leadBins] <= cut) leadBins++;
let trailBins = 0;
while (trailBins < n && peak[n - 1 - trailBins] <= cut) trailBins++;
const secPerBin = dur / n;
const leadSilenceSec = Math.min(leadBins * secPerBin, maxTrimSec);
const trailSilenceSec = Math.min(trailBins * secPerBin, maxTrimSec);
// Degenerate overlap (shouldn't happen given the all-quiet guard, but keep
// the contract: always leave a positive content window).
if (leadSilenceSec + trailSilenceSec >= dur) return none;
return {
leadSilenceSec,
trailSilenceSec,
contentStartSec: leadSilenceSec,
contentEndSec: dur - trailSilenceSec,
};
}
/** Boundary shape: silence bounds + the length of the gentle fade/rise regions. */
export interface BoundaryShape extends WaveformSilenceBounds {
/** Seconds of trailing decay (plateau → floor) just before `contentEndSec`. */
outroFadeSec: number;
/** Seconds of leading rise (floor → plateau) just after `contentStartSec`. */
introRiseSec: number;
}
/**
* Extend {@link computeWaveformSilence} with the *shape* of the track's edges:
* how long the music takes to rise to full level at the start (`introRiseSec`)
* and how long it decays at the end (`outroFadeSec`). A long musical fade-out or
* a quiet count-in produces a large value; a hard cut/abrupt start → ~0. These
* drive the dynamic crossfade overlap (phase 2).
*/
export function analyzeBoundary(
bins: number[] | null | undefined,
durationSec: number,
opts: WaveformSilenceOptions = {},
): BoundaryShape {
const base = computeWaveformSilence(bins, durationSec, opts);
const dur = base.contentEndSec + base.trailSilenceSec; // == sanitised duration
if (!bins || !(dur > 0)) return { ...base, outroFadeSec: 0, introRiseSec: 0 };
const peak = peakHalf(bins);
const n = peak.length;
if (n === 0) return { ...base, outroFadeSec: 0, introRiseSec: 0 };
const cut = opts.cut ?? DEFAULT_SILENCE_CUT;
const secPerBin = dur / n;
const startBin = Math.min(n - 1, Math.max(0, Math.round(base.contentStartSec / secPerBin)));
const endBin = Math.min(n, Math.max(startBin + 1, Math.round(base.contentEndSec / secPerBin)));
const plateau = plateauLevel(peak, startBin, endBin, cut);
// "Full level" target for the rise/decay edges: halfway between cut and plateau.
const riseTarget = Math.max(cut + 1, plateau * 0.5);
let i = startBin;
while (i < endBin && peak[i] < riseTarget) i++;
const introRiseSec = (i - startBin) * secPerBin;
let j = endBin - 1;
while (j >= startBin && peak[j] < riseTarget) j--;
const outroFadeSec = Math.max(0, (endBin - 1 - j) * secPerBin);
return { ...base, outroFadeSec, introRiseSec };
}
/** Engine fade min/max — the override is clamped to the same range on the Rust side. */
const DYNAMIC_OVERLAP_MIN_SEC = 0.5;
export const DYNAMIC_OVERLAP_HARD_CAP_SEC = 12;
export const DYNAMIC_OVERLAP_ABSOLUTE_MAX_SEC = 30;
/**
* Standard pleasant blend used when *both* edges are known but neither fades —
* a hard, loud→loud meeting (e.g. a track that ends loud but had protective
* trailing silence we trim away, butting up against a loud intro). A bare
* anti-click floor (~0.5 s) would sound like an abrupt cut, so we equal-power
* crossfade over this many seconds instead.
*/
export const STANDARD_BLEND_SEC = 2.0;
/**
* A's own outro fade must be at least this long (≥2 waveform bins of decay at
* 500 bins / 4-min track) before we trust it enough to suppress the engine
* fade-out and let the *recording* carry A down to silence (scenario A).
*/
const OWN_FADE_TRUST_SEC = 1.0;
/** A per-transition crossfade plan derived from both tracks' envelopes. */
export interface CrossfadeTransitionPlan {
/** Where the incoming track should begin playing (leading silence skipped). */
bStartSec: number;
/** Fade length both sides use, derived purely from the audio's fade/rise shape. */
overlapSec: number;
/**
* Engine fade-out length for the *outgoing* track A, decoupled from B's
* fade-in (`overlapSec`):
* • `0` → A already fades out in the recording, so don't double-fade it —
* it rides at full engine gain while B rises underneath (scenario A);
* • else → fade A over this many seconds (== `overlapSec`; A has no natural
* fade, e.g. a hard cut, so the engine supplies one).
*/
outgoingFadeSec: number;
}
export interface CrossfadePlanOptions extends WaveformSilenceOptions {
/** Floor on the overlap (anti-click). Default 0.5 s (matches the engine clamp). */
minOverlapSec?: number;
/** Hard cap on the overlap. Default 12 s (engine max). */
maxOverlapSec?: number;
}
/**
* Pick a crossfade overlap + incoming start offset purely from what the two
* tracks *actually sound like* at the boundary — the user's `crossfadeSecs` is
* **not** involved in this mode ("work by fact"):
*
* `overlap = clamp( max(outroFadeA, introRiseB), min, cap )`
*
* The overlap spans exactly the outgoing track's natural fade-out and/or the
* incoming track's quiet buildup, positioned to **end** at A's content end
* (`audioEventHandlers` advances at `contentEndA overlap`) with B starting past
* its own leading silence. So:
* • a real fade-out / buildup → a long blend that overlaps the *audible* tail
* and head (B rises under A instead of blaring in after A went quiet);
* • two hard edges (no fade, no buildup) → collapses to the `min` floor — a
* quick blend, because there is simply nothing gradual to mix.
*
* Equal-power fades keep the summed loudness flat. Returns `overlapSec = min`
* (and `bStartSec = 0`) when an envelope is missing — the caller then leaves the
* normal engine-driven crossfade in charge.
*/
export function planCrossfadeTransition(
aBins: number[] | null | undefined,
aDurationSec: number,
bBins: number[] | null | undefined,
bDurationSec: number,
opts: CrossfadePlanOptions = {},
): CrossfadeTransitionPlan {
const min = Math.max(0.1, opts.minOverlapSec ?? DYNAMIC_OVERLAP_MIN_SEC);
const capUpper = opts.maxOverlapSec ?? DYNAMIC_OVERLAP_HARD_CAP_SEC;
const cap = Math.max(min, Math.min(capUpper, DYNAMIC_OVERLAP_ABSOLUTE_MAX_SEC));
const aShape = analyzeBoundary(aBins, aDurationSec, opts);
const bShape = analyzeBoundary(bBins, bDurationSec, opts);
const bStartSec = bShape.contentStartSec;
// Don't overlap more than ~90 % of the shorter content window (very short tracks).
const aContentLen = Math.max(0, aShape.contentEndSec - aShape.contentStartSec);
const bContentLen = Math.max(0, bShape.contentEndSec - bShape.contentStartSec);
const sustainable = Math.min(aContentLen || cap, bContentLen || cap) * 0.9;
const wanted = Math.max(aShape.outroFadeSec, bShape.introRiseSec);
// When we've analysed both edges and nothing fades (a hard, loud→loud meeting
// — typically a loud ending whose protective trailing silence we trim, into a
// loud intro), don't butt them together with a near-cut: blend over a standard
// ~2 s instead. A real fade-out/buildup keeps its (longer) content-driven span.
const haveBothEdges = !!aBins && !!bBins;
const target = haveBothEdges ? Math.max(wanted, STANDARD_BLEND_SEC) : (wanted || min);
const overlapSec = Math.max(min, Math.min(cap, sustainable, target));
// Scenario A: when A's own outro fade is the reason for the overlap (a real,
// trustworthy fade that's at least as long as B's intro rise), let the
// recording fade A out and skip the engine's fade-out — otherwise A would be
// attenuated twice (recording × engine) and vanish too soon under B.
const aRidesOwnFade =
aShape.outroFadeSec >= OWN_FADE_TRUST_SEC && aShape.outroFadeSec >= bShape.introRiseSec;
const outgoingFadeSec = aRidesOwnFade ? 0 : overlapSec;
return { overlapSec, bStartSec, outgoingFadeSec };
}