mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
1521e4ea8f
commit
d24514d67e
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user