From d24514d67e2ca513ee617e00310d59deae0fc8ae Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 11:45:09 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.3=20=E2=80=94=20extract?= =?UTF-8?q?=20waveform/normalization/seek=20pure=20helpers=20(#566)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small tranches out of playerStore.ts: - src/utils/waveformParse.ts — waveformBlobLenOk + coerceWaveformBins (the parser that handles number[] / Uint8Array / ArrayLike payloads Rust serializes as). - src/utils/normalizationCompare.ts — normalizationAlmostEqual (tolerant null-aware dB comparison). - src/utils/seekErrors.ts — isRecoverableSeekError (retry classifier for the Rust seek pipeline). Each gets focused unit tests. All four were file-private, no external callers — pure code move. playerStore 3556 → 3516 LOC. --- src/store/playerStore.ts | 46 +-------------- src/utils/normalizationCompare.test.ts | 32 +++++++++++ src/utils/normalizationCompare.ts | 12 ++++ src/utils/seekErrors.test.ts | 28 +++++++++ src/utils/seekErrors.ts | 13 +++++ src/utils/waveformParse.test.ts | 78 ++++++++++++++++++++++++++ src/utils/waveformParse.ts | 37 ++++++++++++ 7 files changed, 203 insertions(+), 43 deletions(-) create mode 100644 src/utils/normalizationCompare.test.ts create mode 100644 src/utils/normalizationCompare.ts create mode 100644 src/utils/seekErrors.test.ts create mode 100644 src/utils/seekErrors.ts create mode 100644 src/utils/waveformParse.test.ts create mode 100644 src/utils/waveformParse.ts diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 21b85b8c..1bab27ec 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -30,6 +30,9 @@ import { sameQueueTrackId, shallowCloneQueueTracks, } from '../utils/queueIdentity'; +import { coerceWaveformBins, waveformBlobLenOk } from '../utils/waveformParse'; +import { normalizationAlmostEqual } from '../utils/normalizationCompare'; +import { isRecoverableSeekError } from '../utils/seekErrors'; import { getWindowKind } from '../app/windowKind'; import { _resetQueueUndoStacksForTest, @@ -268,36 +271,6 @@ type WaveformCachePayload = { updatedAt: number; }; -/** v4: `500` peak + `500` mean-abs = `1000` bytes. Legacy single curve: `500` (treated as mean=max). */ -function waveformBlobLenOk(len: number): boolean { - return len === 500 || len === 1000; -} - -/** `Vec` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */ -function coerceWaveformBins(bins: unknown): number[] | null { - if (bins == null) return null; - let raw: number[] | null = null; - 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).map(x => Number(x) & 255); - } catch { - return null; - } - } else { - return null; - } - if (!waveformBlobLenOk(raw.length)) return null; - return raw; -} - type LoudnessCachePayload = { integratedLufs: number; truePeak: number; @@ -438,12 +411,6 @@ function emitNormalizationDebug(step: string, details?: Record) }).catch(() => {}); } -function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean { - if (a == null && b == null) return true; - if (a == null || b == null) return false; - return Math.abs(a - b) <= eps; -} - function deriveNormalizationSnapshot( track: Track, queue: Track[], @@ -593,13 +560,6 @@ function clearSeekFallbackRetry() { seekFallbackRetryTarget = null; } -function isRecoverableSeekError(msg: string): boolean { - return msg.includes('not seekable') - || msg.includes('audio sink not ready') - || msg.includes('audio seek busy') - || msg.includes('audio seek timeout'); -} - function scheduleSeekFallbackRetry(trackId: string, seconds: number) { const now = Date.now(); if ( diff --git a/src/utils/normalizationCompare.test.ts b/src/utils/normalizationCompare.test.ts new file mode 100644 index 00000000..05538cfd --- /dev/null +++ b/src/utils/normalizationCompare.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { normalizationAlmostEqual } from './normalizationCompare'; + +describe('normalizationAlmostEqual', () => { + it('returns true for two nulls', () => { + expect(normalizationAlmostEqual(null, null)).toBe(true); + }); + + it('returns false for mixed null / number', () => { + expect(normalizationAlmostEqual(null, 0)).toBe(false); + expect(normalizationAlmostEqual(0, null)).toBe(false); + }); + + it('returns true for values within the default epsilon (0.12)', () => { + expect(normalizationAlmostEqual(-14.0, -14.05)).toBe(true); + expect(normalizationAlmostEqual(-14.0, -14.12)).toBe(true); + }); + + it('returns false just past the default epsilon', () => { + expect(normalizationAlmostEqual(-14.0, -14.13)).toBe(false); + }); + + it('honours a custom epsilon', () => { + expect(normalizationAlmostEqual(-14.0, -14.5, 1.0)).toBe(true); + expect(normalizationAlmostEqual(-14.0, -14.5, 0.4)).toBe(false); + }); + + it('treats exact equality as equal', () => { + expect(normalizationAlmostEqual(-10, -10)).toBe(true); + expect(normalizationAlmostEqual(0, 0)).toBe(true); + }); +}); diff --git a/src/utils/normalizationCompare.ts b/src/utils/normalizationCompare.ts new file mode 100644 index 00000000..e2d1a9fe --- /dev/null +++ b/src/utils/normalizationCompare.ts @@ -0,0 +1,12 @@ +/** + * Tolerant equality for normalization gain values. Both arguments may be null + * (meaning "no override / unknown"); two nulls compare equal, mixed null/number + * does not. Default epsilon (0.12 dB) is the threshold below which the audible + * difference is negligible — used to skip UI updates that would otherwise jitter + * on every analysis-cache refresh. + */ +export function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + return Math.abs(a - b) <= eps; +} diff --git a/src/utils/seekErrors.test.ts b/src/utils/seekErrors.test.ts new file mode 100644 index 00000000..f48a866e --- /dev/null +++ b/src/utils/seekErrors.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { isRecoverableSeekError } from './seekErrors'; + +describe('isRecoverableSeekError', () => { + it.each([ + 'not seekable', + 'audio sink not ready', + 'audio seek busy', + 'audio seek timeout', + ])('classifies "%s" as recoverable', msg => { + expect(isRecoverableSeekError(msg)).toBe(true); + }); + + it('matches when the marker is embedded in a longer message', () => { + expect(isRecoverableSeekError('seek failed: audio sink not ready yet')).toBe(true); + expect(isRecoverableSeekError('error: audio seek timeout after 6000ms')).toBe(true); + }); + + it('returns false for unrelated errors', () => { + expect(isRecoverableSeekError('decoder failed')).toBe(false); + expect(isRecoverableSeekError('file not found')).toBe(false); + expect(isRecoverableSeekError('')).toBe(false); + }); + + it('is case-sensitive (Rust messages are stable)', () => { + expect(isRecoverableSeekError('Audio Sink Not Ready')).toBe(false); + }); +}); diff --git a/src/utils/seekErrors.ts b/src/utils/seekErrors.ts new file mode 100644 index 00000000..d00eff67 --- /dev/null +++ b/src/utils/seekErrors.ts @@ -0,0 +1,13 @@ +/** + * Classify a Rust-side seek error message as "retryable" — set when the audio + * pipeline is still settling (sink not yet bound, a previous seek still + * draining, codec hasn't reported seekability) rather than a hard failure. + * Callers retry these with a bounded interval; everything else surfaces a + * single toast and aborts. + */ +export function isRecoverableSeekError(msg: string): boolean { + return msg.includes('not seekable') + || msg.includes('audio sink not ready') + || msg.includes('audio seek busy') + || msg.includes('audio seek timeout'); +} diff --git a/src/utils/waveformParse.test.ts b/src/utils/waveformParse.test.ts new file mode 100644 index 00000000..91ac4443 --- /dev/null +++ b/src/utils/waveformParse.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { coerceWaveformBins, waveformBlobLenOk } from './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 as object)', () => { + const arrayLike = { 0: 10, 1: 20, length: 500 } as ArrayLike; + // Fill remaining slots with zeros to match expected shape + const proxy: ArrayLike = { + length: 500, + ...Object.fromEntries(new Array(500).fill(0).map((_, i) => [i, i === 0 ? 10 : i === 1 ? 20 : 0])), + } as ArrayLike; + 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(); + }); +}); diff --git a/src/utils/waveformParse.ts b/src/utils/waveformParse.ts new file mode 100644 index 00000000..1143031d --- /dev/null +++ b/src/utils/waveformParse.ts @@ -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` 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[] | null = null; + 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).map(x => Number(x) & 255); + } catch { + return null; + } + } else { + return null; + } + if (!waveformBlobLenOk(raw.length)) return null; + return raw; +}