refactor(player): E.3 — extract waveform/normalization/seek pure helpers (#566)

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.
This commit is contained in:
Frank Stellmacher
2026-05-12 11:45:09 +02:00
committed by GitHub
parent 1521e4ea8f
commit d24514d67e
7 changed files with 203 additions and 43 deletions
+3 -43
View File
@@ -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<u8>` 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<number>).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<string, unknown>)
}).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 (
+32
View File
@@ -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);
});
});
+12
View File
@@ -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;
}
+28
View File
@@ -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);
});
});
+13
View File
@@ -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');
}
+78
View File
@@ -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<u8> as object)', () => {
const arrayLike = { 0: 10, 1: 20, length: 500 } as ArrayLike<number>;
// 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[] | 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<number>).map(x => Number(x) & 255);
} catch {
return null;
}
} else {
return null;
}
if (!waveformBlobLenOk(raw.length)) return null;
return raw;
}