mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user