refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)

111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
This commit is contained in:
Frank Stellmacher
2026-05-14 14:27:44 +02:00
committed by GitHub
parent 2409a1fec8
commit 7a7a9f5e6b
324 changed files with 551 additions and 551 deletions
+118
View File
@@ -0,0 +1,118 @@
/** Makes raw ALSA device names more readable on Linux.
* Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
* e.g. "sysdefault:CARD=U192k" → "U192k"
* "hw:CARD=U192k,DEV=0" → "U192k (hw · PCM 0)"
* "hdmi:CARD=NVidia,DEV=1" → "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string)
* "iec958:CARD=PCH,DEV=0" → "PCH (S/PDIF)"
* Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */
export function formatAudioDeviceLabel(name: string): string {
const cardMatch = name.match(/CARD=([^,]+)/);
if (!cardMatch) return name;
const card = cardMatch[1];
const devM = name.match(/DEV=(\d+)/);
const devNum = devM ? parseInt(devM[1], 10) : null;
const subM = name.match(/SUBDEV=(\d+)/);
const subNum = subM ? parseInt(subM[1], 10) : null;
if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
if (name.startsWith('hdmi:')) {
const d = devNum !== null ? devNum : 0;
return `${card} (HDMI · DEV ${d})`;
}
if (name.startsWith('sysdefault:')) {
if (devNum !== null && devNum > 0) return `${card} (default · PCM ${devNum})`;
return card;
}
if (name.startsWith('plughw:')) {
if (devNum !== null) {
const sub = subNum !== null ? ` · sub ${subNum}` : '';
return `${card} (plug · PCM ${devNum}${sub})`;
}
return card;
}
if (name.startsWith('hw:')) {
if (devNum !== null) {
const sub = subNum !== null ? ` · sub ${subNum}` : '';
return `${card} (hw · PCM ${devNum}${sub})`;
}
return `${card} (hw)`;
}
if (name.startsWith('front:')) return `${card} (Front)`;
if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
// Other ALSA iface:card,dev — show plugin + PCM so identical cards differ
const iface = name.split(':')[0];
if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) {
if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`;
return `${card} (${iface})`;
}
return card;
}
/** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */
export function audioDeviceDuplicateHint(raw: string): string {
const cardM = raw.match(/CARD=([^,]+)/);
const devM = raw.match(/DEV=(\d+)/);
const subM = raw.match(/SUBDEV=(\d+)/);
const iface = raw.split(':')[0] || '';
const parts: string[] = [];
if (iface) parts.push(iface);
if (cardM) parts.push(cardM[1]);
if (devM) parts.push(`PCM ${devM[1]}`);
if (subM) parts.push(`sub ${subM[1]}`);
if (parts.length > 1) return parts.join(' · ');
return raw.length > 56 ? `${raw.slice(-53)}` : raw;
}
/** When several devices share the same display label, append a disambiguator. */
export function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string {
if (!duplicateBase) return baseLabel;
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
}
/** cpal order is arbitrary; sort by readable label, current OS default first. */
export function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
return [...devices].sort((a, b) => {
const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
const bDef = osDefaultDeviceId && b === osDefaultDeviceId;
if (aDef !== bDef) return aDef ? -1 : 1;
const la = formatAudioDeviceLabel(a);
const lb = formatAudioDeviceLabel(b);
const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' });
if (byLabel !== 0) return byLabel;
return a.localeCompare(b);
});
}
export function buildAudioDeviceSelectOptions(
devices: string[],
defaultLabel: string,
osDefaultDeviceId: string | null,
osDefaultMark: string,
pinnedDevice: string | null,
notInListSuffix: string,
): { value: string; label: string }[] {
const baseLabels = devices.map(formatAudioDeviceLabel);
const countByBase = new Map<string, number>();
for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
const pinned = pinnedDevice?.trim() || null;
const pinnedNotListed = !!(pinned && !devices.includes(pinned));
const ghost: { value: string; label: string }[] = pinnedNotListed
? (() => {
const base = formatAudioDeviceLabel(pinned);
let label = `${base} · ${notInListSuffix}`;
if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
return [{ value: pinned, label }];
})()
: [];
return [
{ value: '', label: defaultLabel },
...ghost,
...devices.map((d, i) => {
const base = baseLabels[i];
const dup = (countByBase.get(base) ?? 0) > 1;
let label = disambiguatedAudioDeviceLabel(d, base, dup);
if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
return { value: d, label };
}),
];
}
+24
View File
@@ -0,0 +1,24 @@
// ─── AutoEQ helpers ───────────────────────────────────────────────────────────
export interface AutoEqVariant { form: string; rig: string | null; source: string; }
export interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
/** Parses AutoEQ FixedBandEQ.txt format.
* Expected lines:
* Preamp: -5.5 dB
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
* ...
* Returns all 10 band gains as exact floats and the preamp value.
*/
export function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
allFilters.slice(0, 10).forEach((m, i) => {
gains[i] = parseFloat(m[1]);
});
return { gains, preamp };
}
+127
View File
@@ -0,0 +1,127 @@
import { EQ_BANDS } from '../../store/eqStore';
// ─── Frequency response canvas ────────────────────────────────────────────────
const SAMPLE_RATE = 44100;
const EQ_Q = 1.41;
export function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
if (Math.abs(gainDb) < 0.01) return 0;
const w0 = (2 * Math.PI * centerHz) / sampleRate;
const A = Math.pow(10, gainDb / 40);
const alpha = Math.sin(w0) / (2 * EQ_Q);
const b0 = 1 + alpha * A;
const b1 = -2 * Math.cos(w0);
const b2 = 1 - alpha * A;
const a0 = 1 + alpha / A;
const a1 = -2 * Math.cos(w0);
const a2 = 1 - alpha / A;
const w = (2 * Math.PI * freq) / sampleRate;
const cosW = Math.cos(w), sinW = Math.sin(w);
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
const numRe = b0 + b1 * cosW + b2 * cos2W;
const numIm = - b1 * sinW - b2 * sin2W;
const denRe = a0 + a1 * cosW + a2 * cos2W;
const denIm = - a1 * sinW - a2 * sin2W;
const numMag2 = numRe * numRe + numIm * numIm;
const denMag2 = denRe * denRe + denIm * denIm;
return 10 * Math.log10(numMag2 / denMag2);
}
export function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth;
const H = canvas.offsetHeight;
const fMin = 20, fMax = 20000;
const dbMin = -13, dbMax = 13;
const padL = 36, padR = 8, padT = 8, padB = 1;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
// Canvas hidden or not laid out yet (e.g. inside a collapsed <details> on macOS WebKit).
// Bail before allocating an invalid back-buffer; ResizeObserver redraws when the canvas
// gets real dimensions.
if (innerW <= 0 || innerH <= 0) return;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
const freqToX = (f: number) =>
padL + (Math.log10(f / fMin) / Math.log10(fMax / fMin)) * innerW;
const dbToY = (db: number) =>
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, W, H);
// Grid: dB lines
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
[-12, -6, 0, 6, 12].forEach(db => {
const y = dbToY(db);
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(W - padR, y);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.font = '9px monospace';
ctx.textAlign = 'right';
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
});
// Grid: frequency lines
[31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].forEach(f => {
const x = freqToX(f);
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.moveTo(x, padT);
ctx.lineTo(x, H - padB);
ctx.stroke();
});
// Zero line (brighter)
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, dbToY(0));
ctx.lineTo(W - padR, dbToY(0));
ctx.stroke();
// Frequency response curve
const points: [number, number][] = [];
const steps = innerW * 2;
for (let i = 0; i <= steps; i++) {
const f = fMin * Math.pow(fMax / fMin, i / steps);
let totalDb = 0;
for (let band = 0; band < 10; band++) {
totalDb += biquadPeakResponse(f, EQ_BANDS[band].freq, gains[band], SAMPLE_RATE);
}
totalDb = Math.max(dbMin, Math.min(dbMax, totalDb));
points.push([freqToX(f), dbToY(totalDb)]);
}
// Fill under curve
const grad = ctx.createLinearGradient(0, padT, 0, H);
grad.addColorStop(0, accentColor.replace(')', ', 0.25)').replace('rgb', 'rgba'));
grad.addColorStop(1, accentColor.replace(')', ', 0.0)').replace('rgb', 'rgba'));
ctx.beginPath();
ctx.moveTo(points[0][0], dbToY(0));
points.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.lineTo(points[points.length - 1][0], dbToY(0));
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// Curve line
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
points.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.8;
ctx.stroke();
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Before SQLite has integrated LUFS, match Rust `loudness_gain_placeholder_until_cache`:
* pivot at -14 LUFS, `true_peak = 0` (no EBU headroom cap), then add pre-attenuation from settings.
*/
const PLACEHOLDER_INTEGRATED_LUFS = -14;
export function loudnessGainPlaceholderUntilCacheDb(
targetLufs: number,
preAnalysisAttenuationDb: number,
): number {
const pre = Math.min(0, Math.max(-24, preAnalysisAttenuationDb));
let pivot = targetLufs - PLACEHOLDER_INTEGRATED_LUFS;
pivot = Math.max(-24, Math.min(24, pivot));
return Math.max(-24, Math.min(24, pivot + pre));
}
@@ -0,0 +1,21 @@
/** Pre-analysis level is defined relative to a 14 LUFS target; engine uses an offset for other targets. */
export const LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS = -14 as const;
/**
* dB actually applied by the engine (and placeholder math) for the current target.
* Example: ref 4.5 dB at 14, at 12 → 2.5 dB.
*/
export function effectiveLoudnessPreAnalysisAttenuationDb(
storedDbRelativeToRef: number,
targetLufs: number,
): number {
const stepped = Math.round(storedDbRelativeToRef * 2) / 2;
const effective = stepped + (targetLufs - LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS);
return Math.max(-24, Math.min(0, effective));
}
/** Stored [24, 0] dB, meaning “at 14 LUFS target”. */
export function clampStoredLoudnessPreAnalysisAttenuationRefDb(v: number): number {
const n = Math.round(v * 2) / 2;
return Math.max(-24, Math.min(0, n));
}
@@ -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;
}
@@ -0,0 +1,74 @@
/**
* Pure-helper characterization for `resolveReplayGainDb`.
*
* Picks track vs album gain based on mode + adjacent queue neighbours.
* Originally lived in `playerStore.ts`; extracted in M0 of the frontend
* refactor (2026-05-12).
*/
import type { Track } from '../../store/playerStoreTypes';
import { describe, expect, it } from 'vitest';
import { resolveReplayGainDb } from './resolveReplayGainDb';
describe('resolveReplayGainDb', () => {
const t = (overrides: Partial<Track> = {}): Track => ({
id: 'x',
title: 'x',
artist: 'x',
album: 'a',
albumId: 'a-1',
duration: 100,
...overrides,
});
it('returns null when ReplayGain is disabled', () => {
const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 });
expect(resolveReplayGainDb(track, null, null, false, 'track')).toBeNull();
expect(resolveReplayGainDb(track, null, null, false, 'album')).toBeNull();
expect(resolveReplayGainDb(track, null, null, false, 'auto')).toBeNull();
});
it('mode=track uses the track gain', () => {
const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 });
expect(resolveReplayGainDb(track, null, null, true, 'track')).toBe(-6);
});
it('mode=album uses the album gain when present', () => {
const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 });
expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-7);
});
it('mode=album falls back to track gain when album is missing', () => {
const track = t({ replayGainTrackDb: -6 });
expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-6);
});
it('mode=auto picks album gain when the prev neighbour shares the albumId', () => {
const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 });
const prev = t({ albumId: 'shared' });
expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-8);
});
it('mode=auto picks album gain when the next neighbour shares the albumId', () => {
const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 });
const next = t({ albumId: 'shared' });
expect(resolveReplayGainDb(track, null, next, true, 'auto')).toBe(-8);
});
it('mode=auto picks track gain when neither neighbour shares the albumId', () => {
const track = t({ albumId: 'a-1', replayGainTrackDb: -6, replayGainAlbumDb: -8 });
const other = t({ albumId: 'a-2' });
expect(resolveReplayGainDb(track, other, other, true, 'auto')).toBe(-6);
});
it('mode=auto treats a missing albumId as no-album-match (returns track gain)', () => {
const track = t({ albumId: '', replayGainTrackDb: -6, replayGainAlbumDb: -8 } as Track);
const prev = t({ albumId: '' } as Track);
expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-6);
});
it('returns null when both gains are missing', () => {
const track = t({});
expect(resolveReplayGainDb(track, null, null, true, 'track')).toBeNull();
expect(resolveReplayGainDb(track, null, null, true, 'album')).toBeNull();
expect(resolveReplayGainDb(track, null, null, true, 'auto')).toBeNull();
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { Track } from '../../store/playerStoreTypes';
/**
* Resolve the ReplayGain dB value for a track based on the configured mode.
* In 'auto' mode, picks album-gain when an adjacent queue neighbour shares the
* same albumId (i.e. the track is being played as part of an album), otherwise
* track-gain. Falls back to track-gain when album-gain is missing.
*/
export function resolveReplayGainDb(
track: Track,
prevTrack: Track | null | undefined,
nextTrack: Track | null | undefined,
enabled: boolean,
mode: 'track' | 'album' | 'auto',
): number | null {
if (!enabled) return null;
let useAlbum: boolean;
if (mode === 'album') {
useAlbum = true;
} else if (mode === 'track') {
useAlbum = false;
} else {
const albumId = track.albumId;
useAlbum = !!albumId && (
prevTrack?.albumId === albumId || nextTrack?.albumId === albumId
);
}
const value = useAlbum
? (track.replayGainAlbumDb ?? track.replayGainTrackDb)
: track.replayGainTrackDb;
return value ?? null;
}
+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');
}