import type { PeqFilter, PeqState } from "@/lib/luxsinApi"; import { normalizePeqFiltersForSubmit } from "@/lib/luxsinApi"; import { getFilterShortName, getFilterType } from "@/lib/peqAudio"; import { BAND_FREQ_MIN, BAND_FREQ_MAX, BAND_GAIN_MIN, BAND_GAIN_MAX, BAND_Q_MIN, BAND_Q_MAX, type BandParamKind, type PeqCatalogItem, } from "./constants"; /* ── Template interpolation ── */ export function eqInterp( template: string | undefined, vars: Record, ): string { if (!template) return ""; let s = template; for (const [k, v] of Object.entries(vars)) { s = s.split(`{{${k}}}`).join(String(v)); } return s; } /* ── Band frequency display formatting ── */ export function formatBandFreqDisplay(freq: number) { return freq >= 1000 ? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` : `${freq} Hz`; } export function formatBandGainDisplay(gain: unknown) { const db = typeof gain === "number" && Number.isFinite(gain) ? gain : Number(gain); if (!Number.isFinite(db)) return "0.0 dB"; const text = db.toFixed(1); return `${db >= 0 ? `+${text}` : text} dB`; } export function formatBandQDisplay(q: unknown) { const n = typeof q === "number" && Number.isFinite(q) ? q : Number(q); return Number.isFinite(n) ? n.toFixed(2) : "—"; } /* ── Band param value for input field ── */ export function formatBandParamForInput( kind: BandParamKind, band: { freq: number; gain: number; q: number }, ) { switch (kind) { case "freq": return String(band.freq); case "gain": return band.gain.toFixed(1); case "q": return band.q.toFixed(2); } } /* ── Parse band param input with validation ── */ export function parseBandParamInput( kind: BandParamKind, raw: string, messages: { invalid: string; outOfRange: string }, ): { ok: true; value: number } | { ok: false; message: string } { const trimmed = raw.trim(); if (!trimmed) return { ok: false, message: messages.invalid }; if (kind === "freq") { let s = trimmed.replace(/\s+/g, "").toLowerCase().replace(/hz$/, ""); const kHz = /k(hz)?$/.test(s); if (kHz) s = s.replace(/k(hz)?$/, ""); const num = Number.parseFloat(s); if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; const hz = Math.round(kHz ? num * 1000 : num); if (hz < BAND_FREQ_MIN || hz > BAND_FREQ_MAX) { return { ok: false, message: messages.outOfRange }; } return { ok: true, value: hz }; } if (kind === "gain") { const s = trimmed.replace(/\s*dB\s*$/i, "").trim(); const num = Number.parseFloat(s); if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; const gain = Number(num.toFixed(1)); if (gain < BAND_GAIN_MIN || gain > BAND_GAIN_MAX) { return { ok: false, message: messages.outOfRange }; } return { ok: true, value: gain }; } const num = Number.parseFloat(trimmed); if (!Number.isFinite(num)) return { ok: false, message: messages.invalid }; const q = Number(num.toFixed(2)); if (q < BAND_Q_MIN || q > BAND_Q_MAX) { return { ok: false, message: messages.outOfRange }; } return { ok: true, value: q }; } /* ── Normalize filter type from string or number ── */ export function normalizeFilterType( type: string | number | undefined, ): string { if (type === undefined || type === null) return "PEAK"; if (typeof type === "number") { switch (type) { case 0: return "LPF"; case 1: return "HPF"; case 2: return "BPF"; case 3: return "NOTCH"; case 4: return "PEAK"; case 5: return "LSHELF"; case 6: return "HSHELF"; case 7: return "APF"; default: return "PEAK"; } } return getFilterShortName(type); } /* ── Clone bands array ── */ export function cloneBands( source: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean; }>, ) { return source.map((band) => ({ ...band })); } /* ── Frequency label for band grid (numeric only; avoid browser translate → words) ── */ export function freqLabel(freq: unknown) { const hz = typeof freq === "number" && Number.isFinite(freq) ? freq : Number(freq); if (!Number.isFinite(hz)) return "—"; if (hz >= 1000) { const k = hz / 1000; const text = k >= 10 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, ""); return `${text}K`; } return String(Math.round(hz)); } /** Dev: log decoded syncPeq JSON after EQ page fetches device PEQ state. */ export function logEqSyncPeqDev(source: string, data: PeqState): void { if (!import.meta.env.DEV) return; console.log(`[EQ syncPeq] ${source}`, data); } export async function fetchEqSyncPeq( api: { getPeqState: () => Promise }, source: string, ): Promise { const data = await api.getPeqState(); logEqSyncPeqDev(source, data); return data; } /* ── Build PEQ catalog sync key ── */ export function buildPeqCatalogSyncKey( remote: Pick | null | undefined, devicePeqSelect?: number, ): string { const items = remote?.peq; if (!items?.length) return items ? "empty" : ""; const select = devicePeqSelect ?? remote?.peqSelect ?? 0; return `${select}|${items.map((p) => p.name).join("\u0001")}`; } /* ── Generate unique preset name ── */ export function getUniquePresetName( base: string, existingNames: string[], ) { if (!existingNames.includes(base)) return base; let index = 1; while (existingNames.includes(`${base}_${index}`)) { index += 1; } return `${base}_${index}`; } /** Normalize shared/imported EQ filters into device PeqFilter[]. */ export function importedFiltersToPeqFilters(filters: unknown): PeqFilter[] { return normalizePeqFiltersForSubmit(filters).map((f) => { const item = f as Record; return { fc: Number(item.fc ?? item.freq ?? item.frequency ?? 1000), gain: Number(item.gain ?? 0), q: Number(item.q ?? 1), type: getFilterType(item.type as string | number), }; }); } /* ── Convert UI band → device PeqFilter ── */ export function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: string | number; }): PeqFilter { return { fc: b.freq, gain: b.gain, q: b.q, type: getFilterType(b.type), }; }