refactor(waveform): move waveform data pipeline back to audio-core

The M0 pilot mis-membered the waveform data pipeline into features/waveform,
creating a core->feature inversion: ~12 audio-core files in store/ imported
refreshWaveformForTrack / fetchWaveformBins / bumpWaveformRefreshGen from the
feature barrel. These are playback coordination (write waveformBins into
playerStore on track change, last-write-wins gen guard), not seekbar rendering.

Move waveformRefresh + waveformRefreshGen (+ tests) to src/store/ and
waveformParse (+ test) to src/utils/waveform/ (beside waveformSilence). The
feature now holds only the seekbar UI (WaveformSeek/SeekbarPreview + their
hooks/render utils); its barrel exports UI only. Core consumers import the
pipeline directly from @/store/*. Restores the no-core->feature-inversion rule.
This commit is contained in:
Psychotoxical
2026-06-30 00:07:34 +02:00
parent e945da693a
commit 70c145db06
19 changed files with 32 additions and 28 deletions
+11 -7
View File
@@ -1,11 +1,15 @@
/**
* Waveform seekbar feature — the canvas seekbar, its waveform-bin loading, and
* the per-track refresh generation guard. Co-located mirror of `cover/` /
* `music-network/`. `waveformSilence` stays in `utils/waveform/` (crossfade /
* auto-dj concern, not seekbar rendering).
* Waveform seekbar feature — the canvas seekbar (rendering only). Co-located
* mirror of `cover/` / `music-network/`.
*
* The waveform DATA pipeline is audio-core, NOT this feature: the audio engine
* calls it on track change to write `waveformBins` into the player store. It
* therefore lives in core, consumed directly (never via this barrel):
* - `@/store/waveformRefresh` — `refreshWaveformForTrack` / `fetchWaveformBins`
* - `@/store/waveformRefreshGen` — `bumpWaveformRefreshGen` / `getWaveformRefreshGen`
* - `@/utils/waveform/waveformParse` — bin coercion (alongside `waveformSilence`,
* the crossfade / auto-dj silence helper)
* Keeping these in core preserves the iron rule (no core→feature inversion).
*/
export { default as WaveformSeek } from './components/WaveformSeek';
export { SeekbarPreview } from './components/WaveformSeekPreview';
export { fetchWaveformBins, refreshWaveformForTrack } from './store/waveformRefresh';
export type { WaveformCachePayload } from './store/waveformRefresh';
export { bumpWaveformRefreshGen, getWaveformRefreshGen } from './store/waveformRefreshGen';
@@ -1,99 +0,0 @@
/**
* `refreshWaveformForTrack` fetches an analysis row from Rust and applies
* it to the player store — but only if the refresh generation hasn't been
* bumped meanwhile and the track is still current. The tests pin both
* guards and the success / null-row / empty-bins / error branches.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
coerceWaveformBinsMock: vi.fn((bins: unknown) => {
if (bins == null) return null;
if (Array.isArray(bins) && bins.length === 0) return null;
return bins as number[];
}),
playerSnapshot: {
currentTrack: null as { id: string } | null,
},
playerSetStateMock: vi.fn(),
gen: 0,
getGenMock: vi.fn(() => hoisted.gen),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('@/features/waveform/utils/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
vi.mock('@/store/playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerSnapshot,
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('@/features/waveform/store/waveformRefreshGen', () => ({
getWaveformRefreshGen: hoisted.getGenMock,
}));
import { refreshWaveformForTrack } from '@/features/waveform/store/waveformRefresh';
beforeEach(() => {
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(null);
hoisted.coerceWaveformBinsMock.mockClear();
hoisted.playerSetStateMock.mockClear();
hoisted.playerSnapshot.currentTrack = null;
hoisted.gen = 0;
});
describe('refreshWaveformForTrack', () => {
it('is a no-op for empty trackId', async () => {
await refreshWaveformForTrack('');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
});
it('discards results when the gen has been bumped since the call started', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockImplementationOnce(async () => {
hoisted.gen = 99; // simulate concurrent bump
return { bins: [1, 2, 3] };
});
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
});
it('skips when the track is no longer current after the fetch', async () => {
hoisted.playerSnapshot.currentTrack = { id: 'other' };
hoisted.invokeMock.mockResolvedValueOnce({ bins: [1, 2, 3] });
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
});
it('blanks bins when the row is null', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockResolvedValueOnce(null);
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
});
it('blanks bins when coerceWaveformBins returns null (invalid shape)', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockResolvedValueOnce({ bins: 'garbage' });
hoisted.coerceWaveformBinsMock.mockReturnValueOnce(null);
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
});
it('applies the coerced bins on a clean fetch', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockResolvedValueOnce({ bins: [10, 20, 30] });
hoisted.coerceWaveformBinsMock.mockReturnValueOnce([10, 20, 30]);
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: [10, 20, 30] });
});
it('swallows fetch errors silently (placeholder waveform stays)', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockRejectedValueOnce(new Error('boom'));
await expect(refreshWaveformForTrack('t1')).resolves.toBeUndefined();
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
});
});
@@ -1,73 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { coerceWaveformBins } from '@/features/waveform/utils/waveformParse';
import { getPlaybackIndexKey } from '@/utils/playback/playbackServer';
import { usePlayerStore } from '@/store/playerStore';
import { getWaveformRefreshGen } from '@/features/waveform/store/waveformRefreshGen';
/** Subsonic-server waveform-cache row as Rust hands it back. */
export type WaveformCachePayload = {
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
bins: number[] | Uint8Array;
binCount: number;
isPartial: boolean;
knownUntilSec: number;
durationSec: number;
updatedAt: number;
};
/**
* Fetch the cached waveform row for `trackId` from Rust and apply its bins
* to the player store — but only if (a) the refresh generation snapshot
* still matches (no newer invalidation has fired meanwhile) and (b) the
* track is still the current one. Best-effort: any failure leaves the
* seekbar with the placeholder waveform.
*/
/**
* Fetch a track's cached waveform bins **without touching the player store** —
* used by the silence-aware crossfade to inspect the *next* track's leading
* silence while a different track is still playing (writing `waveformBins` here
* would replace the current track's seekbar). Returns `null` on a cold miss /
* any failure so callers degrade to no-trim.
*/
export async function fetchWaveformBins(
trackId: string,
serverId?: string | null,
): Promise<number[] | null> {
if (!trackId) return null;
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
trackId,
serverId: serverId ?? getPlaybackIndexKey() ?? null,
});
const bins = row ? coerceWaveformBins(row.bins) : null;
return bins && bins.length > 0 ? bins : null;
} catch {
return null;
}
}
export async function refreshWaveformForTrack(trackId: string): Promise<void> {
if (!trackId) return;
const gen = getWaveformRefreshGen(trackId);
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
trackId,
serverId: getPlaybackIndexKey() || null,
});
if (getWaveformRefreshGen(trackId) !== gen) return;
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
const bins = row ? coerceWaveformBins(row.bins) : null;
if (!bins || bins.length === 0) {
usePlayerStore.setState({
waveformBins: null,
});
return;
}
usePlayerStore.setState({
waveformBins: bins,
});
} catch {
// best-effort; seekbar falls back to placeholder waveform
}
}
@@ -1,44 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
_resetWaveformRefreshGenForTest,
bumpWaveformRefreshGen,
getWaveformRefreshGen,
} from '@/features/waveform/store/waveformRefreshGen';
afterEach(() => {
_resetWaveformRefreshGenForTest();
});
describe('waveformRefreshGen', () => {
it('returns 0 for an unknown track', () => {
expect(getWaveformRefreshGen('missing')).toBe(0);
});
it('increments the per-track generation on each bump', () => {
bumpWaveformRefreshGen('t1');
expect(getWaveformRefreshGen('t1')).toBe(1);
bumpWaveformRefreshGen('t1');
expect(getWaveformRefreshGen('t1')).toBe(2);
});
it('keeps tracks independent', () => {
bumpWaveformRefreshGen('a');
bumpWaveformRefreshGen('a');
bumpWaveformRefreshGen('b');
expect(getWaveformRefreshGen('a')).toBe(2);
expect(getWaveformRefreshGen('b')).toBe(1);
});
it('is a no-op for an empty trackId', () => {
bumpWaveformRefreshGen('');
expect(getWaveformRefreshGen('')).toBe(0);
});
it('captures the stale-result guard pattern: a snapshot is invalidated by a later bump', () => {
bumpWaveformRefreshGen('t1');
const snapshot = getWaveformRefreshGen('t1');
expect(snapshot).toBe(1);
bumpWaveformRefreshGen('t1');
expect(getWaveformRefreshGen('t1')).not.toBe(snapshot);
});
});
@@ -1,32 +0,0 @@
/**
* Last-write-wins generation counter per track. Avoids applying a stale
* empty waveform read when `analysis:waveform-updated` bumps the gen after
* SQLite commit while an older `analysis_get_waveform_for_track` is still
* in flight. Gen is bumped only on explicit invalidation (waveform-updated,
* analysis storage), not on every `refreshWaveformForTrack` call —
* otherwise bursts (Lucky Mix, queue) cancel each other.
*
* Typical usage:
*
* const gen = getWaveformRefreshGen(trackId);
* const row = await invoke('analysis_get_waveform_for_track', { trackId });
* if (getWaveformRefreshGen(trackId) !== gen) return; // stale result
*/
const waveformRefreshGenByTrackId: Record<string, number> = {};
export function bumpWaveformRefreshGen(trackId: string): void {
if (!trackId) return;
waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1;
}
export function getWaveformRefreshGen(trackId: string): number {
return waveformRefreshGenByTrackId[trackId] ?? 0;
}
/** Test-only: wipe the per-track generations so each spec starts fresh. */
export function _resetWaveformRefreshGenForTest(): void {
for (const k of Object.keys(waveformRefreshGenByTrackId)) {
delete waveformRefreshGenByTrackId[k];
}
}
@@ -1,77 +0,0 @@
import { describe, expect, it } from 'vitest';
import { coerceWaveformBins, waveformBlobLenOk } from '@/features/waveform/utils/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();
});
});
@@ -1,37 +0,0 @@
/**
* 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;
}