From c52b0c426d22a4f799e4f90e3ae2d9b1bcbb17ad Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:36:29 +0300 Subject: [PATCH 1/6] feat(audio): port the Android equalizer model, presets and AutoEQ format --- src/lib/audio/eq/autoEqFormat.test.ts | 112 ++++++++++ src/lib/audio/eq/autoEqFormat.ts | 126 +++++++++++ src/lib/audio/eq/builtInPresets.ts | 134 ++++++++++++ src/lib/audio/eq/legacyMigration.ts | 47 ++++ src/lib/audio/eq/limits.ts | 124 +++++++++++ src/lib/audio/eq/models.ts | 43 ++++ src/lib/audio/eq/stateJson.test.ts | 78 +++++++ src/lib/audio/eq/stateJson.ts | 145 +++++++++++++ src/lib/audio/eq/stateOps.test.ts | 252 ++++++++++++++++++++++ src/lib/audio/eq/stateOps.ts | 297 ++++++++++++++++++++++++++ src/lib/i18n/strings.json | 13 ++ 11 files changed, 1371 insertions(+) create mode 100644 src/lib/audio/eq/autoEqFormat.test.ts create mode 100644 src/lib/audio/eq/autoEqFormat.ts create mode 100644 src/lib/audio/eq/builtInPresets.ts create mode 100644 src/lib/audio/eq/legacyMigration.ts create mode 100644 src/lib/audio/eq/limits.ts create mode 100644 src/lib/audio/eq/stateJson.test.ts create mode 100644 src/lib/audio/eq/stateJson.ts create mode 100644 src/lib/audio/eq/stateOps.test.ts create mode 100644 src/lib/audio/eq/stateOps.ts diff --git a/src/lib/audio/eq/autoEqFormat.test.ts b/src/lib/audio/eq/autoEqFormat.test.ts new file mode 100644 index 00000000..7b5e0a1b --- /dev/null +++ b/src/lib/audio/eq/autoEqFormat.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { formatAutoEq, parseAutoEq } from "./autoEqFormat"; +import { band, EqLimits, type EqCurve } from "./models"; + +// Ported from Android's AutoEqFormatTest. +describe("AutoEqFormat", () => { + it("reads an AutoEQ file and skips its empty filters", () => { + const text = [ + "Preamp: -2.0 dB", + "Filter 1: ON PK Fc 60 Hz Gain 4.0 dB Q 0.710", + "Filter 2: ON LSC Fc 105 Hz Gain 5.5 dB Q 0.70", + "Filter 3: ON HSC Fc 10000 Hz Gain -2.1 dB Q 0.70", + "Filter 4: OFF PK Fc 0 Hz Gain 0.0 dB Q 0.000", + "Filter 5: OFF PK Fc 0 Hz Gain 0.0 dB Q 0.000", + ].join("\n"); + + const result = parseAutoEq(text)!; + + expect(result.curve.preamp).toBe(-2); + expect(result.curve.bands).toEqual([ + band(60, 4, 0.71, "PEAK"), + band(105, 5.5, 0.7, "LOW_SHELF"), + band(10_000, -2.1, 0.7, "HIGH_SHELF"), + ]); + expect(result.skippedFilters).toBe(2); + }); + + it("an ON filter at 0 Hz or Q 0 is refused rather than turned into NaN", () => { + const text = "Filter 1: ON PK Fc 0 Hz Gain 3 dB Q 1\nFilter 2: ON PK Fc 500 Hz Gain 3 dB Q 0\nFilter 3: ON PK Fc 800 Hz Gain 1 dB Q 1"; + const result = parseAutoEq(text)!; + expect(result.curve.bands).toEqual([band(800, 1, 1)]); + expect(result.skippedFilters).toBe(2); + expect(parseAutoEq("Filter 1: ON PK Fc 0 Hz Gain 3 dB Q 0")).toBeNull(); + }); + + it("accepts comma decimals, lower case, short type names and bandwidth", () => { + const text = [ + "preamp: -3,5 dB", + "filter 1: on pk fc 1000 hz gain 2,5 db q 1,41", + "Filter 2: ON LS Fc 80 Hz Gain 3 dB", + "Filter 3: ON PK Fc 2000 Hz Gain -1 dB BW Oct 1.0", + "Filter 4: ON HP Fc 30 Hz Q 0.5", + "Filter 5: ON LSC 12 dB Fc 90 Hz Gain 2 dB", + ].join("\n"); + + const bands = parseAutoEq(text)!.curve.bands; + + expect(bands[0]).toEqual(band(1_000, 2.5, 1.41, "PEAK")); + expect(bands[1]).toEqual(band(80, 3, EqLimits.DEFAULT_SHELF_Q, "LOW_SHELF")); + expect(Math.abs(bands[2]!.q - 1.414)).toBeLessThan(0.001); + expect(bands[3]).toEqual(band(30, 0, 0.5, "HIGH_PASS")); + expect(bands[4]).toEqual(band(90, 2, EqLimits.DEFAULT_SHELF_Q, "LOW_SHELF")); + }); + + it("a thousands separator in a frequency is not a decimal mark", () => { + expect(parseAutoEq("Filter 1: ON PK Fc 1,000 Hz Gain 2 dB Q 1")!.curve.bands[0]!.frequency).toBe(1_000); + }); + + it("counts unsupported filters and other commands", () => { + const text = [ + "# headphone target", + "Device: Speakers", + "Channel: L", + "Filter 1: ON NO Fc 50 Hz Q 30", + "Filter 2: ON PK Fc 500 Hz Gain 1 dB Q 1", + ].join("\n"); + + const result = parseAutoEq(text)!; + + expect(result.curve.bands).toHaveLength(1); + expect(result.skippedFilters).toBe(1); + expect(result.ignoredLines).toBe(2); + }); + + it("keeps the first twenty filters", () => { + const text = Array.from({ length: 25 }, (_, i) => `Filter ${i + 1}: ON PK Fc ${(i + 1) * 100} Hz Gain 1 dB Q 1`).join("\n"); + const result = parseAutoEq(text)!; + expect(result.curve.bands).toHaveLength(EqLimits.MAX_BANDS); + expect(result.truncatedFilters).toBe(5); + }); + + it("clamps values outside the limits", () => { + expect(parseAutoEq("Filter 1: ON PK Fc 5 Hz Gain 40 dB Q 50")!.curve.bands[0]).toEqual( + band(EqLimits.MIN_FREQUENCY, EqLimits.MAX_GAIN, EqLimits.MAX_Q, "PEAK"), + ); + }); + + it("text without filters or preamp is not a preset", () => { + expect(parseAutoEq("")).toBeNull(); + expect(parseAutoEq("hello\nworld")).toBeNull(); + }); + + it("exported text reads back to the same curve", () => { + const curve: EqCurve = { + preamp: -4.5, + bands: [ + band(64, 3.5, 0.7, "LOW_SHELF"), + band(910, -2, 1.41), + band(15_000, 0, 0.707, "LOW_PASS"), + band(3_000, 2, 1, "PEAK", false), + ], + }; + + const text = formatAutoEq(curve); + const parsed = parseAutoEq(text)!; + + expect(text).toContain("Filter 3: ON LPQ Fc 15000 Hz Q 0.707"); + expect(parsed.curve).toEqual({ ...curve, bands: curve.bands.slice(0, 3) }); + expect(parsed.skippedFilters).toBe(1); + }); +}); diff --git a/src/lib/audio/eq/autoEqFormat.ts b/src/lib/audio/eq/autoEqFormat.ts new file mode 100644 index 00000000..b0eb61e7 --- /dev/null +++ b/src/lib/audio/eq/autoEqFormat.ts @@ -0,0 +1,126 @@ +import { sanitizeBand, sanitizeCurve } from "./limits"; +import { APO_CODES, defaultQ, EqLimits, hasGain, type EqBand, type EqCurve, type EqFilterType } from "./models"; + +/** What an EqualizerAPO / AutoEQ `ParametricEQ.txt` import produced, and what it left out. */ +export interface AutoEqImport { + curve: EqCurve; + skippedFilters: number; + ignoredLines: number; + truncatedFilters: number; +} + +/* + * Reads and writes the EqualizerAPO parametric text format that AutoEQ publishes, for example + * `Filter 1: ON PK Fc 105 Hz Gain -2.3 dB Q 0.70`. Ported from Android's `AutoEqFormat.kt`: + * lenient about case, spacing, comma decimals and the short type names; strict about anything that + * would break the audio, such as Q 0. + */ +const FILTER_PREFIX = /^filter\s*\d*\s*:/i; +const PREAMP_PREFIX = /^preamp\s*:/i; +const NUMBER = /^[-+]?\d+(?:[.,]\d+)?$/; +const THOUSANDS = /^\d{1,2},\d{3}$/; + +const TYPES: Record = { + PK: "PEAK", + PEQ: "PEAK", + MODAL: "PEAK", + LS: "LOW_SHELF", + LSC: "LOW_SHELF", + LSQ: "LOW_SHELF", + HS: "HIGH_SHELF", + HSC: "HIGH_SHELF", + HSQ: "HIGH_SHELF", + LP: "LOW_PASS", + LPQ: "LOW_PASS", + HP: "HIGH_PASS", + HPQ: "HIGH_PASS", +}; + +const tokens = (text: string): string[] => text.trim().split(/\s+/).filter(Boolean); + +const toNumber = (text: string | undefined): number | null => + text !== undefined && NUMBER.test(text) ? Number(text.replace(",", ".")) : null; + +/** "1,000" in a frequency is a thousands separator: no one sets a filter at 1 Hz. */ +const toFrequency = (text: string): number | null => + THOUSANDS.test(text) ? Number(text.replace(",", "")) : toNumber(text); + +function bandwidthToQ(octaves: number): number | null { + if (octaves <= 0) return null; + const ratio = Math.pow(2, octaves); + return Math.sqrt(ratio) / (ratio - 1); +} + +function parseFilter(parts: string[]): EqBand | null { + const [state, code] = parts; + if (state?.toUpperCase() !== "ON" || code === undefined) return null; + const type = TYPES[code.toUpperCase()]; + if (!type) return null; + let frequency: number | null = null; + let gain = 0; + let q: number | null = null; + let i = 2; + while (i < parts.length) { + const key = parts[i]?.toLowerCase(); + const next = parts[i + 1]; + const value = toNumber(next); + if (key === "fc" && next !== undefined && value !== null) { + frequency = toFrequency(next); + i += 2; + } else if (key === "gain" && value !== null) { + gain = value; + i += 2; + } else if (key === "q" && value !== null) { + q = value; + i += 2; + } else if (key === "bw" && parts[i + 1]?.toLowerCase() === "oct") { + const octaves = toNumber(parts[i + 2]); + q = (octaves !== null ? bandwidthToQ(octaves) : null) ?? q; + i += 3; + } else { + i++; + } + } + if (frequency === null) return null; + return sanitizeBand({ frequency, gain, q: q ?? defaultQ(type), type, enabled: true }); +} + +/** Null when the text holds no usable filter and no preamp. */ +export function parseAutoEq(text: string): AutoEqImport | null { + let preamp: number | null = null; + const bands: EqBand[] = []; + let skipped = 0; + let ignored = 0; + for (const raw of text.split(/\r?\n|\r/)) { + const line = (raw.split("#")[0] ?? "").trim(); + if (!line) continue; + const afterColon = line.slice(line.indexOf(":") + 1); + if (PREAMP_PREFIX.test(line)) { + preamp = tokens(afterColon).map(toNumber).find((n) => n !== null) ?? preamp; + } else if (FILTER_PREFIX.test(line)) { + const parsed = parseFilter(tokens(afterColon)); + if (parsed) bands.push(parsed); + else skipped++; + } else { + ignored++; + } + } + if (bands.length === 0 && preamp === null) return null; + return { + curve: sanitizeCurve({ preamp: preamp ?? 0, bands: bands.slice(0, EqLimits.MAX_BANDS) }), + skippedFilters: skipped, + ignoredLines: ignored, + truncatedFilters: Math.max(0, bands.length - EqLimits.MAX_BANDS), + }; +} + +export function formatAutoEq(curve: EqCurve): string { + let out = `Preamp: ${curve.preamp.toFixed(1)} dB\n`; + curve.bands.forEach((b, index) => { + out += `Filter ${index + 1}: ${b.enabled ? "ON " : "OFF "}${APO_CODES[b.type]}`; + out += ` Fc ${b.frequency.toFixed(0)} Hz`; + if (hasGain(b.type)) out += ` Gain ${b.gain.toFixed(1)} dB`; + out += ` Q ${b.q.toFixed(3)}\n`; + }); + return out; +} diff --git a/src/lib/audio/eq/builtInPresets.ts b/src/lib/audio/eq/builtInPresets.ts new file mode 100644 index 00000000..068db8e2 --- /dev/null +++ b/src/lib/audio/eq/builtInPresets.ts @@ -0,0 +1,134 @@ +import type { StringKey } from "../../i18n/index"; +import { band, EqLimits, type EqBand, type EqCurve } from "./models"; + +export interface BuiltInEqPreset { + id: string; + nameKey: StringKey; + curve: EqCurve; +} + +/** + * The presets Flow ships, with Android's stable ids (`BuiltInEqPresets.kt`), so a preset picked on + * one device is the same preset on the other. Shelves use the Butterworth Q. + */ +export const FLAT_PRESET_ID = "builtin:flat"; + +const SHELF_Q = EqLimits.DEFAULT_SHELF_Q; + +const preset = (key: string, nameKey: StringKey, preamp: number, ...bands: EqBand[]): BuiltInEqPreset => ({ + id: `builtin:${key}`, + nameKey, + curve: { preamp, bands }, +}); + +export const BUILT_IN_EQ_PRESETS: readonly BuiltInEqPreset[] = [ + { id: FLAT_PRESET_ID, nameKey: "eq_preset_flat", curve: { preamp: 0, bands: [] } }, + preset( + "acoustic", + "eq_preset_acoustic", + 0, + band(100, 2, 0.7, "PEAK"), + band(400, -1, 1, "PEAK"), + band(2_000, 1.5, 1, "PEAK"), + band(6_000, 3, SHELF_Q, "HIGH_SHELF"), + ), + preset("bass_boost", "eq_preset_bass_boost", 0, band(60, 9, SHELF_Q, "LOW_SHELF"), band(200, 3, 0.7, "PEAK")), + preset( + "classical", + "eq_preset_classical", + 0, + band(100, 3, SHELF_Q, "LOW_SHELF"), + band(500, -2, 1, "PEAK"), + band(2_500, 2, 1, "PEAK"), + band(10_000, 3, SHELF_Q, "HIGH_SHELF"), + ), + preset( + "electronic", + "eq_preset_electronic", + -1, + band(50, 7, SHELF_Q, "LOW_SHELF"), + band(400, -1, 1, "PEAK"), + band(2_000, 2.5, 1, "PEAK"), + band(8_000, 5, SHELF_Q, "HIGH_SHELF"), + ), + preset( + "heavy_metal", + "eq_preset_heavy_metal", + -1, + band(60, 6, SHELF_Q, "LOW_SHELF"), + band(250, 2, 1, "PEAK"), + band(700, -3, 1, "PEAK"), + band(2_000, 1, 1, "PEAK"), + band(4_000, 4, 1, "PEAK"), + band(10_000, 5, SHELF_Q, "HIGH_SHELF"), + ), + preset( + "hip_hop", + "eq_preset_hip_hop", + -1, + band(55, 8, SHELF_Q, "LOW_SHELF"), + band(250, 2, 1, "PEAK"), + band(1_000, -2, 1, "PEAK"), + band(5_000, 3, 1, "PEAK"), + ), + preset( + "jazz", + "eq_preset_jazz", + 0, + band(100, 2, SHELF_Q, "LOW_SHELF"), + band(500, -1, 1, "PEAK"), + band(2_500, 1.5, 1, "PEAK"), + band(10_000, 2, SHELF_Q, "HIGH_SHELF"), + ), + preset( + "metal", + "eq_preset_metal", + 0, + band(60, 4, SHELF_Q, "LOW_SHELF"), + band(300, 3, 1.41, "PEAK"), + band(1_500, 2, 1, "PEAK"), + band(4_000, 4, 1, "PEAK"), + ), + preset( + "pop", + "eq_preset_pop", + 0, + band(100, 4, 0.7, "PEAK"), + band(2_500, 2.5, 1, "PEAK"), + band(12_000, 3.5, SHELF_Q, "HIGH_SHELF"), + ), + preset( + "rnb", + "eq_preset_rnb", + 0, + band(70, 6, SHELF_Q, "LOW_SHELF"), + band(300, -2, 1, "PEAK"), + band(1_200, -1.5, 1, "PEAK"), + band(4_000, 2, 1, "PEAK"), + ), + preset( + "rock", + "eq_preset_rock", + 0, + band(60, 5, SHELF_Q, "LOW_SHELF"), + band(230, 3, 1, "PEAK"), + band(910, -2, 1, "PEAK"), + band(3_600, 3.5, 1, "PEAK"), + band(14_000, 5, SHELF_Q, "HIGH_SHELF"), + ), + preset( + "vocal", + "eq_preset_vocal", + 0, + band(100, -2, SHELF_Q, "LOW_SHELF"), + band(1_000, 3, 2, "PEAK"), + band(5_000, 2, 1, "PEAK"), + ), +]; + +const BY_ID = new Map(BUILT_IN_EQ_PRESETS.map((p) => [p.id, p])); + +export const builtInPreset = (id: string | null | undefined): BuiltInEqPreset | undefined => + id ? BY_ID.get(id) : undefined; + +export const isBuiltInPreset = (id: string | null | undefined): boolean => !!id && BY_ID.has(id); diff --git a/src/lib/audio/eq/legacyMigration.ts b/src/lib/audio/eq/legacyMigration.ts new file mode 100644 index 00000000..16e8cfae --- /dev/null +++ b/src/lib/audio/eq/legacyMigration.ts @@ -0,0 +1,47 @@ +import { GraphicEq, roundTo } from "./limits"; +import type { EqState } from "./models"; +import { defaultEqState, sanitizeState } from "./stateOps"; + +/** Where the first desktop equalizer kept its switch and ten gains, beside the music volume. */ +export const LEGACY_MUSIC_CONFIG_KEY = "flow_music_player"; + +export interface LegacyEqConfig { + eqEnabled?: unknown; + eqGains?: unknown; +} + +/** + * The first desktop equalizer as an `EqState`, or null when it was never used. Its ten bands sat + * on the same octaves as Android's graphic mode (32 and 64 Hz are now 31 and 63), so the gains + * carry over as a graphic curve; its end bands were shelves and are now peaks, a small change at + * both ends of the range. + */ +export function migrateLegacyEq(config: LegacyEqConfig | null): EqState | null { + if (!config) return null; + const raw = Array.isArray(config.eqGains) ? config.eqGains : []; + const gains = GraphicEq.FREQUENCIES.map((_, i) => { + const value = Number(raw[i]); + const gain = Number.isFinite(value) ? Math.max(-GraphicEq.MAX_GAIN, Math.min(GraphicEq.MAX_GAIN, value)) : 0; + return roundTo(gain, GraphicEq.STEP); + }); + const enabled = config.eqEnabled === true; + if (!enabled && gains.every((g) => g === 0)) return null; + return sanitizeState({ + ...defaultEqState(), + enabled, + mode: "GRAPHIC", + graphic: { presetId: null, curve: GraphicEq.curveOf(gains) }, + }); +} + +/** The legacy fields as the old store saved them, or null when there are none. */ +export function readLegacyEqConfig(): LegacyEqConfig | null { + try { + const saved = localStorage.getItem(LEGACY_MUSIC_CONFIG_KEY); + if (!saved) return null; + const parsed: unknown = JSON.parse(saved); + return typeof parsed === "object" && parsed !== null ? (parsed as LegacyEqConfig) : null; + } catch { + return null; + } +} diff --git a/src/lib/audio/eq/limits.ts b/src/lib/audio/eq/limits.ts new file mode 100644 index 00000000..e1150b77 --- /dev/null +++ b/src/lib/audio/eq/limits.ts @@ -0,0 +1,124 @@ +import { frequencyAt, magnitudeDb } from "./filterMath"; +import { band, EqLimits, hasGain, type EqBand, type EqCurve } from "./models"; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +/** Rounds to a multiple of `step` with ties to even, like Kotlin's `round`, and never leaves a negative zero. */ +export function roundTo(value: number, step: number): number { + const x = value / step; + const floor = Math.floor(x); + const diff = x - floor; + const whole = diff > 0.5 || (diff === 0.5 && floor % 2 !== 0) ? floor + 1 : floor; + return whole * step + 0; +} + +/** Null when the band cannot be made valid (not a number, or a zero or negative frequency or Q). */ +export function sanitizeBand(b: EqBand): EqBand | null { + if (!Number.isFinite(b.frequency) || !Number.isFinite(b.gain) || !Number.isFinite(b.q)) return null; + if (b.frequency <= 0 || b.q <= 0) return null; + return { + ...b, + frequency: clamp(b.frequency, EqLimits.MIN_FREQUENCY, EqLimits.MAX_FREQUENCY), + gain: hasGain(b.type) ? clamp(b.gain, EqLimits.MIN_GAIN, EqLimits.MAX_GAIN) : 0, + q: clamp(b.q, EqLimits.MIN_Q, EqLimits.MAX_Q), + }; +} + +export function sanitizeCurve(curve: EqCurve): EqCurve { + return { + preamp: Number.isFinite(curve.preamp) ? clamp(curve.preamp, EqLimits.MIN_PREAMP, EqLimits.MAX_PREAMP) : 0, + bands: curve.bands + .map(sanitizeBand) + .filter((b): b is EqBand => b !== null) + .slice(0, EqLimits.MAX_BANDS), + }; +} + +const FIT_POINTS = 96; +const FIT_PASSES = 4; +const FIT_SMOOTHING = 6; + +/** Ten octave bands, 31 Hz to 16 kHz, all peaking, as on Flow for Android. */ +export const GraphicEq = { + FREQUENCIES: [31, 63, 125, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000] as const, + Q: 1.41, + MAX_GAIN: 12, + STEP: 0.5, + + flatCurve(): EqCurve { + return GraphicEq.curveOf(GraphicEq.FREQUENCIES.map(() => 0)); + }, + + curveOf(gains: readonly number[], preamp = 0): EqCurve { + return { + preamp, + bands: GraphicEq.FREQUENCIES.map((frequency, i) => band(frequency, gains[i] ?? 0, GraphicEq.Q)), + }; + }, + + /** + * The ten gains whose response comes closest to `curve` across the whole range: a least-squares + * fit on a log grid, with a penalty on the step between neighbouring bands so the sliders do not + * zigzag, then refined against the real, overlapping filters. + */ + fit(curve: EqCurve): EqCurve { + const grid = Array.from({ length: FIT_POINTS }, (_, k) => frequencyAt(k / (FIT_POINTS - 1))); + const target = grid.map((f) => magnitudeDb(curve.bands, f)); + const basis = GraphicEq.FREQUENCIES.map((centre) => { + const unit = [band(centre, 1, GraphicEq.Q)]; + return grid.map((f) => magnitudeDb(unit, f)); + }); + let gains: number[] = GraphicEq.FREQUENCIES.map(() => 0); + for (let pass = 0; pass < FIT_PASSES; pass++) { + const bands = GraphicEq.curveOf(gains).bands; + const residual = grid.map((f, k) => (target[k] ?? 0) - magnitudeDb(bands, f)); + const step = solveDamped(basis, residual); + gains = gains.map((gain, i) => clamp(gain + (step[i] ?? 0), -GraphicEq.MAX_GAIN, GraphicEq.MAX_GAIN)); + } + return GraphicEq.curveOf( + gains.map((g) => roundTo(g, GraphicEq.STEP)), + curve.preamp, + ); + }, + + /** Reads a stored graphic curve back as ten gains; anything malformed becomes flat. */ + gainsOf(curve: EqCurve): number[] { + if (curve.bands.length !== GraphicEq.FREQUENCIES.length) return GraphicEq.FREQUENCIES.map(() => 0); + return curve.bands.map((b) => clamp(b.gain, -GraphicEq.MAX_GAIN, GraphicEq.MAX_GAIN)); + }, +}; + +const dot = (x: readonly number[], y: readonly number[]) => x.reduce((sum, v, k) => sum + v * (y[k] ?? 0), 0); + +/** Solves (B·Bᵀ + λ·DᵀD)·x = B·r, D being the difference between neighbouring bands, by Gaussian elimination. */ +function solveDamped(basis: readonly number[][], residual: readonly number[]): number[] { + const n = basis.length; + const width = n + 1; + // Row-major augmented matrix; rows are swapped by index rather than moved. + const a = new Float64Array(n * width); + const rows = Array.from({ length: n }, (_, i) => i); + const get = (r: number, c: number) => a[(rows[r] ?? 0) * width + c] ?? 0; + const add = (r: number, c: number, v: number) => { + a[(rows[r] ?? 0) * width + c] = get(r, c) + v; + }; + basis.forEach((bi, i) => { + basis.forEach((bj, j) => add(i, j, dot(bi, bj))); + add(i, i, FIT_SMOOTHING * (i === 0 || i === n - 1 ? 1 : 2)); + if (i > 0) add(i, i - 1, -FIT_SMOOTHING); + if (i < n - 1) add(i, i + 1, -FIT_SMOOTHING); + add(i, n, dot(bi, residual)); + }); + for (let col = 0; col < n; col++) { + let pivot = col; + for (let row = col + 1; row < n; row++) { + if (Math.abs(get(row, col)) > Math.abs(get(pivot, col))) pivot = row; + } + [rows[col], rows[pivot]] = [rows[pivot] ?? 0, rows[col] ?? 0]; + for (let row = 0; row < n; row++) { + if (row === col) continue; + const factor = get(row, col) / get(col, col); + for (let c = col; c <= n; c++) add(row, c, -factor * get(col, c)); + } + } + return rows.map((_, i) => get(i, n) / get(i, i)); +} diff --git a/src/lib/audio/eq/models.ts b/src/lib/audio/eq/models.ts index f4caf589..9fcb070c 100644 --- a/src/lib/audio/eq/models.ts +++ b/src/lib/audio/eq/models.ts @@ -47,6 +47,49 @@ export const EqLimits = { export const defaultQ = (type: EqFilterType): number => type === "PEAK" ? EqLimits.DEFAULT_PEAK_Q : EqLimits.DEFAULT_SHELF_Q; +export interface EqCurve { + preamp: number; + bands: EqBand[]; +} + +export type EqMode = "PARAMETRIC" | "GRAPHIC"; + +export interface EqPreset { + id: string; + name: string; + curve: EqCurve; + mode: EqMode; + imported: boolean; +} + +/** The curve a mode is playing, and the preset it started from; null once that preset is gone. */ +export interface EqWorkingCopy { + presetId: string | null; + curve: EqCurve; +} + +export interface EqState { + version: number; + enabled: boolean; + mode: EqMode; + parametric: EqWorkingCopy; + graphic: EqWorkingCopy; + autoPreamp: boolean; + bassBoost: number; + userPresets: EqPreset[]; +} + +export const EQ_STATE_VERSION = 1; + +/** What the audio graph runs: already resolved for bypass, bass boost and auto preamp. */ +export interface EqProcessingSpec { + enabled: boolean; + preampDb: number; + bands: EqBand[]; +} + +export const EQ_PROCESSING_OFF: EqProcessingSpec = { enabled: false, preampDb: 0, bands: [] }; + /** A band with Android's defaults for whatever is left out. */ export function band( frequency: number, diff --git a/src/lib/audio/eq/stateJson.test.ts b/src/lib/audio/eq/stateJson.test.ts new file mode 100644 index 00000000..68e4edeb --- /dev/null +++ b/src/lib/audio/eq/stateJson.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { GraphicEq } from "./limits"; +import { migrateLegacyEq } from "./legacyMigration"; +import { decodeEqState, encodeEqState } from "./stateJson"; +import { defaultEqState, statesEqual } from "./stateOps"; + +// What Android's EqStateJson.encode writes (kotlinx, encodeDefaults), trimmed to one band. +const ANDROID_JSON = `{"version":1,"enabled":true,"mode":"PARAMETRIC", +"parametric":{"presetId":"builtin:rock","curve":{"preamp":0.0,"bands":[{"frequency":60.0,"gain":5.0,"q":0.707,"type":"LOW_SHELF","enabled":true}]}}, +"graphic":{"presetId":null,"curve":{"preamp":0.0,"bands":[]}}, +"autoPreamp":true,"bassBoost":2.5, +"userPresets":[{"id":"user:1","name":"Car","curve":{"preamp":-1.0,"bands":[]},"mode":"GRAPHIC","imported":false}]}`; + +describe("EqStateJson", () => { + it("reads Android's JSON, repairing what the schema allows", () => { + const state = decodeEqState(ANDROID_JSON)!; + expect(state.parametric.presetId).toBe("builtin:rock"); + expect(state.parametric.curve.bands[0]).toEqual({ + frequency: 60, + gain: 5, + q: 0.707, + type: "LOW_SHELF", + enabled: true, + }); + expect(state.bassBoost).toBe(2.5); + expect(state.userPresets[0]!.mode).toBe("GRAPHIC"); + // A graphic curve that is not ten bands becomes flat, as Android's sanitizer does. + expect(state.graphic.curve).toEqual(GraphicEq.flatCurve()); + }); + + it("writes every field Android reads, under the same names", () => { + const parsed = JSON.parse(encodeEqState(defaultEqState())); + expect(Object.keys(parsed)).toEqual([ + "version", + "enabled", + "mode", + "parametric", + "graphic", + "autoPreamp", + "bassBoost", + "userPresets", + ]); + expect(parsed.parametric).toEqual({ presetId: "builtin:flat", curve: { preamp: 0, bands: [] } }); + expect(parsed.graphic.presetId).toBeNull(); + expect(parsed.graphic.curve.bands[0]).toEqual({ frequency: 31, gain: 0, q: 1.41, type: "PEAK", enabled: true }); + }); + + it("an empty object is the default state, and a wrong type rejects the whole value", () => { + expect(statesEqual(decodeEqState("{}")!, defaultEqState())).toBe(true); + expect(decodeEqState(`{"enabled":"yes"}`)).toBeNull(); + expect(decodeEqState(`{"mode":"SPECTRAL"}`)).toBeNull(); + expect(decodeEqState("[]")).toBeNull(); + }); +}); + +describe("migrateLegacyEq", () => { + it("carries the old ten gains over as a graphic curve", () => { + const state = migrateLegacyEq({ eqEnabled: true, eqGains: [6, 5, 4, 2, 0, 0, 0, 0, 1, 2.3] })!; + expect(state.enabled).toBe(true); + expect(state.mode).toBe("GRAPHIC"); + expect(state.graphic.presetId).toBeNull(); + expect(GraphicEq.gainsOf(state.graphic.curve)).toEqual([6, 5, 4, 2, 0, 0, 0, 0, 1, 2.5]); + expect(state.graphic.curve.bands.every((b) => b.type === "PEAK")).toBe(true); + }); + + it("migrates nothing when the old equalizer was never used", () => { + expect(migrateLegacyEq(null)).toBeNull(); + expect(migrateLegacyEq({ eqEnabled: false, eqGains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] })).toBeNull(); + expect(migrateLegacyEq({ volume: 0.4 } as never)).toBeNull(); + }); + + it("keeps a curve that was switched off, off", () => { + const state = migrateLegacyEq({ eqEnabled: false, eqGains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 3] })!; + expect(state.enabled).toBe(false); + expect(GraphicEq.gainsOf(state.graphic.curve)[9]).toBe(3); + }); +}); diff --git a/src/lib/audio/eq/stateJson.ts b/src/lib/audio/eq/stateJson.ts new file mode 100644 index 00000000..2d4116c1 --- /dev/null +++ b/src/lib/audio/eq/stateJson.ts @@ -0,0 +1,145 @@ +import { + EQ_FILTER_TYPES, + EqLimits, + type EqBand, + type EqCurve, + type EqFilterType, + type EqMode, + type EqPreset, + type EqState, + type EqWorkingCopy, +} from "./models"; +import { defaultEqState, sanitizeState } from "./stateOps"; + +/* + * The equalizer as one JSON value: the form it is saved in, backed up and synced as. It is the + * schema Android's `EqStateJson` writes with kotlinx.serialization, so either app reads the + * other's. Like Android, a value that does not fit the schema is rejected whole rather than + * half-applied, and missing fields take their defaults. + */ +class Invalid extends Error {} + +type Json = Record; + +const isObject = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v); + +function field(obj: Json, key: string, read: (v: unknown) => T, fallback: () => T): T { + if (!(key in obj)) return fallback(); + return read(obj[key]); +} + +const num = (v: unknown): number => { + if (typeof v !== "number") throw new Invalid(); + return v; +}; +const bool = (v: unknown): boolean => { + if (typeof v !== "boolean") throw new Invalid(); + return v; +}; +const str = (v: unknown): string => { + if (typeof v !== "string") throw new Invalid(); + return v; +}; +const filterType = (v: unknown): EqFilterType => { + if (!EQ_FILTER_TYPES.includes(v as EqFilterType)) throw new Invalid(); + return v as EqFilterType; +}; +const mode = (v: unknown): EqMode => { + if (v !== "PARAMETRIC" && v !== "GRAPHIC") throw new Invalid(); + return v; +}; +const list = (read: (v: unknown) => T) => (v: unknown): T[] => { + if (!Array.isArray(v)) throw new Invalid(); + return v.map(read); +}; +const object = (v: unknown): Json => { + if (!isObject(v)) throw new Invalid(); + return v; +}; + +function readBand(v: unknown): EqBand { + const o = object(v); + if (!("frequency" in o)) throw new Invalid(); + return { + frequency: num(o.frequency), + gain: field(o, "gain", num, () => 0), + q: field(o, "q", num, () => EqLimits.DEFAULT_PEAK_Q), + type: field(o, "type", filterType, () => "PEAK" as const), + enabled: field(o, "enabled", bool, () => true), + }; +} + +function readCurve(v: unknown): EqCurve { + const o = object(v); + return { + preamp: field(o, "preamp", num, () => 0), + bands: field(o, "bands", list(readBand), () => []), + }; +} + +function readCopy(v: unknown): EqWorkingCopy { + const o = object(v); + if (!("curve" in o)) throw new Invalid(); + const presetId = o.presetId; + if (presetId !== undefined && presetId !== null && typeof presetId !== "string") throw new Invalid(); + return { presetId: presetId ?? null, curve: readCurve(o.curve) }; +} + +function readPreset(v: unknown): EqPreset { + const o = object(v); + if (!("id" in o) || !("name" in o) || !("curve" in o)) throw new Invalid(); + return { + id: str(o.id), + name: str(o.name), + curve: readCurve(o.curve), + mode: field(o, "mode", mode, () => "PARAMETRIC" as const), + imported: field(o, "imported", bool, () => false), + }; +} + +/** Null when `raw` is not an equalizer state either app wrote. */ +export function decodeEqState(raw: string): EqState | null { + try { + const o = object(JSON.parse(raw)); + const defaults = defaultEqState(); + return sanitizeState({ + version: field(o, "version", num, () => defaults.version), + enabled: field(o, "enabled", bool, () => defaults.enabled), + mode: field(o, "mode", mode, () => defaults.mode), + parametric: field(o, "parametric", readCopy, () => defaults.parametric), + graphic: field(o, "graphic", readCopy, () => defaults.graphic), + autoPreamp: field(o, "autoPreamp", bool, () => defaults.autoPreamp), + bassBoost: field(o, "bassBoost", num, () => defaults.bassBoost), + userPresets: field(o, "userPresets", list(readPreset), () => defaults.userPresets), + }); + } catch { + return null; + } +} + +/** Every field written, in Android's order, as kotlinx's `encodeDefaults` does. */ +export function encodeEqState(state: EqState): string { + const curve = (c: EqCurve) => ({ + preamp: c.preamp, + bands: c.bands.map((b) => ({ frequency: b.frequency, gain: b.gain, q: b.q, type: b.type, enabled: b.enabled })), + }); + const copy = (c: EqWorkingCopy) => ({ presetId: c.presetId, curve: curve(c.curve) }); + return JSON.stringify({ + version: state.version, + enabled: state.enabled, + mode: state.mode, + parametric: copy(state.parametric), + graphic: copy(state.graphic), + autoPreamp: state.autoPreamp, + bassBoost: state.bassBoost, + userPresets: state.userPresets.map((p) => ({ + id: p.id, + name: p.name, + curve: curve(p.curve), + mode: p.mode, + imported: p.imported, + })), + }); +} + +export const newPresetId = (): string => `user:${crypto.randomUUID()}`; diff --git a/src/lib/audio/eq/stateOps.test.ts b/src/lib/audio/eq/stateOps.test.ts new file mode 100644 index 00000000..cceb474d --- /dev/null +++ b/src/lib/audio/eq/stateOps.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; + +import { builtInPreset, BUILT_IN_EQ_PRESETS } from "./builtInPresets"; +import { autoPreampDb, frequencyAt, magnitudeDb } from "./filterMath"; +import { GraphicEq } from "./limits"; +import { band, EQ_PROCESSING_OFF, EqLimits, type EqCurve, type EqPreset, type EqState } from "./models"; +import { decodeEqState, encodeEqState } from "./stateJson"; +import { + activeCopy, + addBand, + canSaveActive, + changesSound, + defaultEqState, + deletePreset, + importPreset, + isEdited, + isNameAvailable, + presetCurveFor, + processingSpec, + restorePreset, + revert, + sanitizeState, + saveActive, + saveActiveAs, + selectPreset, + statesEqual, + userPreset, + withActiveCurve, + withBand, + withBassBoost, + withManualPreamp, +} from "./stateOps"; + +// Ported from Android's EqStateOpsTest. +const rock = "builtin:rock"; +const mine: EqPreset = { + id: "user:a", + name: "Mine", + curve: { preamp: 0, bands: [band(500, 3)] }, + mode: "PARAMETRIC", + imported: false, +}; +const state = (patch: Partial = {}): EqState => ({ ...defaultEqState(), ...patch }); + +describe("EqStateOps", () => { + it("has Android's 13 built-in presets", () => { + expect(BUILT_IN_EQ_PRESETS.map((p) => p.id)).toEqual([ + "builtin:flat", + "builtin:acoustic", + "builtin:bass_boost", + "builtin:classical", + "builtin:electronic", + "builtin:heavy_metal", + "builtin:hip_hop", + "builtin:jazz", + "builtin:metal", + "builtin:pop", + "builtin:rnb", + "builtin:rock", + "builtin:vocal", + ]); + }); + + it("selecting a preset loads its curve and is not an edit", () => { + const s = selectPreset(state(), rock); + expect(activeCopy(s).presetId).toBe(rock); + expect(activeCopy(s).curve).toEqual(builtInPreset(rock)!.curve); + expect(isEdited(s)).toBe(false); + }); + + it("an edited curve stays reachable and reverts to its preset", () => { + const edited = withBand(selectPreset(state(), rock), 0, band(60, 1, 0.707, "LOW_SHELF")); + expect(isEdited(edited)).toBe(true); + expect(activeCopy(edited).presetId).toBe(rock); + expect(isEdited(revert(edited))).toBe(false); + }); + + it("a built-in cannot be overwritten, a user preset can", () => { + const builtIn = addBand(selectPreset(state(), rock), band(8_000, 2)); + expect(canSaveActive(builtIn)).toBe(false); + expect(saveActive(builtIn)).toBe(builtIn); + + const own = addBand(selectPreset(state({ userPresets: [mine] }), mine.id), band(8_000, 2)); + const saved = saveActive(own); + expect(userPreset(saved, mine.id)!.curve.bands).toHaveLength(2); + expect(isEdited(saved)).toBe(false); + }); + + it("save as creates a preset and selects it", () => { + const s = saveActiveAs(addBand(selectPreset(state(), rock), band(8_000, 2)), "user:b", "Car"); + expect(activeCopy(s).presetId).toBe("user:b"); + expect(userPreset(s, "user:b")!.name).toBe("Car"); + expect(isEdited(s)).toBe(false); + }); + + it("deleting a selected preset keeps the sound and undo restores it in place", () => { + const other = { ...mine, id: "user:z", name: "Other" }; + const s = selectPreset(state({ userPresets: [other, mine] }), mine.id); + const [deleted, removed] = deletePreset(s, mine.id); + + expect(activeCopy(deleted).presetId).toBeNull(); + expect(activeCopy(deleted).curve).toEqual(mine.curve); + + const restored = restorePreset(deleted, removed!); + expect(restored.userPresets.map((p) => p.id)).toEqual(["user:z", mine.id]); + expect(activeCopy(restored).presetId).toBe(mine.id); + }); + + it("each mode keeps its own curve", () => { + const parametric = selectPreset(state(), rock); + const graphic = withActiveCurve({ ...parametric, mode: "GRAPHIC" }, GraphicEq.curveOf(Array(10).fill(3))); + const back = { ...graphic, mode: "PARAMETRIC" as const }; + expect(activeCopy(back).curve).toEqual(activeCopy(parametric).curve); + expect(back.graphic.curve.bands.map((b) => b.gain)).toEqual(Array(10).fill(3)); + }); + + it("importing adds a preset and plays it", () => { + const curve: EqCurve = { preamp: -2, bands: [band(910, 9, 0.71)] }; + const s = importPreset(state(), "user:i", "Hefty Metal", curve); + expect(activeCopy(s).presetId).toBe("user:i"); + expect(userPreset(s, "user:i")!.imported).toBe(true); + expect(s.mode).toBe("PARAMETRIC"); + }); + + it("the processing spec adds bass boost and protects the peak", () => { + const spec = processingSpec(withBassBoost(selectPreset(state(), rock), 6), false); + expect(spec.enabled).toBe(true); + const boost = spec.bands[spec.bands.length - 1]!; + expect(boost.type).toBe("LOW_SHELF"); + expect(boost.gain).toBe(6); + expect(spec.preampDb).toBeCloseTo(autoPreampDb(spec.bands), 2); + expect(spec.preampDb).toBeLessThan(0); + }); + + it("manual preamp is used when auto preamp is off", () => { + const s = withManualPreamp(selectPreset(state({ autoPreamp: false }), rock), -3); + expect(processingSpec(s, false).preampDb).toBe(-3); + }); + + it("off and compare both produce an unprocessed spec", () => { + const s = selectPreset(state(), rock); + expect(processingSpec({ ...s, enabled: false }, false)).toEqual(EQ_PROCESSING_OFF); + expect(processingSpec(s, true)).toEqual(EQ_PROCESSING_OFF); + }); + + it("a preview replaces the playing curve without touching the state", () => { + const preview: EqCurve = { preamp: 0, bands: [band(2_000, 5)] }; + expect(processingSpec(selectPreset(state(), rock), false, preview).bands).toEqual([band(2_000, 5)]); + }); + + it("flat and off settings change nothing", () => { + expect(changesSound(state())).toBe(false); + expect(changesSound(selectPreset(state(), rock))).toBe(true); + expect(changesSound({ ...selectPreset(state(), rock), enabled: false })).toBe(false); + expect(changesSound(withBassBoost(state(), 3))).toBe(true); + expect(changesSound(addBand(state(), band(1_000, 0)))).toBe(false); + }); + + it("names are unique regardless of case, including built-in names", () => { + const s = state({ userPresets: [mine] }); + expect(isNameAvailable(s, "mine", ["Rock"])).toBe(false); + expect(isNameAvailable(s, "ROCK", ["Rock"])).toBe(false); + expect(isNameAvailable(s, "Mine", ["Rock"], mine.id)).toBe(true); + expect(isNameAvailable(s, " ", ["Rock"])).toBe(false); + }); + + it("band edits respect the band limit and graphic mode", () => { + let full = state(); + for (let i = 1; i <= EqLimits.MAX_BANDS; i++) full = addBand(full, band(i * 100, 1)); + expect(addBand(full, band(5_000, 1))).toBe(full); + const graphic = state({ mode: "GRAPHIC" }); + expect(addBand(graphic, band(5_000, 1))).toBe(graphic); + }); + + it("sanitising repairs a damaged state", () => { + const damaged = state({ + graphic: { presetId: null, curve: { preamp: 0, bands: [band(1, 99)] } }, + bassBoost: Number.NaN, + userPresets: [mine, mine, { id: "", name: "x", curve: { preamp: 0, bands: [] }, mode: "PARAMETRIC", imported: false }], + }); + const clean = sanitizeState(damaged); + expect(clean.graphic.curve).toEqual(GraphicEq.flatCurve()); + expect(clean.bassBoost).toBe(0); + expect(clean.userPresets).toEqual([mine]); + }); + + it("state survives its JSON form", () => { + const s = withBassBoost(selectPreset(state({ userPresets: [mine] }), mine.id), 4); + expect(statesEqual(decodeEqState(encodeEqState(s))!, s)).toBe(true); + expect(decodeEqState("not json")).toBeNull(); + }); + + it("a preset picked in graphic mode is fitted onto the ten bands and stays graphic", () => { + const s = selectPreset(state({ mode: "GRAPHIC" }), rock); + const rockBands = builtInPreset(rock)!.curve.bands; + + expect(s.mode).toBe("GRAPHIC"); + expect(activeCopy(s).presetId).toBe(rock); + expect(isEdited(s)).toBe(false); + expect(s.parametric).toEqual(defaultEqState().parametric); + const grid = Array.from({ length: 81 }, (_, i) => frequencyAt(0.05 + (0.9 * i) / 80)); + const error = grid.map((f) => magnitudeDb(s.graphic.curve.bands, f) - magnitudeDb(rockBands, f)); + const rms = Math.sqrt(error.reduce((sum, e) => sum + e * e, 0) / error.length); + expect(rms).toBeLessThan(1); + const gains = GraphicEq.gainsOf(s.graphic.curve); + let zigzags = 0; + for (let i = 0; i + 2 < gains.length; i++) { + const [a, b, c] = [gains[i]!, gains[i + 1]!, gains[i + 2]!]; + if ((b - a) * (c - b) < 0 && Math.abs(b - a) > 1.5 && Math.abs(c - b) > 1.5) zigzags++; + } + expect(zigzags).toBe(0); + }); + + it("revert in graphic mode returns to the fitted preset", () => { + const picked = selectPreset(state({ mode: "GRAPHIC" }), rock); + const moved = withActiveCurve(picked, GraphicEq.curveOf(Array(10).fill(6))); + expect(isEdited(moved)).toBe(true); + expect(statesEqual(revert(moved), picked)).toBe(true); + }); + + it("a graphic preset picked in parametric mode loads its bands and stays parametric", () => { + const car: EqPreset = { + id: "user:car", + name: "Car", + curve: GraphicEq.curveOf(Array.from({ length: 10 }, (_, i) => i)), + mode: "GRAPHIC", + imported: false, + }; + const s = selectPreset(state({ userPresets: [car] }), car.id); + expect(s.mode).toBe("PARAMETRIC"); + expect(activeCopy(s).curve.bands).toHaveLength(10); + expect(isEdited(s)).toBe(false); + }); + + it("importing from graphic mode switches to parametric, where the file plays exactly", () => { + const curve: EqCurve = { preamp: 0, bands: [band(910, 9, 0.71)] }; + const s = importPreset(state({ mode: "GRAPHIC" }), "user:i", "Hefty Metal", curve); + expect(s.mode).toBe("PARAMETRIC"); + expect(activeCopy(s).curve).toEqual(curve); + }); + + it("fitting flat gives flat and never writes a negative zero", () => { + const fit = GraphicEq.fit({ preamp: 0, bands: [] }); + expect(fit).toEqual(GraphicEq.flatCurve()); + expect(fit.bands.every((b) => !Object.is(b.gain, -0))).toBe(true); + }); + + it("presets apply in the current mode", () => { + expect(presetCurveFor(state(), "GRAPHIC", rock)!.bands).toHaveLength(10); + expect(presetCurveFor(state(), "PARAMETRIC", rock)!.bands).toHaveLength(5); + }); +}); diff --git a/src/lib/audio/eq/stateOps.ts b/src/lib/audio/eq/stateOps.ts new file mode 100644 index 00000000..dc3ae4d9 --- /dev/null +++ b/src/lib/audio/eq/stateOps.ts @@ -0,0 +1,297 @@ +import { builtInPreset, FLAT_PRESET_ID, isBuiltInPreset } from "./builtInPresets"; +import { autoPreampDb, isBypassed, REFERENCE_SAMPLE_RATE } from "./filterMath"; +import { GraphicEq, sanitizeCurve } from "./limits"; +import { + band, + EQ_PROCESSING_OFF, + EQ_STATE_VERSION, + EqLimits, + type EqBand, + type EqCurve, + type EqMode, + type EqPreset, + type EqProcessingSpec, + type EqState, + type EqWorkingCopy, +} from "./models"; + +/** + * Every change the equalizer supports, as pure functions of `EqState`, ported from Android's + * `EqStateOps.kt`. The store applies them and saves the result. + */ +export function defaultEqState(): EqState { + return { + version: EQ_STATE_VERSION, + enabled: true, + mode: "PARAMETRIC", + parametric: { presetId: FLAT_PRESET_ID, curve: { preamp: 0, bands: [] } }, + graphic: { presetId: null, curve: GraphicEq.flatCurve() }, + autoPreamp: true, + bassBoost: 0, + userPresets: [], + }; +} + +const bandsEqual = (a: EqBand, b: EqBand) => + a.frequency === b.frequency && a.gain === b.gain && a.q === b.q && a.type === b.type && a.enabled === b.enabled; + +export const curvesEqual = (a: EqCurve, b: EqCurve): boolean => + a.preamp === b.preamp && + a.bands.length === b.bands.length && + a.bands.every((x, i) => { + const y = b.bands[i]; + return y !== undefined && bandsEqual(x, y); + }); + +const copiesEqual = (a: EqWorkingCopy, b: EqWorkingCopy) => a.presetId === b.presetId && curvesEqual(a.curve, b.curve); + +const presetsEqual = (a: EqPreset, b: EqPreset) => + a.id === b.id && a.name === b.name && a.mode === b.mode && a.imported === b.imported && curvesEqual(a.curve, b.curve); + +export const statesEqual = (a: EqState, b: EqState): boolean => + a.version === b.version && + a.enabled === b.enabled && + a.mode === b.mode && + copiesEqual(a.parametric, b.parametric) && + copiesEqual(a.graphic, b.graphic) && + a.autoPreamp === b.autoPreamp && + a.bassBoost === b.bassBoost && + a.userPresets.length === b.userPresets.length && + a.userPresets.every((p, i) => { + const q = b.userPresets[i]; + return q !== undefined && presetsEqual(p, q); + }); + +export const activeCopy = (state: EqState): EqWorkingCopy => + state.mode === "PARAMETRIC" ? state.parametric : state.graphic; + +export const userPreset = (state: EqState, id: string | null | undefined): EqPreset | undefined => + id ? state.userPresets.find((p) => p.id === id) : undefined; + +export const presetCurve = (state: EqState, id: string | null | undefined): EqCurve | undefined => + builtInPreset(id)?.curve ?? userPreset(state, id)?.curve; + +const presetMode = (state: EqState, id: string | null | undefined): EqMode | undefined => + isBuiltInPreset(id) ? "PARAMETRIC" : userPreset(state, id)?.mode; + +const defaultCurve = (mode: EqMode): EqCurve => + mode === "PARAMETRIC" ? { preamp: 0, bands: [] } : GraphicEq.flatCurve(); + +export function sanitizedFor(mode: EqMode, curve: EqCurve): EqCurve { + return mode === "GRAPHIC" + ? GraphicEq.curveOf(GraphicEq.gainsOf(curve), sanitizeCurve(curve).preamp) + : sanitizeCurve(curve); +} + +// Presets are immutable values, so a fit is reused for as long as its source curve lives. +const fits = new WeakMap(); +const fitted = (curve: EqCurve): EqCurve => { + let fit = fits.get(curve); + if (!fit) { + fit = GraphicEq.fit(curve); + fits.set(curve, fit); + } + return fit; +}; + +/** + * Preset `id` as `mode` plays it: a graphic preset's ten bands load as parametric bands as they + * are, and a parametric preset is fitted onto the ten graphic bands. + */ +export function presetCurveFor(state: EqState, mode: EqMode, id: string | null | undefined): EqCurve | undefined { + const curve = presetCurve(state, id); + if (!curve) return undefined; + if (mode === "PARAMETRIC") return sanitizeCurve(curve); + if (presetMode(state, id) === "GRAPHIC") return sanitizedFor("GRAPHIC", curve); + return fitted(curve); +} + +/** The curve differs from the preset it came from, or there is no preset and it is not flat. */ +export function isEdited(state: EqState): boolean { + const active = activeCopy(state); + return !curvesEqual(active.curve, presetCurveFor(state, state.mode, active.presetId) ?? defaultCurve(state.mode)); +} + +/** The equalizer is on and something in it alters the signal. */ +export function changesSound(state: EqState): boolean { + const curve = activeCopy(state).curve; + return ( + state.enabled && + (state.bassBoost > 0 || + curve.bands.some((b) => !isBypassed(b, REFERENCE_SAMPLE_RATE)) || + (!state.autoPreamp && curve.preamp !== 0)) + ); +} + +/** Save can overwrite the source preset only when it is one of the user's own. */ +export function canSaveActive(state: EqState): boolean { + return isEdited(state) && userPreset(state, activeCopy(state).presetId)?.mode === state.mode; +} + +const withActive = (state: EqState, working: EqWorkingCopy): EqState => + state.mode === "PARAMETRIC" ? { ...state, parametric: working } : { ...state, graphic: working }; + +export const withActiveCurve = (state: EqState, curve: EqCurve): EqState => + withActive(state, { ...activeCopy(state), curve: sanitizedFor(state.mode, curve) }); + +/** Plays preset `id` in the current mode; the mode itself never changes here. */ +export function selectPreset(state: EqState, id: string): EqState { + const curve = presetCurveFor(state, state.mode, id); + return curve ? withActive(state, { presetId: id, curve }) : state; +} + +export const revert = (state: EqState): EqState => + withActiveCurve(state, presetCurveFor(state, state.mode, activeCopy(state).presetId) ?? defaultCurve(state.mode)); + +export function saveActive(state: EqState): EqState { + if (!canSaveActive(state)) return state; + const { presetId, curve } = activeCopy(state); + return { ...state, userPresets: state.userPresets.map((p) => (p.id === presetId ? { ...p, curve } : p)) }; +} + +export function saveActiveAs(state: EqState, id: string, name: string): EqState { + const curve = activeCopy(state).curve; + const next = { ...state, userPresets: [...state.userPresets, { id, name, curve, mode: state.mode, imported: false }] }; + return withActive(next, { presetId: id, curve }); +} + +export const renamePreset = (state: EqState, id: string, name: string): EqState => ({ + ...state, + userPresets: state.userPresets.map((p) => (p.id === id ? { ...p, name } : p)), +}); + +export function duplicatePreset(state: EqState, sourceId: string, newId: string, name: string): EqState { + const curve = presetCurve(state, sourceId); + const mode = presetMode(state, sourceId); + if (!curve || !mode) return state; + return { ...state, userPresets: [...state.userPresets, { id: newId, name, curve, mode, imported: false }] }; +} + +/** A deleted preset and where it was, so Undo can put it back in place. */ +export interface DeletedEqPreset { + preset: EqPreset; + index: number; + selectedIn: EqMode[]; +} + +export function deletePreset(state: EqState, id: string): [EqState, DeletedEqPreset | null] { + const index = state.userPresets.findIndex((p) => p.id === id); + const preset = state.userPresets[index]; + if (!preset) return [state, null]; + const selectedIn: EqMode[] = []; + if (state.parametric.presetId === id) selectedIn.push("PARAMETRIC"); + if (state.graphic.presetId === id) selectedIn.push("GRAPHIC"); + const next: EqState = { + ...state, + userPresets: state.userPresets.filter((p) => p.id !== id), + parametric: state.parametric.presetId === id ? { ...state.parametric, presetId: null } : state.parametric, + graphic: state.graphic.presetId === id ? { ...state.graphic, presetId: null } : state.graphic, + }; + return [next, { preset, index, selectedIn }]; +} + +export function restorePreset(state: EqState, deleted: DeletedEqPreset): EqState { + if (userPreset(state, deleted.preset.id)) return state; + const presets = [...state.userPresets]; + presets.splice(Math.max(0, Math.min(deleted.index, presets.length)), 0, deleted.preset); + const id = deleted.preset.id; + const reselect = (mode: EqMode, copy: EqWorkingCopy) => + deleted.selectedIn.includes(mode) && copy.presetId === null ? { ...copy, presetId: id } : copy; + return { + ...state, + userPresets: presets, + parametric: reselect("PARAMETRIC", state.parametric), + graphic: reselect("GRAPHIC", state.graphic), + }; +} + +export function importPreset(state: EqState, id: string, name: string, curve: EqCurve): EqState { + const next: EqState = { + ...state, + mode: "PARAMETRIC", + userPresets: [ + ...state.userPresets, + { id, name, curve: sanitizeCurve(curve), mode: "PARAMETRIC", imported: true }, + ], + }; + return selectPreset(next, id); +} + +export const withBassBoost = (state: EqState, db: number): EqState => ({ + ...state, + bassBoost: Math.max(0, Math.min(EqLimits.MAX_BASS_BOOST, db)), +}); + +export const withManualPreamp = (state: EqState, db: number): EqState => + withActiveCurve(state, { ...activeCopy(state).curve, preamp: db }); + +export function withBand(state: EqState, index: number, next: EqBand): EqState { + const bands = activeCopy(state).curve.bands; + if (index < 0 || index >= bands.length) return state; + return withActiveCurve(state, { ...activeCopy(state).curve, bands: bands.map((b, i) => (i === index ? next : b)) }); +} + +export function addBand(state: EqState, next: EqBand): EqState { + const curve = activeCopy(state).curve; + if (state.mode !== "PARAMETRIC" || curve.bands.length >= EqLimits.MAX_BANDS) return state; + return withActiveCurve(state, { ...curve, bands: [...curve.bands, next] }); +} + +export function removeBand(state: EqState, index: number): EqState { + const curve = activeCopy(state).curve; + if (state.mode !== "PARAMETRIC" || index < 0 || index >= curve.bands.length) return state; + return withActiveCurve(state, { ...curve, bands: curve.bands.filter((_, i) => i !== index) }); +} + +export function insertBand(state: EqState, index: number, next: EqBand): EqState { + const curve = activeCopy(state).curve; + if (state.mode !== "PARAMETRIC" || curve.bands.length >= EqLimits.MAX_BANDS) return state; + const bands = [...curve.bands]; + bands.splice(Math.max(0, Math.min(index, bands.length)), 0, next); + return withActiveCurve(state, { ...curve, bands }); +} + +export const normalizedPresetName = (raw: string): string => raw.trim().slice(0, EqLimits.MAX_NAME_LENGTH); + +/** True when `name` is free among the user's presets and the built-in names shown to them. */ +export function isNameAvailable( + state: EqState, + name: string, + builtInNames: readonly string[], + exceptId?: string | null, +): boolean { + const wanted = name.trim().toLocaleLowerCase(); + if (!wanted) return false; + const taken = [...state.userPresets.filter((p) => p.id !== exceptId).map((p) => p.name), ...builtInNames]; + return taken.every((n) => n.toLocaleLowerCase() !== wanted); +} + +/** + * What the audio graph should run. `preview` replaces the active curve while a point is dragged, + * so the sound follows the pointer without the saved state changing every frame. + */ +export function processingSpec(state: EqState, bypass: boolean, preview?: EqCurve | null): EqProcessingSpec { + if (!state.enabled || bypass) return EQ_PROCESSING_OFF; + const curve = preview ?? activeCopy(state).curve; + const bands = curve.bands.filter((b) => b.enabled); + if (state.bassBoost > 0) { + bands.push(band(EqLimits.BASS_BOOST_FREQUENCY, state.bassBoost, EqLimits.DEFAULT_SHELF_Q, "LOW_SHELF")); + } + const preampDb = state.autoPreamp ? autoPreampDb(bands) : curve.preamp; + return { enabled: true, preampDb, bands }; +} + +/** Clamps every value and repairs anything a hand-edited backup or an old version could hold. */ +export function sanitizeState(state: EqState): EqState { + const seen = new Set(); + return { + ...state, + parametric: { ...state.parametric, curve: sanitizeCurve(state.parametric.curve) }, + graphic: { ...state.graphic, curve: sanitizedFor("GRAPHIC", state.graphic.curve) }, + bassBoost: Number.isFinite(state.bassBoost) ? Math.max(0, Math.min(EqLimits.MAX_BASS_BOOST, state.bassBoost)) : 0, + userPresets: state.userPresets + .filter((p) => p.id.trim() !== "" && p.name.trim() !== "") + .filter((p) => (seen.has(p.id) ? false : (seen.add(p.id), true))) + .map((p) => ({ ...p, name: normalizedPresetName(p.name), curve: sanitizedFor(p.mode, p.curve) })), + }; +} diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index ff7f6382..42dd68b8 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -1038,6 +1038,19 @@ "music_equalizer": "Equalizer", "music_loudness_normalization": "Loudness normalization", "music_loudness_normalization_desc": "Turns down songs mastered louder than YouTube Music's target", + "eq_preset_flat": "Flat", + "eq_preset_acoustic": "Acoustic", + "eq_preset_bass_boost": "Bass Boost", + "eq_preset_classical": "Classical", + "eq_preset_electronic": "Electronic", + "eq_preset_heavy_metal": "Heavy Metal", + "eq_preset_hip_hop": "Hip-Hop", + "eq_preset_jazz": "Jazz", + "eq_preset_metal": "Metal", + "eq_preset_pop": "Pop", + "eq_preset_rnb": "R&B", + "eq_preset_rock": "Rock", + "eq_preset_vocal": "Vocal / Podcast", "music_expand": "Expand", "music_collapse": "Collapse", "music_now_playing": "Now playing", From 44b6493121703b082c6117cb2c178ad41f839819 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:52:01 +0300 Subject: [PATCH 2/6] feat(audio): run the equalizer on a fixed filter pool with auto preamp and a soft clipper --- src/components/music/EqPanel.tsx | 147 ++++++++------- src/components/music/GlobalMusicAudio.tsx | 2 - src/components/music/GlobalMusicDock.tsx | 3 +- src/components/music/MusicOverlay.tsx | 3 +- src/components/music/eq/EqResponseGraph.tsx | 198 ++++++++++++++++++++ src/components/music/eq/eqFormat.test.ts | 18 ++ src/components/music/eq/eqFormat.ts | 15 ++ src/lib/audio/audioContext.ts | 61 ++++++ src/lib/audio/eq/graphPlan.test.ts | 48 +++++ src/lib/audio/eq/graphPlan.ts | 74 ++++++++ src/lib/audio/eq/stateOps.ts | 9 +- src/lib/audio/eq/useEqualizer.ts | 95 ++++++++++ src/lib/audio/eqBands.test.ts | 18 -- src/lib/audio/eqBands.ts | 73 -------- src/lib/audio/eqChain.ts | 72 +++++++ src/lib/audio/musicAudioEngine.eq.test.ts | 154 +++++++++++++++ src/lib/audio/musicAudioEngine.ts | 136 +++++--------- src/lib/audio/musicAudioSettings.ts | 30 ++- src/lib/i18n/strings.json | 58 ++++++ src/lib/settings/schema.ts | 3 + src/lib/useElementWidth.ts | 19 ++ src/store/useEqStore.test.ts | 95 ++++++++++ src/store/useEqStore.ts | 118 ++++++++++++ src/store/useMusicPlayerStore.ts | 47 ----- 24 files changed, 1190 insertions(+), 306 deletions(-) create mode 100644 src/components/music/eq/EqResponseGraph.tsx create mode 100644 src/components/music/eq/eqFormat.test.ts create mode 100644 src/components/music/eq/eqFormat.ts create mode 100644 src/lib/audio/audioContext.ts create mode 100644 src/lib/audio/eq/graphPlan.test.ts create mode 100644 src/lib/audio/eq/graphPlan.ts create mode 100644 src/lib/audio/eq/useEqualizer.ts delete mode 100644 src/lib/audio/eqBands.test.ts delete mode 100644 src/lib/audio/eqBands.ts create mode 100644 src/lib/audio/eqChain.ts create mode 100644 src/lib/audio/musicAudioEngine.eq.test.ts create mode 100644 src/lib/useElementWidth.ts create mode 100644 src/store/useEqStore.test.ts create mode 100644 src/store/useEqStore.ts diff --git a/src/components/music/EqPanel.tsx b/src/components/music/EqPanel.tsx index 7f95955b..e80cca07 100644 --- a/src/components/music/EqPanel.tsx +++ b/src/components/music/EqPanel.tsx @@ -1,32 +1,20 @@ import { AnimatePresence, motion } from "framer-motion"; -import { X } from "lucide-react"; +import { SlidersHorizontal, X } from "lucide-react"; -import { useMusicPlayerStore } from "../../store/useMusicPlayerStore"; -import { - EQ_BANDS, - EQ_MAX_GAIN_DB, - EQ_PRESETS, - type EqPresetName, -} from "../../lib/audio/eqBands"; -import { ToggleSwitch } from "../ui/ToggleSwitch"; -import { Chip } from "../ui/Chip"; +import { EqLimits } from "../../lib/audio/eq/models"; +import { withBassBoost } from "../../lib/audio/eq/stateOps"; +import { pickEqPreset, restoreCustomEq, useEqualizer, useMusicEqAvailable } from "../../lib/audio/eq/useEqualizer"; import { getString } from "../../lib/i18n/index"; -import { Slider } from "../ui/Slider"; -import { useBoolPref } from "../../lib/usePreference"; import { SETTINGS } from "../../lib/settings/schema"; +import { useBoolPref } from "../../lib/usePreference"; +import { useEqStore } from "../../store/useEqStore"; +import { Chip } from "../ui/Chip"; +import { Slider } from "../ui/Slider"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { EqResponseGraph } from "./eq/EqResponseGraph"; +import { formatDb } from "./eq/eqFormat"; -const PRESETS: EqPresetName[] = ["flat", "bass", "vocal", "treble", "electronic", "acoustic"]; - -function arraysEqual(a: number[], b: number[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); -} - -function formatGain(db: number | undefined): string { - const v = Math.round(db ?? 0); - return v > 0 ? `+${v}` : `${v}`; -} - - +/** The quick equalizer panel: switch, curve, presets and bass boost, with Edit for the rest. */ export function EqPanel({ open, onClose, @@ -36,14 +24,14 @@ export function EqPanel({ onClose: () => void; placement?: "top" | "bottom"; }) { - const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled); - const eqGains = useMusicPlayerStore((s) => s.eqGains); - const setEqEnabled = useMusicPlayerStore((s) => s.setEqEnabled); - const setEqBand = useMusicPlayerStore((s) => s.setEqBand); - const applyEqPreset = useMusicPlayerStore((s) => s.applyEqPreset); + const eq = useEqualizer(); + const available = useMusicEqAvailable(); + const update = useEqStore((s) => s.update); + const setEditorOpen = useEqStore((s) => s.setEditorOpen); const [normalization, setNormalization] = useBoolPref(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION); const fromY = placement === "top" ? 10 : -10; + const selectedId = eq.active.presetId; return ( @@ -57,13 +45,15 @@ export function EqPanel({ style={{ transformOrigin: placement === "top" ? "bottom right" : "top right" }} className="w-80 rounded-2xl border border-chrome-neutral-800 bg-surface-container p-4" > - {/* header */} -
+
{getString("music_equalizer")}
- + update((s) => ({ ...s, enabled }))} + />
- {/* presets */} -
- {PRESETS.map((p) => ( - applyEqPreset(p)} - className="h-7 px-2.5 text-xs capitalize" - > - {p} - - ))} + {!available && ( +

+ {getString("eq_unavailable")} +

+ )} + +
+
+ +
+ +
+ {eq.hasCustom && ( + restoreCustomEq(eq.state.mode)} + className="h-7 px-2.5 text-xs" + > + {getString("eq_preset_custom")} + + )} + {eq.presets.map((preset) => ( + pickEqPreset(preset.id)} + className="h-7 px-2.5 text-xs" + > + {preset.name} + + ))} +
+ +
+ {getString("eq_bass_boost")} + update((s) => withBassBoost(s, db))} + aria-label={getString("eq_bass_boost")} + className="flex-1" + /> + + {formatDb(eq.state.bassBoost)} + +
- {/* sliders */} -
{ + onClose(); + setEditorOpen(true); + }} + className="mt-3 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-surface-container-high px-3 py-2 text-sm text-chrome-neutral-200 transition-colors duration-200 ease-out hover:bg-surface-container-highest" > - {EQ_BANDS.map((band, i) => ( -
- - {formatGain(eqGains[i])} - - setEqBand(i, gain)} - aria-label={`${band.label} Hz`} - /> - {band.label} -
- ))} -
+ + {getString("eq_edit")} +
diff --git a/src/components/music/GlobalMusicAudio.tsx b/src/components/music/GlobalMusicAudio.tsx index cd4d5edd..bfe31ab4 100644 --- a/src/components/music/GlobalMusicAudio.tsx +++ b/src/components/music/GlobalMusicAudio.tsx @@ -44,8 +44,6 @@ export function GlobalMusicAudio() { const s = useMusicPlayerStore.getState(); musicAudioEngine.setVolume(s.volume); musicAudioEngine.setMuted(s.isMuted); - musicAudioEngine.setEqEnabled(s.eqEnabled); - musicAudioEngine.setEqGains(s.eqGains); bindMusicAudioSettings(); }, []); diff --git a/src/components/music/GlobalMusicDock.tsx b/src/components/music/GlobalMusicDock.tsx index 0b913971..d450dff6 100644 --- a/src/components/music/GlobalMusicDock.tsx +++ b/src/components/music/GlobalMusicDock.tsx @@ -29,6 +29,7 @@ import { useNowPlayingMenuActions } from "./useNowPlayingMenuActions"; import { MusicArtwork } from "./MusicArtwork"; import { MusicScrubber } from "./MusicScrubber"; import { EqPanel } from "./EqPanel"; +import { useEqActive } from "../../lib/audio/eq/useEqualizer"; import { VolumePopover } from "./VolumePopover"; import { useDominantColor } from "../../lib/useDominantColor"; import { accentForeground, accentSurface } from "../../lib/accentColor"; @@ -44,7 +45,7 @@ export function GlobalMusicDock() { const isPlaying = useMusicPlayerStore((s) => s.isPlaying); const isBuffering = useMusicPlayerStore((s) => s.isBuffering); const loadingStreamId = useMusicPlayerStore((s) => s.loadingStreamId); - const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled); + const eqEnabled = useEqActive(); const isShuffle = useMusicPlayerStore((s) => s.isShuffle); const repeatMode = useMusicPlayerStore((s) => s.repeatMode); const isMuted = useMusicPlayerStore((s) => s.isMuted); diff --git a/src/components/music/MusicOverlay.tsx b/src/components/music/MusicOverlay.tsx index 1a53d3bc..aa312143 100644 --- a/src/components/music/MusicOverlay.tsx +++ b/src/components/music/MusicOverlay.tsx @@ -38,6 +38,7 @@ import { MusicQueuePane } from "./MusicQueuePane"; import { MusicLyrics } from "./MusicLyrics"; import { AmbientBackdrop, isMusicBackgroundStyle } from "./AmbientBackdrop"; import { EqPanel } from "./EqPanel"; +import { useEqActive } from "../../lib/audio/eq/useEqualizer"; import { useLyrics } from "../../lib/lyrics/useLyrics"; import { useDominantColor } from "../../lib/useDominantColor"; import { accentForeground, accentSurface } from "../../lib/accentColor"; @@ -68,7 +69,7 @@ export function MusicOverlay() { const loadingStreamId = useMusicPlayerStore((s) => s.loadingStreamId); const repeatMode = useMusicPlayerStore((s) => s.repeatMode); const isShuffle = useMusicPlayerStore((s) => s.isShuffle); - const eqEnabled = useMusicPlayerStore((s) => s.eqEnabled); + const eqEnabled = useEqActive(); const togglePlay = useMusicPlayerStore((s) => s.togglePlay); const next = useMusicPlayerStore((s) => s.next); diff --git a/src/components/music/eq/EqResponseGraph.tsx b/src/components/music/eq/EqResponseGraph.tsx new file mode 100644 index 00000000..8773c810 --- /dev/null +++ b/src/components/music/eq/EqResponseGraph.tsx @@ -0,0 +1,198 @@ +import { useMemo, useRef, useState, type PointerEvent, type WheelEvent } from "react"; + +import { fractionOf, frequencyAt, magnitudeDb } from "../../../lib/audio/eq/filterMath"; +import { useElementWidth } from "../../../lib/useElementWidth"; +import { GraphicEq } from "../../../lib/audio/eq/limits"; +import { EqLimits, hasGain, type EqBand } from "../../../lib/audio/eq/models"; + +const POINTS = 160; +const LABELS: [number, string][] = [ + [31, "31"], + [125, "125"], + [500, "500"], + [2_000, "2k"], + [8_000, "8k"], + [16_000, "16k"], +]; + +export interface EqGraphHandle { + band: EqBand; + /** Graphic sliders only move up and down. */ + verticalOnly: boolean; +} + +export interface EqResponseGraphProps { + /** Every filter that shapes the sound, bass boost included. */ + bands: readonly EqBand[]; + /** Draggable points; none for the read-only mini graph. */ + handles?: readonly EqGraphHandle[]; + selected?: number | null; + height?: number; + rangeDb?: number; + onSelect?: (index: number) => void; + /** A point moved; the band it now describes. */ + onDrag?: (index: number, band: EqBand) => void; + onDragEnd?: () => void; + onAddAt?: (frequency: number, gain: number) => void; + ariaLabel: string; +} + +const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); + +/** The equalizer's frequency response, drawn from the same maths that sets the filters. */ +export function EqResponseGraph({ + bands, + handles = [], + selected = null, + height = 180, + rangeDb = 15, + onSelect, + onDrag, + onDragEnd, + onAddAt, + ariaLabel, +}: EqResponseGraphProps) { + const svgRef = useRef(null); + const [dragging, setDragging] = useState(null); + // Drawn at its real pixel size, so points stay round and labels unstretched. + const width = useElementWidth(svgRef, 600); + + const xOf = (frequency: number) => fractionOf(frequency) * width; + const yOf = (db: number) => height / 2 - (clamp(db, -rangeDb, rangeDb) / rangeDb) * (height / 2 - 6); + + const path = useMemo(() => { + let d = ""; + for (let i = 0; i <= POINTS; i++) { + const f = frequencyAt(i / POINTS); + d += `${i === 0 ? "M" : "L"}${((i / POINTS) * width).toFixed(1)},${yOf(magnitudeDb(bands, f)).toFixed(1)}`; + } + return d; + }, [bands, width, height, rangeDb]); + + const pointerToValues = (event: PointerEvent | { clientX: number; clientY: number }) => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return null; + const x = clamp((event.clientX - rect.left) / rect.width, 0, 1); + const y = clamp((event.clientY - rect.top) / rect.height, 0, 1); + const gain = ((0.5 - y) * 2 * rangeDb * height) / (height - 12); + return { frequency: frequencyAt(x), gain: clamp(gain, -rangeDb, rangeDb) }; + }; + + const moveHandle = (index: number, event: PointerEvent) => { + const handle = handles[index]; + const values = pointerToValues(event); + if (!handle || !values || !onDrag) return; + const { band } = handle; + if (handle.verticalOnly) { + const step = GraphicEq.STEP; + const gain = clamp(Math.round(values.gain / step) * step, -GraphicEq.MAX_GAIN, GraphicEq.MAX_GAIN); + onDrag(index, { ...band, gain: gain + 0 }); + return; + } + onDrag(index, { + ...band, + frequency: Math.round(values.frequency), + gain: hasGain(band.type) ? Math.round(values.gain * 10) / 10 : 0, + }); + }; + + // The wheel widens or narrows the selected band, on the same log scale as its Q slider. + const onWheel = (index: number, event: WheelEvent) => { + const handle = handles[index]; + if (!handle || handle.verticalOnly || !onDrag) return; + const factor = Math.pow(1.1, event.deltaY > 0 ? -1 : 1); + onDrag(index, { ...handle.band, q: clamp(handle.band.q * factor, EqLimits.MIN_Q, EqLimits.MAX_Q) }); + onDragEnd?.(); + }; + + return ( + { + const values = onAddAt ? pointerToValues(event) : null; + if (values) onAddAt?.(Math.round(values.frequency), Math.round(values.gain * 10) / 10); + }} + > + {[-12, -6, 0, 6, 12] + .filter((db) => Math.abs(db) < rangeDb) + .map((db) => ( + + ))} + {LABELS.map(([f, label]) => ( + + + {height >= 120 && ( + + {label} + + )} + + ))} + + {handles.map((handle, index) => { + const { band } = handle; + const active = index === selected || index === dragging; + return ( + { + event.stopPropagation(); + (event.target as Element).setPointerCapture(event.pointerId); + setDragging(index); + onSelect?.(index); + }} + onPointerMove={(event) => { + if (dragging === index) moveHandle(index, event); + }} + onPointerUp={() => { + if (dragging !== index) return; + setDragging(null); + onDragEnd?.(); + }} + onPointerCancel={() => { + setDragging(null); + onDragEnd?.(); + }} + onWheel={(event) => onWheel(index, event)} + onDoubleClick={(event) => event.stopPropagation()} + /> + ); + })} + + ); +} diff --git a/src/components/music/eq/eqFormat.test.ts b/src/components/music/eq/eqFormat.test.ts new file mode 100644 index 00000000..ed34651d --- /dev/null +++ b/src/components/music/eq/eqFormat.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { formatDb, formatFrequency } from "./eqFormat"; + +describe("eqFormat", () => { + it("labels frequencies the way EQ axes do", () => { + expect(formatFrequency(31)).toBe("31"); + expect(formatFrequency(1_000)).toBe("1k"); + expect(formatFrequency(2_500)).toBe("2.5k"); + expect(formatFrequency(16_000)).toBe("16k"); + }); + + it("signs gains and drops a negative zero", () => { + expect(formatDb(3)).toBe("+3"); + expect(formatDb(-1.5)).toBe("-1.5"); + expect(formatDb(-0.04)).toBe("0"); + }); +}); diff --git a/src/components/music/eq/eqFormat.ts b/src/components/music/eq/eqFormat.ts new file mode 100644 index 00000000..0b339054 --- /dev/null +++ b/src/components/music/eq/eqFormat.ts @@ -0,0 +1,15 @@ +/** "1k", "16k", "250": the way frequency axes are usually labelled. */ +export function formatFrequency(hz: number): string { + if (hz >= 1_000) { + const k = hz / 1_000; + return `${Number.isInteger(k) ? k : k.toFixed(1)}k`; + } + return String(Math.round(hz)); +} + +/** A signed gain with one decimal where it has one: "+3", "-1.5", "0". */ +export function formatDb(db: number): string { + const rounded = Math.round(db * 10) / 10; + const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); + return rounded > 0 ? `+${text}` : text; +} diff --git a/src/lib/audio/audioContext.ts b/src/lib/audio/audioContext.ts new file mode 100644 index 00000000..c6dfac47 --- /dev/null +++ b/src/lib/audio/audioContext.ts @@ -0,0 +1,61 @@ +type AudioContextCtor = typeof AudioContext; + +function audioContextCtor(): AudioContextCtor | null { + if (typeof window === "undefined") return null; + return ( + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: AudioContextCtor }).webkitAudioContext ?? + null + ); +} + +export const webAudioSupported = (): boolean => audioContextCtor() !== null; + +export function createAudioContext(): AudioContext | null { + const Ctor = audioContextCtor(); + return Ctor ? new Ctor() : null; +} + +/** + * How long a start waits for a suspended context. WebView2 can leave `resume()` pending forever + * without a user gesture, so playback must never wait on it unbounded. + */ +const RESUME_TIMEOUT_MS = 400; + +/** Resumes `ctx`, giving up after a short wait; the element starts either way. */ +export async function resumeWithin(ctx: AudioContext, timeoutMs = RESUME_TIMEOUT_MS): Promise { + if (ctx.state === "running" || ctx.state === "closed") return; + let timer: ReturnType | undefined; + try { + await Promise.race([ + ctx.resume(), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }), + ]); + } catch { + // A rejected resume leaves the context as it was; the next state change retries. + } finally { + clearTimeout(timer); + } +} + +/** + * Resumes `ctx` whenever the system suspends or interrupts it (sleep, a Bluetooth reconnect, an + * output device change) while something should be playing. Without this the element keeps + * advancing with no sound until the user pauses and plays again. + */ +export function keepRunning(ctx: AudioContext, shouldRun: () => boolean): () => void { + const recover = () => { + // WebKit reports "interrupted", which the DOM typings do not list. + const state = ctx.state as AudioContextState | "interrupted"; + if (state !== "running" && state !== "closed" && shouldRun()) void resumeWithin(ctx); + }; + ctx.addEventListener("statechange", recover); + const devices = typeof navigator !== "undefined" ? navigator.mediaDevices : undefined; + devices?.addEventListener?.("devicechange", recover); + return () => { + ctx.removeEventListener("statechange", recover); + devices?.removeEventListener?.("devicechange", recover); + }; +} diff --git a/src/lib/audio/eq/graphPlan.test.ts b/src/lib/audio/eq/graphPlan.test.ts new file mode 100644 index 00000000..e0f64c08 --- /dev/null +++ b/src/lib/audio/eq/graphPlan.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { band, EqLimits } from "./models"; +import { filterSetting, graphChangesSound, graphSpec, hasNonButterworthShelf, EQ_GRAPH_OFF } from "./graphPlan"; +import { defaultEqState, processingSpec, selectPreset, withBassBoost } from "./stateOps"; + +const RATE = 48_000; + +describe("graphPlan", () => { + it("gives bass boost its own shelf and keeps Android's preamp", () => { + const state = withBassBoost(selectPreset(defaultEqState(), "builtin:rock"), 6); + const spec = graphSpec(state, false); + expect(spec.bassBoostDb).toBe(6); + expect(spec.bands).toHaveLength(5); + expect(spec.preampDb).toBe(processingSpec(state, false).preampDb); + }); + + it("is off when the equalizer is off or held for compare", () => { + const state = selectPreset(defaultEqState(), "builtin:rock"); + expect(graphSpec({ ...state, enabled: false }, false)).toEqual(EQ_GRAPH_OFF); + expect(graphSpec(state, true)).toEqual(EQ_GRAPH_OFF); + expect(graphChangesSound(graphSpec(state, false), RATE)).toBe(true); + expect(graphChangesSound(graphSpec(defaultEqState(), false), RATE)).toBe(false); + }); + + it("hands WebAudio its own Q forms", () => { + expect(filterSetting(band(1_000, 3, 2), RATE)).toEqual({ type: "peaking", frequency: 1_000, q: 2, gain: 3 }); + expect(filterSetting(band(80, 4, 0.707, "LOW_SHELF"), RATE)?.type).toBe("lowshelf"); + const lowPass = filterSetting(band(8_000, 0, 0.707, "LOW_PASS"), RATE)!; + expect(lowPass.type).toBe("lowpass"); + // Butterworth's 0.707 is -3 dB in WebAudio's resonance form. + expect(lowPass.q).toBeCloseTo(-3.01, 2); + expect(filterSetting(band(30, 0, 1, "HIGH_PASS"), RATE)!.q).toBeCloseTo(0, 6); + }); + + it("leaves flat, disabled and out-of-range bands flat", () => { + expect(filterSetting(undefined, RATE)).toBeNull(); + expect(filterSetting(band(1_000, 0), RATE)).toBeNull(); + expect(filterSetting(band(1_000, 3, 1, "PEAK", false), RATE)).toBeNull(); + expect(filterSetting(band(EqLimits.MAX_FREQUENCY, 3), 22_050)).toBeNull(); + }); + + it("notices shelves whose Q WebAudio cannot play", () => { + expect(hasNonButterworthShelf({ preamp: 0, bands: [band(100, 3, 0.7, "LOW_SHELF")] })).toBe(false); + expect(hasNonButterworthShelf({ preamp: 0, bands: [band(100, 3, 2, "LOW_SHELF")] })).toBe(true); + expect(hasNonButterworthShelf({ preamp: 0, bands: [band(100, 3, 2)] })).toBe(false); + }); +}); diff --git a/src/lib/audio/eq/graphPlan.ts b/src/lib/audio/eq/graphPlan.ts new file mode 100644 index 00000000..c91eab40 --- /dev/null +++ b/src/lib/audio/eq/graphPlan.ts @@ -0,0 +1,74 @@ +import { isBypassed } from "./filterMath"; +import { EqLimits, type EqBand, type EqCurve, type EqState } from "./models"; +import { processingSpec } from "./stateOps"; + +/** Filters in the WebAudio chain. Unused ones sit flat, so adding a band never rewires the graph. */ +export const EQ_FILTER_POOL = EqLimits.MAX_BANDS; + +/** What one player's equalizer chain runs: the curve, bass boost on its own shelf, and the preamp. */ +export interface EqGraphSpec { + enabled: boolean; + preampDb: number; + bands: EqBand[]; + bassBoostDb: number; +} + +export const EQ_GRAPH_OFF: EqGraphSpec = { enabled: false, preampDb: 0, bands: [], bassBoostDb: 0 }; + +/** + * The chain's settings for `state`. The preamp is Android's `processingSpec`, so auto preamp + * counts bass boost; bass boost is split off again because it has its own filter. + */ +export function graphSpec(state: EqState, bypass: boolean, preview?: EqCurve | null): EqGraphSpec { + const spec = processingSpec(state, bypass, preview); + if (!spec.enabled) return EQ_GRAPH_OFF; + const boosted = state.bassBoost > 0; + return { + enabled: true, + preampDb: spec.preampDb, + bands: boosted ? spec.bands.slice(0, -1) : spec.bands, + bassBoostDb: boosted ? state.bassBoost : 0, + }; +} + +/** Whether running `spec` sounds different from not running it. */ +export function graphChangesSound(spec: EqGraphSpec, sampleRate: number): boolean { + return ( + spec.enabled && + (spec.preampDb !== 0 || spec.bassBoostDb > 0 || spec.bands.some((b) => !isBypassed(b, sampleRate))) + ); +} + +export interface FilterSetting { + type: BiquadFilterType; + frequency: number; + q: number; + gain: number; +} + +/** + * One BiquadFilterNode's settings for `band`, or null for a pool slot that should sit flat. + * WebAudio's shelves have no Q (they are always Butterworth), and its low and high pass take Q in + * dB, which is 20·log10 of the RBJ Q every other part of the model uses. + */ +export function filterSetting(band: EqBand | undefined, sampleRate: number): FilterSetting | null { + if (!band || isBypassed(band, sampleRate)) return null; + switch (band.type) { + case "PEAK": + return { type: "peaking", frequency: band.frequency, q: band.q, gain: band.gain }; + case "LOW_SHELF": + return { type: "lowshelf", frequency: band.frequency, q: 1, gain: band.gain }; + case "HIGH_SHELF": + return { type: "highshelf", frequency: band.frequency, q: 1, gain: band.gain }; + case "LOW_PASS": + return { type: "lowpass", frequency: band.frequency, q: 20 * Math.log10(band.q), gain: 0 }; + case "HIGH_PASS": + return { type: "highpass", frequency: band.frequency, q: 20 * Math.log10(band.q), gain: 0 }; + } +} + +/** Whether a shelf in `curve` plays differently in WebAudio than its stored Q says. */ +export const hasNonButterworthShelf = (curve: EqCurve): boolean => + curve.bands.some( + (b) => (b.type === "LOW_SHELF" || b.type === "HIGH_SHELF") && Math.abs(b.q - EqLimits.DEFAULT_SHELF_Q) > 0.01, + ); diff --git a/src/lib/audio/eq/stateOps.ts b/src/lib/audio/eq/stateOps.ts index dc3ae4d9..8b6b300e 100644 --- a/src/lib/audio/eq/stateOps.ts +++ b/src/lib/audio/eq/stateOps.ts @@ -128,16 +128,17 @@ export function canSaveActive(state: EqState): boolean { return isEdited(state) && userPreset(state, activeCopy(state).presetId)?.mode === state.mode; } -const withActive = (state: EqState, working: EqWorkingCopy): EqState => +/** Puts `working` in the current mode, preset id and all. */ +export const withWorkingCopy = (state: EqState, working: EqWorkingCopy): EqState => state.mode === "PARAMETRIC" ? { ...state, parametric: working } : { ...state, graphic: working }; export const withActiveCurve = (state: EqState, curve: EqCurve): EqState => - withActive(state, { ...activeCopy(state), curve: sanitizedFor(state.mode, curve) }); + withWorkingCopy(state, { ...activeCopy(state), curve: sanitizedFor(state.mode, curve) }); /** Plays preset `id` in the current mode; the mode itself never changes here. */ export function selectPreset(state: EqState, id: string): EqState { const curve = presetCurveFor(state, state.mode, id); - return curve ? withActive(state, { presetId: id, curve }) : state; + return curve ? withWorkingCopy(state, { presetId: id, curve }) : state; } export const revert = (state: EqState): EqState => @@ -152,7 +153,7 @@ export function saveActive(state: EqState): EqState { export function saveActiveAs(state: EqState, id: string, name: string): EqState { const curve = activeCopy(state).curve; const next = { ...state, userPresets: [...state.userPresets, { id, name, curve, mode: state.mode, imported: false }] }; - return withActive(next, { presetId: id, curve }); + return withWorkingCopy(next, { presetId: id, curve }); } export const renamePreset = (state: EqState, id: string, name: string): EqState => ({ diff --git a/src/lib/audio/eq/useEqualizer.ts b/src/lib/audio/eq/useEqualizer.ts new file mode 100644 index 00000000..0093ee8b --- /dev/null +++ b/src/lib/audio/eq/useEqualizer.ts @@ -0,0 +1,95 @@ +import { useMemo, useSyncExternalStore } from "react"; + +import { getString } from "../../i18n/index"; +import { musicAudioEngine } from "../musicAudioEngine"; +import { useEqStore } from "../../../store/useEqStore"; +import { BUILT_IN_EQ_PRESETS, isBuiltInPreset } from "./builtInPresets"; +import type { EqMode, EqState } from "./models"; +import { + activeCopy, + canSaveActive, + changesSound, + isEdited, + processingSpec, + selectPreset, + withWorkingCopy, +} from "./stateOps"; + +export interface EqPresetEntry { + id: string; + name: string; + builtIn: boolean; +} + +export const builtInPresetNames = (): string[] => BUILT_IN_EQ_PRESETS.map((p) => getString(p.nameKey)); + +/** Built-ins first, in Android's order, then the user's own. */ +export function presetEntries(state: EqState): EqPresetEntry[] { + return [ + ...BUILT_IN_EQ_PRESETS.map((p) => ({ id: p.id, name: getString(p.nameKey), builtIn: true })), + ...state.userPresets.map((p) => ({ id: p.id, name: p.name, builtIn: false })), + ]; +} + +export function presetName(state: EqState, id: string | null): string | null { + if (!id) return null; + return presetEntries(state).find((p) => p.id === id)?.name ?? null; +} + +/** + * Plays preset `id` in the current mode. An unsaved curve it replaces is kept as Custom, so + * picking a preset never loses a curve the user tuned. + */ +export function pickEqPreset(id: string): void { + const { state, update, setCustom } = useEqStore.getState(); + const active = activeCopy(state); + if (active.presetId === id) return; + // A flat curve with no preset is nothing to keep. + if (isEdited(state)) setCustom(state.mode, active); + update((s) => selectPreset(s, id)); +} + +/** Brings back the curve that was playing before a preset replaced it. */ +export function restoreCustomEq(mode: EqMode): void { + const { custom, update, setCustom } = useEqStore.getState(); + const copy = custom[mode]; + if (!copy) return; + setCustom(mode, undefined); + update((s) => (s.mode === mode ? withWorkingCopy(s, { presetId: null, curve: copy.curve }) : s)); +} + +/** The equalizer state the editor and the quick panel show, with what they derive from it. */ +export function useEqualizer() { + const state = useEqStore((s) => s.state); + const preview = useEqStore((s) => s.preview); + const custom = useEqStore((s) => s.custom[s.state.mode]); + return useMemo(() => { + const active = activeCopy(state); + const spec = processingSpec({ ...state, enabled: true }, false, preview); + return { + state, + active, + /** What the graph draws: the curve being dragged, or the active one, with bass boost. */ + drawnBands: spec.bands, + autoPreampDb: spec.preampDb, + edited: isEdited(state), + canSave: canSaveActive(state), + changesSound: changesSound(state), + hasCustom: !!custom || active.presetId === null, + selectedIsUserPreset: !!active.presetId && !isBuiltInPreset(active.presetId), + presets: presetEntries(state), + activeName: presetName(state, active.presetId), + }; + }, [state, preview, custom]); +} + +/** Whether the equalizer is on and changes the sound, for the buttons that open it. */ +export const useEqActive = (): boolean => useEqStore((s) => changesSound(s.state)); + +/** False when WebAudio failed for music, so the equalizer cannot be heard here. */ +export function useMusicEqAvailable(): boolean { + return useSyncExternalStore( + (onChange) => musicAudioEngine.onEqAvailabilityChange(onChange), + () => musicAudioEngine.isEqAvailable(), + ); +} diff --git a/src/lib/audio/eqBands.test.ts b/src/lib/audio/eqBands.test.ts deleted file mode 100644 index 4374ef2b..00000000 --- a/src/lib/audio/eqBands.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { EQ_FLAT, EQ_PRESETS, eqCurveBands } from "./eqBands"; -import { autoPreampDb, peakDb } from "./eq/filterMath"; - -describe("EQ headroom", () => { - it("takes the Bass preset's stacked boosts off before the filters", () => { - const bands = eqCurveBands(EQ_PRESETS.bass); - // +6, +5 and +4 dB on overlapping bands add up past any single slider. - expect(peakDb(bands)).toBeGreaterThan(6); - expect(autoPreampDb(bands)).toBeCloseTo(-peakDb(bands), 6); - }); - - it("leaves a flat or cut-only curve at unity", () => { - expect(autoPreampDb(eqCurveBands(EQ_FLAT))).toBe(0); - expect(autoPreampDb(eqCurveBands(EQ_FLAT.map(() => -3)))).toBe(0); - }); -}); diff --git a/src/lib/audio/eqBands.ts b/src/lib/audio/eqBands.ts deleted file mode 100644 index ff096ed8..00000000 --- a/src/lib/audio/eqBands.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { band, EqLimits, type EqBand } from "./eq/models"; - -export interface EqBandConfig { - frequency: number; - type: BiquadFilterType; - label: string; -} - -export const EQ_BANDS: EqBandConfig[] = [ - { frequency: 32, type: "lowshelf", label: "32" }, - { frequency: 64, type: "peaking", label: "64" }, - { frequency: 125, type: "peaking", label: "125" }, - { frequency: 250, type: "peaking", label: "250" }, - { frequency: 500, type: "peaking", label: "500" }, - { frequency: 1000, type: "peaking", label: "1K" }, - { frequency: 2000, type: "peaking", label: "2K" }, - { frequency: 4000, type: "peaking", label: "4K" }, - { frequency: 8000, type: "peaking", label: "8K" }, - { frequency: 16000, type: "highshelf", label: "16K" }, -]; - -export const EQ_BAND_COUNT = EQ_BANDS.length; - -/** Quality factor for the peaking bands. ~1.4 gives smooth, musical overlap. */ -export const EQ_PEAKING_Q = 1.41; - -/** Maximum boost/cut per band, in dB (slider range is ±this). */ -export const EQ_MAX_GAIN_DB = 12; - -/** A flat (0 dB everywhere) curve — the neutral default. */ -export const EQ_FLAT: number[] = EQ_BANDS.map(() => 0); - -export type EqPresetName = - | "flat" - | "bass" - | "vocal" - | "treble" - | "electronic" - | "acoustic"; - -// Preset gains are ordered low → high to match EQ_BANDS exactly. -export const EQ_PRESETS: Record = { - flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - bass: [6, 5, 4, 2, 0, 0, 0, 0, 1, 2], - vocal: [-2, -1, 0, 2, 4, 4, 3, 2, 0, -1], - treble: [0, 0, 0, 0, 0, 1, 2, 4, 5, 6], - electronic: [5, 4, 1, 0, -1, 1, 0, 2, 3, 4], - acoustic: [3, 2, 1, 1, 2, 2, 3, 3, 2, 1], -}; - -// Clamp an arbitrary gains array to exactly EQ_BAND_COUNT entries in range. -export function normalizeEqGains(gains: number[] | null | undefined): number[] { - const clampDb = (n: number) => - Math.max(-EQ_MAX_GAIN_DB, Math.min(EQ_MAX_GAIN_DB, Number.isFinite(n) ? n : 0)); - return EQ_BANDS.map((_, i) => clampDb(gains?.[i] ?? 0)); -} - -/** - * The curve `gains` plays, in the shared EQ model. WebAudio's shelves ignore Q and always have the - * Butterworth slope, so they are modelled at that Q. - */ -export function eqCurveBands(gains: readonly number[]): EqBand[] { - return EQ_BANDS.map((config, i) => - config.type === "peaking" - ? band(config.frequency, gains[i] ?? 0, EQ_PEAKING_Q, "PEAK") - : band( - config.frequency, - gains[i] ?? 0, - EqLimits.DEFAULT_SHELF_Q, - config.type === "lowshelf" ? "LOW_SHELF" : "HIGH_SHELF", - ), - ); -} diff --git a/src/lib/audio/eqChain.ts b/src/lib/audio/eqChain.ts new file mode 100644 index 00000000..12b3b406 --- /dev/null +++ b/src/lib/audio/eqChain.ts @@ -0,0 +1,72 @@ +import { EQ_FILTER_POOL, EQ_GRAPH_OFF, filterSetting, type EqGraphSpec } from "./eq/graphPlan"; +import { EqLimits } from "./eq/models"; +import { SOFT_CLIP_HEADROOM, softClipCurve } from "./softClip"; + +/** Time constant every parameter glides with: quick enough to feel instant, slow enough not to click. */ +export const RAMP_TIME_CONSTANT = 0.012; + +let sharedClipCurve: Float32Array | null = null; + +/** + * One player's equalizer: preamp, a fixed pool of filters, a bass boost shelf and a soft clipper. + * Nodes are made once; a new curve only moves their parameters, so the sound glides from one + * setting to the next and nothing is ever reconnected while audio plays. + */ +export class EqChain { + readonly input: GainNode; + readonly output: AudioNode; + private readonly filters: BiquadFilterNode[]; + private readonly bass: BiquadFilterNode; + + constructor(private readonly ctx: BaseAudioContext) { + this.input = ctx.createGain(); + this.filters = Array.from({ length: EQ_FILTER_POOL }, () => { + const f = ctx.createBiquadFilter(); + f.type = "peaking"; + f.gain.value = 0; + return f; + }); + this.bass = ctx.createBiquadFilter(); + this.bass.type = "lowshelf"; + this.bass.frequency.value = EqLimits.BASS_BOOST_FREQUENCY; + this.bass.gain.value = 0; + + // A WaveShaperNode clamps its input to ±1, so the signal goes in scaled down and the curve + // spans the headroom; below the knee the curve is the identity, so normal levels pass as-is. + const clipIn = ctx.createGain(); + clipIn.gain.value = 1 / SOFT_CLIP_HEADROOM; + const clipper = ctx.createWaveShaper(); + sharedClipCurve ??= softClipCurve(); + clipper.curve = sharedClipCurve; + + let previous: AudioNode = this.input; + for (const node of [...this.filters, this.bass, clipIn, clipper]) { + previous.connect(node); + previous = node; + } + this.output = clipper; + } + + apply(spec: EqGraphSpec = EQ_GRAPH_OFF): void { + const t = this.ctx.currentTime; + const glide = (param: AudioParam, value: number) => param.setTargetAtTime(value, t, RAMP_TIME_CONSTANT); + const sampleRate = this.ctx.sampleRate; + + this.filters.forEach((filter, i) => { + const setting = spec.enabled ? filterSetting(spec.bands[i], sampleRate) : null; + if (!setting) { + // Peaks and shelves go flat at 0 dB whatever their frequency, so only the gain moves; a + // low or high pass cannot be flattened and becomes a flat peak. + if (filter.type === "lowpass" || filter.type === "highpass") filter.type = "peaking"; + glide(filter.gain, 0); + return; + } + if (filter.type !== setting.type) filter.type = setting.type; + glide(filter.frequency, setting.frequency); + glide(filter.Q, setting.q); + glide(filter.gain, setting.gain); + }); + glide(this.bass.gain, spec.enabled ? spec.bassBoostDb : 0); + glide(this.input.gain, spec.enabled ? Math.pow(10, spec.preampDb / 20) : 1); + } +} diff --git a/src/lib/audio/musicAudioEngine.eq.test.ts b/src/lib/audio/musicAudioEngine.eq.test.ts new file mode 100644 index 00000000..34da8779 --- /dev/null +++ b/src/lib/audio/musicAudioEngine.eq.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { graphSpec, EQ_GRAPH_OFF } from "./eq/graphPlan"; +import { defaultEqState, selectPreset } from "./eq/stateOps"; +import { musicAudioEngine } from "./musicAudioEngine"; + +// A minimal WebAudio stand-in: enough to see which nodes exist and what they were told. +class FakeParam { + constructor(public value = 0) {} + setTargetAtTime = vi.fn((value: number) => { + this.value = value; + }); +} +class FakeNode { + connect = vi.fn((node: unknown) => node); +} +class FakeGain extends FakeNode { + gain = new FakeParam(1); +} +class FakeBiquad extends FakeNode { + type = "lowpass"; + frequency = new FakeParam(350); + Q = new FakeParam(1); + gain = new FakeParam(0); +} +class FakeShaper extends FakeNode { + curve: Float32Array | null = null; +} + +const contexts: FakeContext[] = []; +class FakeContext extends EventTarget { + state: AudioContextState = "running"; + currentTime = 0; + sampleRate = 48_000; + destination = new FakeNode(); + biquads: FakeBiquad[] = []; + sources: HTMLMediaElement[] = []; + resume = vi.fn(async () => { + this.state = "running"; + }); + constructor() { + super(); + contexts.push(this); + } + createGain() { + return new FakeGain(); + } + createBiquadFilter() { + const f = new FakeBiquad(); + this.biquads.push(f); + return f; + } + createWaveShaper() { + return new FakeShaper(); + } + createMediaElementSource(el: HTMLMediaElement) { + this.sources.push(el); + return new FakeNode(); + } +} + +const elements = (): [HTMLAudioElement, HTMLAudioElement] => { + const pair = [document.createElement("audio"), document.createElement("audio")] as [ + HTMLAudioElement, + HTMLAudioElement, + ]; + for (const el of pair) { + el.load = vi.fn(); + el.pause = vi.fn(); + el.play = vi.fn(async () => {}); + } + return pair; +}; + +const rock = graphSpec(selectPreset(defaultEqState(), "builtin:rock"), false); + +// The engine is a singleton that keeps its context, so tests share the last one made. +const lastContext = () => contexts[contexts.length - 1]!; + +beforeEach(() => { + vi.stubGlobal("AudioContext", FakeContext); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("music EQ graph", () => { + it("plays straight from the elements until the equalizer changes the sound", () => { + const pair = elements(); + musicAudioEngine.attach(pair); + musicAudioEngine.setEq(EQ_GRAPH_OFF, false); + musicAudioEngine.setVolume(0.5); + musicAudioEngine.setLoudness(6, true); + + expect(contexts).toHaveLength(0); + // Normalization turns the element down by itself: 6 dB over the target is about half. + expect(pair[0].volume).toBeCloseTo(0.25, 2); + expect(musicAudioEngine.isEqAvailable()).toBe(true); + }); + + it("builds one chain of 20 filters plus bass boost, and keeps it through a gapless swap", async () => { + const pair = elements(); + musicAudioEngine.attach(pair); + musicAudioEngine.setEq(rock, true); + + const ctx = lastContext(); + expect(ctx.sources).toEqual(pair); + expect(ctx.biquads).toHaveLength(21); + expect(ctx.biquads[0]!.gain.value).toBe(rock.bands[0]!.gain); + expect(ctx.biquads[5]!.gain.value).toBe(0); + + await musicAudioEngine.load("http://127.0.0.1/stream/a"); + musicAudioEngine.preload("b", "http://127.0.0.1/stream/b", null); + Object.defineProperty(pair[1], "readyState", { value: HTMLMediaElement.HAVE_FUTURE_DATA, configurable: true }); + expect(musicAudioEngine.activatePreloaded("b")).toBe(true); + + // No new sources or filters: the swap only changes which element is heard. + expect(ctx.sources).toHaveLength(2); + expect(ctx.biquads).toHaveLength(21); + expect(ctx.biquads[0]!.gain.value).toBe(rock.bands[0]!.gain); + + musicAudioEngine.setEq(EQ_GRAPH_OFF, false); + expect(ctx.biquads.every((f) => f.gain.value === 0)).toBe(true); + }); + + it("starts the element even when the context will not resume", async () => { + vi.useFakeTimers(); + const pair = elements(); + musicAudioEngine.attach(pair); + musicAudioEngine.setEq(rock, true); + const ctx = lastContext(); + ctx.state = "suspended"; + ctx.resume.mockImplementation(() => new Promise(() => {})); + + const started = musicAudioEngine.play(); + await vi.advanceTimersByTimeAsync(500); + await started; + expect(pair[0].play).toHaveBeenCalled(); + }); + + it("resumes the context when the system suspends it mid-song", () => { + const pair = elements(); + musicAudioEngine.attach(pair); + musicAudioEngine.setEq(rock, true); + const ctx = lastContext(); + Object.defineProperty(pair[0], "paused", { value: false, configurable: true }); + + ctx.state = "suspended"; + ctx.dispatchEvent(new Event("statechange")); + expect(ctx.resume).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts index 7c4166cf..50deaa65 100644 --- a/src/lib/audio/musicAudioEngine.ts +++ b/src/lib/audio/musicAudioEngine.ts @@ -1,23 +1,7 @@ import { loudnessFactor } from "../loudness"; -import { - EQ_BANDS, - EQ_PEAKING_Q, - eqCurveBands, - normalizeEqGains, -} from "./eqBands"; -import { autoPreampDb } from "./eq/filterMath"; -import { SOFT_CLIP_HEADROOM, softClipCurve } from "./softClip"; - -type AudioContextCtor = typeof AudioContext; - -function getAudioContextCtor(): AudioContextCtor | null { - if (typeof window === "undefined") return null; - return ( - window.AudioContext ?? - (window as unknown as { webkitAudioContext?: AudioContextCtor }).webkitAudioContext ?? - null - ); -} +import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from "./audioContext"; +import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan"; +import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain"; /** * One of the two