// core/config.js — zentrale CONFIG + Presets + Persistenz function isLoopbackHost(host) { const h = String(host || '').trim().toLowerCase(); return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]'; } function normalizePhoenixBaseUrlForCurrentClient(rawUrl) { const fallback = resolvePhoenixDefaultBaseUrl(); const input = String(rawUrl || '').trim(); if (!input) return fallback; try { const currentHost = String(globalThis?.location?.hostname || '').trim(); const parsed = new URL(input, fallback); if (!currentHost) return parsed.toString(); if (!isLoopbackHost(currentHost)) { const parsedHost = String(parsed.hostname || '').trim(); if (isLoopbackHost(parsedHost)) { parsed.hostname = currentHost; } } return parsed.toString(); } catch (_) { return fallback; } } function resolvePhoenixDefaultBaseUrl() { try { const host = String(globalThis?.location?.hostname || '').trim(); if (host) return `http://${host}:8789`; } catch (_) {} return 'http://127.0.0.1:8789'; } const DEFAULT_PHOENIX_BASE_URL = resolvePhoenixDefaultBaseUrl(); const SOFTWARE_PRESET_ENDPOINT = '/api/v1/frontend-presets'; const LAYOUT_PRESET_ENDPOINT = '/api/v1/frontend-layouts'; // Define CONFIG first, then export it const CONFIG = { FFT_SIZE: 8192, PHOENIX_BASE_URL: DEFAULT_PHOENIX_BASE_URL, DBFS_TOP: 9, DBFS_BOTTOM: -63, AXIS_GUTTER_LEFT: 14, Y_TICKS: [], AL_MARKERS_ENABLED: false, REALTIME_RENDER_STYLE: 'bars', REALTIME_BAR_HOLD_MS: 800, REALTIME_BAR_DECAY_DB_PER_S: 20, RTA_ENGINE: 'iir', // 'fft' | 'iir' RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf' RTA_BPO_MODE: '1_3', RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c' RTA_DISPLAY_GAIN_FFT_DB: 20, RTA_DISPLAY_GAIN_IIR_DB: 20, RTA_BAR_LAYOUT: 'rtw', // 'iec' | 'rtw' RTA_BAR_BASE_COLOR: '#ffe066', HEADER_TEXT_COLOR: '#ffe066', RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'medium' | 'slow' | 'average' | 'peak' RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both' RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual' RTA_PEAK_HOLD_SEC: 2.5, RTA_PEAK_DECAY_DB_PER_S: 20, RTA_DISPLAY_HOLD_SEC: 0, RTA_IIR_ORDER: 6, RTA_IIR_TAU_FAST: 0.12, RTA_IIR_TAU_SLOW: 1.0, SPECTRO_GAMMA: 0.4, SPECTRO_SCROLL_MODE: 1, // 0.5=Langsam, 1=Normal, 2=Licht, 4=Lächerlich, 6=Wahnsinnig PEAK_HISTORY_SCROLL_MODE: 1, // 0.5=Langsam, 1=Normal, 2=Schnell, 4=Turbo, 6=Max PEAK_HISTORY_FILL_ENABLED: false, PEAK_HISTORY_FILL_INVERT: false, PEAK_HISTORY_SLOT: 'ppm-din', SPECTRO_USE_WORKER: true, // Falls true: Offscreen-Worker; false: Fallback im Main-Thread SPECTRO_DEBUG: false, WAVEFORM_WINDOW_SEC: 1, WAVEFORM_MODE: 'stacked', WAVEFORM_COLOR_LEFT: '#00e7ff', WAVEFORM_COLOR_RIGHT: '#ff6b81', WAVEFORM_COLOR_DIFF: '#00e7ff', INPUT_OFFSET_DB_L: -5.0, INPUT_OFFSET_DB_R: -5.0, INPUT_OFFSET_GAIN_L: Math.pow(10, -5.0 / 20), INPUT_OFFSET_GAIN_R: Math.pow(10, -5.0 / 20), MONO_INPUT: false, LR_FRACTIONAL_DELAY_ENABLED: true, LR_FRACTIONAL_DELAY_SAMPLES: 0.996, SCREENSAVER_ENABLED: true, SCREENSAVER_IDLE_MIN: 5, SCREENSAVER_ACTIVITY_DB: -50, SCREENSAVER_MODE: 'clock', // 'dvd' | 'starfield' | 'clock' | 'black' SCREENSAVER_LED_GLOW: true, SCREENSAVER_LED_COLOR: '#ff0000', CLOCK_LED_GLOW: true, CLOCK_LED_COLOR: '#ff0000', CLOCK_STYLE: 'analog', // 'analog' | 'digital' | 'analog-ppm' | 'digital-ppm' CLOCK_PPM_LED_MODE: false, // Alignment-Profile (EBU default) definieren die digitale Referenz für 0 dBu. ALIGNMENT_MODE: 'ebu', DIGITAL_REF_DBFS_RMS: -18.0, // 0 dBu ≙ −18 dBFS RMS (EBU) VU_REF_IS_PLUS4: true, // 0 VU soll +4 dBu entsprechen → DIGITAL_REF + 4 dB. VU_DBFS_REF: -14.0, VU_OFFSET_DB: 1.92, VU_HEADER_SHOW_VALUE: false, TP_OFFSET_DB: 0.0, TP_HEADER_SHOW_VALUE: false, RMS_OFFSET_DB: 0.0, RMS_HEADER_SHOW_VALUE: false, VU_RED_START: 0.0, TP_RED_START: -1.0, RMS_RED_START: -3.0, VU_RED_BAR_ONLY: true, PPM_RED_BAR_ONLY: true, TP_RED_BAR_ONLY: true, RMS_RED_BAR_ONLY: true, RMS_TC_MODE: 'fast', // 'impulse' (35 ms), 'fast' (125 ms), 'slow' (1000 ms), 'none' RMS_TC_MS: null, // optional override in ms (falls gesetzt, überschreibt den Modus) VU_COLOR_NORMAL: '#ffe066', VU_COLOR_WARN: '#ff3b3b', PPM_DIN_COLOR_NORMAL: '#ffe066', PPM_DIN_COLOR_WARN: '#ff3b3b', PPM_DIN_HEADER_SHOW_VALUE: false, PPM_EBU_COLOR_NORMAL: '#ffe066', PPM_EBU_COLOR_WARN: '#ff3b3b', PPM_EBU_HEADER_SHOW_VALUE: false, TP_COLOR_NORMAL: '#ffe066', TP_COLOR_WARN: '#ff3b3b', HIFI_PEAK_ALIGNMENT: 'ppm_din_minus5', PPM_DIN_LOUDNESS_BOXES: true, PPM_DIN_LOUDNESS_OFFSET_DB: 0.0, // RMS colours: use a green bar for normal RMS to distinguish from PPM/VU. RMS_COLOR_NORMAL: '#34d399', RMS_COLOR_WARN: '#ff3b3b', // RMS meter defaults to dBFS mode. In German Rundfunk entspricht der // Alignment‑Level (0 dBu) −18 dBFS RMS【212988610891871†L41-L43】; 0 VU (+4 dBu) // liegt 4 dB darüber bei −14 dBFS. Viele Anwender möchten die digitale // Anzeige (dBFS) sehen. Setze daher RMS_MODE auf 'dbfs'. Wenn RMS_MODE // auf 'dbu' umgeschaltet würde, nutzt die Umrechnung die folgenden // Referenzen: 0 dBu entspricht −18 dBFS RMS (RMS_REF_DBFS_FOR_REF_DBU) und // RMS_REF_DBU definiert den dBu‑Wert bei diesem Pegel. Wir setzen // RMS_REF_DBU auf 0, sodass −18 dBFS → 0 dBu und −14 dBFS → +4 dBu. RMS_MODE: 'dbfs', RMS_REF_DBFS_FOR_REF_DBU: -18, RMS_REF_DBU: 0, // EBU (Type IIb) PPM scale settings. The scale runs from −12 dB to // +12 dB with the red zone starting at +9 dB. Attack and decay // parameters are defined below. PPM_EBU_TOP: +12, PPM_EBU_BOTTOM: -12, PPM_EBU_RED_START: +9, // DIN (Type I) PPM scale settings. The DIN scale covers −50 dB to +5 dB // and the red zone begins at 0 dB. Attack and decay parameters follow. PPM_DIN_TOP: +5, PPM_DIN_BOTTOM: -50, PPM_DIN_RED_START: 0, // Fixed RTW/DIN profile: 10 ms integration and 20 dB return in 1.5 s. PPM_DIN_ATTACK_MS: 10, // Non-normative: when enabled, PPM DIN attack becomes instant (no integration). // Useful to compensate perceived meter lag caused by device / pipeline latency. PPM_DIN_FAST_ATTACK: false, PPM_DIN_DECAY_DB_PER_S: 20 / 1.5, // Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der Peak‑Hold // auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß // Rundfunkpraxis werden Peak‑Hold‑Striche häufig 1–2 Sekunden gehalten. PPM_DIN_HOLD_MS: 1000, // Decay rate for the DIN PPM hold. When the held peak decays, it uses // the same slope as the main meter (≈13.33 dB/s). PPM_DIN_HOLD_DECAY_DB_PER_S: 20 / 1.5, // Offsets für die PPM-Meter (DIN/EBU-spezifisch). Die EBU/DIN Offsets // ersetzen den früheren globalen PPM_OFFSET. PPM_DIN_OFFSET: -8.90, PPM_EBU_OFFSET: 0.0, PPM_REF_DBFS_PEAK_FOR_0_DBU: -15.0, // Ballistic parameters for the EBU PPM (Type IIb). Attack ~10 ms and // decay 24 dB in 2.8 s (≈8.57 dB/s), per EBU Tech 3205-E Type IIb. A short // peak-hold (~750 ms) helps visualise transients. Hold decay matches // the main decay rate. PPM_EBU_ATTACK_MS: 10, PPM_EBU_DECAY_DB_PER_S: 24 / 2.8, PPM_EBU_HOLD_MS: 750, PPM_EBU_HOLD_DECAY_DB_PER_S: 24 / 2.8, CORR_SMOOTH: 0.85, CORR_ZERO_ON_SILENCE: false, CORR_SILENCE_THRESHOLD_RMS_DBFS: -75, CORR_RESPONSE_S: 1.0, CORR_RESET_TOKEN: 0, XY_POINTS: 1024, XY_STYLE: 'lines', XY_SILENCE_GATE_ENABLED: true, XY_SILENCE_THRESHOLD_RMS_DBFS: -75, METER_BAR_THIN: 0.55, GONI_METER_GAP: 6, GONIO_DISPLAY_GAIN_DB: -5, GONIO_MANUAL_GAIN_DB: -5, GONIO_AGC_ENABLED: false, GONIO_LINE_FADE_MS: 50, GONIO_PERSISTENCE_MODE: 'fast', PHASE_DISPLAY_GAIN_DB: 0, PHASE_AGC_ENABLED: false, PHASE_TRAIL_ENABLED: true, PHASE_AMPLITUDE_MODE: 'ppm-din', // 'bandpass' | 'ppm-din' CLASSIC_NEEDLES_SLOT: 'vu', PANEL_DIVIDERS_ENABLED: true, LUFS_RED_START: -1.0, LUFS_YELLOW_START: -2.0, LUFS_GREEN_START: -5.0, LUFS_COLOR_GREEN: '#34d399', LUFS_COLOR_YELLOW: '#ffe066', LUFS_COLOR_RED: '#ff3b3b', LUFS_COLOR_I_GREEN: '#34d399', LUFS_COLOR_I_YELLOW: '#ffe066', LUFS_COLOR_I_RED: '#ff3b3b', LUFS_COLOR_I: '#34d399', LUFS_COLOR_M_GREEN: '#34d399', LUFS_COLOR_M_YELLOW: '#ffe066', LUFS_COLOR_M_RED: '#ff3b3b', LUFS_COLOR_M: '#ffe066', LUFS_COLOR_S_GREEN: '#34d399', LUFS_COLOR_S_YELLOW: '#ffe066', LUFS_COLOR_S_RED: '#ff3b3b', LUFS_COLOR_S: '#ff3b3b', LUFS_SCALE_LABEL_COLOR: '#8fd3d4', // LUFS Integrated: rollierendes Zeitfenster (Minuten). // Hinweis: Erhöhen kann bereits verworfene Historie nicht zurückholen. LUFS_I_WINDOW_MIN: 4, // LUFS Integrated: normgerecht über gesamte Laufzeit (seit Start/Reset), // deaktiviert das rollierende Zeitfenster. LUFS_I_NORM_ENABLED: false, // Stoppuhr: Anzeige-Stil im Slot-Meter. // 'mono' = normale Canvas-Schrift, 'seven' = 7-Segment-Rendering. STOPWATCH_DISPLAY_STYLE: 'mono', STOPWATCH_AUTO_THRESHOLD_DBFS: -70, // UI: zuletzt gewählte Ansicht (Fallback, falls localStorage-Flush bei hartem Neustart zu spät ist). UI_LAST_STYLE: 'realtime', UI_LAST_NON_OPTION_STYLE: 'realtime', RECORD_OUTPUT_FORMAT: 'wav', // 'wav' | 'mp3' | 'webm' RECORD_MP3_BITRATE_KBPS: 192, RECORD_TARGET: 'download', // 'download' | 'analyzer' | 'volumio' RECORD_PI_TARGET: 'analyzer', // globales Pi-Ziel: 'analyzer' | 'volumio' RECORD_AUTO_DOWNLOAD: false, RECORD_AUTO_SPLIT_GAP_SEC: 1.5, RECORD_AUTO_THRESHOLD_DBFS: -50, RECORD_SHOW_RECORDER_AB: true, SPLIT_VIEW_LEFT: 'realtime', SPLIT_VIEW_RIGHT: 'goniometer-rtw', SPLIT_VIEW_TAP_ACTION: 'popup', // 'switch' | 'popup' QUAD_VIEW_TL: 'peak-history', QUAD_VIEW_TR: 'realtime', QUAD_VIEW_BL: 'phase-wheel', QUAD_VIEW_BR: 'goniometer-rtw', QUAD_VIEW_TAP_ACTION: 'popup', // 'switch' | 'popup' QUAD_VIEW_METER_COUNT: 1, // 0..3 QUAD_VIEW_METER_1: 'ppm-din', QUAD_VIEW_METER_1_POS: 'right', // 'left' | 'center' | 'right' QUAD_VIEW_METER_2: 'ppm-din', QUAD_VIEW_METER_2_POS: 'right', QUAD_VIEW_METER_3: 'lufs', QUAD_VIEW_METER_3_POS: 'right', SPLIT_VIEW_METER_COUNT: 1, // 0..3 SPLIT_VIEW_METER_1: 'ppm-din', SPLIT_VIEW_METER_1_POS: 'center', // 'left' | 'center' | 'right' SPLIT_VIEW_METER_2: 'ppm-din', SPLIT_VIEW_METER_2_POS: 'right', SPLIT_VIEW_METER_3: 'lufs', SPLIT_VIEW_METER_3_POS: 'right', // Hold and decay times for VU meters. // VU_HOLD_MS: how long (in ms) to hold the VU peak before it starts decaying. // VU_DECAY_DB_PER_S: decay speed in dB per second for VU peaks. VU_HOLD_MS: 1000, VU_DECAY_DB_PER_S: 11.1, TP_HOLD_MS: 1000, TP_DECAY_DB_PER_S: 20, PPM_DIN_TRIM_DB: 0, PPM_DIN_MODE: 'al_minus6', SOFTWARE_PRESET: 'default', }; const CONFIG_DEFAULTS = JSON.parse(JSON.stringify(CONFIG)); function applyRtaBpoSelection(value) { const mode = ['1_3', '1_6', '1_12'].includes(String(value)) ? String(value) : '1_3'; CONFIG.RTA_BPO_MODE = mode; // RTW remains the original 31-band third-octave reference profile. Finer // resolutions are Phoenix extensions and switch visibly to the IEC profile. if (mode !== '1_3' && CONFIG.RTA_BAR_LAYOUT === 'rtw') { CONFIG.RTA_BAR_LAYOUT = 'iec'; } return mode; } const SOFTWARE_PRESET_IDS = Object.freeze(['default', 'claus', 'michael']); const LAYOUT_PRESET_IDS = Object.freeze(['1', '2', '3', '4']); const LAYOUT_PRESET_KEYS = Object.freeze([ 'UI_LAST_STYLE', 'UI_LAST_NON_OPTION_STYLE', 'SPLIT_VIEW_LEFT', 'SPLIT_VIEW_RIGHT', 'SPLIT_VIEW_TAP_ACTION', 'SPLIT_VIEW_METER_COUNT', 'SPLIT_VIEW_METER_1', 'SPLIT_VIEW_METER_1_POS', 'SPLIT_VIEW_METER_2', 'SPLIT_VIEW_METER_2_POS', 'SPLIT_VIEW_METER_3', 'SPLIT_VIEW_METER_3_POS', 'QUAD_VIEW_TL', 'QUAD_VIEW_TR', 'QUAD_VIEW_BL', 'QUAD_VIEW_BR', 'QUAD_VIEW_TAP_ACTION', 'QUAD_VIEW_METER_COUNT', 'QUAD_VIEW_METER_1', 'QUAD_VIEW_METER_1_POS', 'QUAD_VIEW_METER_2', 'QUAD_VIEW_METER_2_POS', 'QUAD_VIEW_METER_3', 'QUAD_VIEW_METER_3_POS', ]); function cloneDefaultConfigForPreset(id) { const snapshot = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)); snapshot.SOFTWARE_PRESET = id; return snapshot; } const SOFTWARE_PRESETS = Object.freeze({ default: Object.freeze(cloneDefaultConfigForPreset('default')), claus: Object.freeze(cloneDefaultConfigForPreset('claus')), michael: Object.freeze(cloneDefaultConfigForPreset('michael')), }); function resolvePhoenixApiUrl(pathname) { const base = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL || ''); return `${base}${pathname}`; } function resolveCurrentClientPhoenixApiUrl(pathname) { const host = String(globalThis?.location?.hostname || '').trim(); if (!host) return resolvePhoenixApiUrl(pathname); return `http://${host}:8789${pathname}`; } async function fetchJsonWithPhoenixFallback(pathname, init = {}) { const primaryUrl = resolvePhoenixApiUrl(pathname); const fallbackUrl = resolveCurrentClientPhoenixApiUrl(pathname); let lastError = null; const run = async (url) => { const res = await fetch(url, { cache: 'no-store', ...init }); if (!res.ok) throw new Error(`request failed (${res.status})`); return await res.json(); }; try { return await run(primaryUrl); } catch (err) { lastError = err; if (fallbackUrl !== primaryUrl) { try { return await run(fallbackUrl); } catch (err2) { lastError = err2; } } } throw lastError || new Error('request failed'); } function buildSoftwarePresetSnapshot(extra = null) { const snapshot = JSON.parse(JSON.stringify(CONFIG)); delete snapshot.Y_TICKS; delete snapshot.INPUT_OFFSET_GAIN_L; delete snapshot.INPUT_OFFSET_GAIN_R; if (extra && typeof extra === 'object' && !Array.isArray(extra)) { snapshot.__PHOENIX_UI_STATE = JSON.parse(JSON.stringify(extra)); } else { delete snapshot.__PHOENIX_UI_STATE; } return snapshot; } function applyConfigSnapshot(snapshot, opts = {}) { const presetId = normalizeSoftwarePresetId(opts.presetId || snapshot?.SOFTWARE_PRESET || 'default'); resetConfigToDefaults(); let uiState = null; if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { for (const k of Object.keys(snapshot)) { if (k === '__PHOENIX_UI_STATE') { const extra = snapshot[k]; if (extra && typeof extra === 'object' && !Array.isArray(extra)) { uiState = JSON.parse(JSON.stringify(extra)); } continue; } if (k in CONFIG) CONFIG[k] = snapshot[k]; } } CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL); CONFIG.SOFTWARE_PRESET = presetId; applyAlignmentProfile((CONFIG.ALIGNMENT_MODE === 'ard' || CONFIG.PPM_DIN_MODE === 'al_minus9') ? 'ard' : 'ebu', { persist: false, skipDinStorage: true }); updateInputOffsetGain({ L: CONFIG.INPUT_OFFSET_DB_L, R: CONFIG.INPUT_OFFSET_DB_R }); if (opts.persist) saveConfig(); return { presetId, uiState }; } async function fetchSoftwarePresetStore() { const payload = await fetchJsonWithPhoenixFallback(`${SOFTWARE_PRESET_ENDPOINT}?_=${Date.now()}`); const presets = payload?.presets; return (presets && typeof presets === 'object' && !Array.isArray(presets)) ? presets : {}; } function buildLayoutPresetSnapshot(extra = null) { const snapshot = {}; for (const key of LAYOUT_PRESET_KEYS) { snapshot[key] = JSON.parse(JSON.stringify(CONFIG[key])); } if (extra && typeof extra === 'object' && !Array.isArray(extra)) { snapshot.__PHOENIX_UI_STATE = JSON.parse(JSON.stringify(extra)); } return snapshot; } function applyLayoutSnapshot(snapshot, opts = {}) { const layoutId = normalizeLayoutPresetId(opts.layoutId || snapshot?.LAYOUT_PRESET || ''); let uiState = null; if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { for (const key of Object.keys(snapshot)) { if (key === '__PHOENIX_UI_STATE') { const extra = snapshot[key]; if (extra && typeof extra === 'object' && !Array.isArray(extra)) { uiState = JSON.parse(JSON.stringify(extra)); } continue; } if (LAYOUT_PRESET_KEYS.includes(key) && key in CONFIG) { CONFIG[key] = snapshot[key]; } } } if (opts.persist) saveConfig(); return { layoutId, uiState, snapshot }; } async function fetchLayoutPresetStore() { const payload = await fetchJsonWithPhoenixFallback(`${LAYOUT_PRESET_ENDPOINT}?_=${Date.now()}`); const layouts = payload?.layouts; return (layouts && typeof layouts === 'object' && !Array.isArray(layouts)) ? layouts : {}; } async function loadSoftwarePreset(presetId = 'default') { const id = normalizeSoftwarePresetId(presetId); if (id === 'default') { applySoftwarePreset('default', { persist: true }); return { presetId: 'default', source: 'builtin', snapshot: JSON.parse(JSON.stringify(SOFTWARE_PRESETS.default)), uiState: null }; } const presets = await fetchSoftwarePresetStore(); const snapshot = presets[id]; if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { const applied = applyConfigSnapshot(snapshot, { presetId: id, persist: true }); return { presetId: id, source: 'stored', snapshot: JSON.parse(JSON.stringify(snapshot)), uiState: applied?.uiState || null }; } applySoftwarePreset(id, { persist: true }); return { presetId: id, source: 'builtin', snapshot: JSON.parse(JSON.stringify(SOFTWARE_PRESETS[id] || SOFTWARE_PRESETS.default)), uiState: null }; } async function saveSoftwarePreset(presetId = 'default', snapshot = null) { const id = normalizeSoftwarePresetId(presetId); if (id === 'default') return 'default'; CONFIG.SOFTWARE_PRESET = id; saveConfig(); const configSnapshot = (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) ? JSON.parse(JSON.stringify(snapshot)) : buildSoftwarePresetSnapshot(); configSnapshot.SOFTWARE_PRESET = id; const body = JSON.stringify({ preset_id: id, config: configSnapshot }); const primaryUrl = resolvePhoenixApiUrl(SOFTWARE_PRESET_ENDPOINT); const fallbackUrl = resolveCurrentClientPhoenixApiUrl(SOFTWARE_PRESET_ENDPOINT); let res = null; let lastError = null; try { res = await fetch(primaryUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, cache: 'no-store', body, }); if (!res.ok) throw new Error(`preset save failed (${res.status})`); } catch (err) { lastError = err; if (fallbackUrl !== primaryUrl) { res = await fetch(fallbackUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, cache: 'no-store', body, }); if (!res.ok) throw new Error(`preset save failed (${res.status})`); } else { throw lastError; } } return id; } async function loadLayoutPreset(layoutId = '1') { const id = normalizeLayoutPresetId(layoutId); if (!id) return null; const layouts = await fetchLayoutPresetStore(); const snapshot = layouts[id]; if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { const applied = applyLayoutSnapshot(snapshot, { layoutId: id, persist: true }); return { layoutId: id, source: 'stored', snapshot: JSON.parse(JSON.stringify(snapshot)), uiState: applied?.uiState || null }; } return null; } async function saveLayoutPreset(layoutId = '1', extra = null) { const id = normalizeLayoutPresetId(layoutId); if (!id) throw new Error('invalid layout id'); const configSnapshot = buildLayoutPresetSnapshot(extra); const body = JSON.stringify({ preset_id: id, config: configSnapshot }); const primaryUrl = resolvePhoenixApiUrl(LAYOUT_PRESET_ENDPOINT); const fallbackUrl = resolveCurrentClientPhoenixApiUrl(LAYOUT_PRESET_ENDPOINT); let res = null; try { res = await fetch(primaryUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, cache: 'no-store', body, }); if (!res.ok) throw new Error(`layout save failed (${res.status})`); } catch (err) { if (fallbackUrl !== primaryUrl) { res = await fetch(fallbackUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, cache: 'no-store', body, }); if (!res.ok) throw new Error(`layout save failed (${res.status})`); } else { throw err; } } return id; } async function exportSoftwarePreset(presetId = 'default') { const id = normalizeSoftwarePresetId(presetId); let snapshot = null; if (id === 'default') { snapshot = JSON.parse(JSON.stringify(SOFTWARE_PRESETS.default)); } else { const presets = await fetchSoftwarePresetStore(); snapshot = (presets[id] && typeof presets[id] === 'object' && !Array.isArray(presets[id])) ? JSON.parse(JSON.stringify(presets[id])) : buildSoftwarePresetSnapshot(); } snapshot.SOFTWARE_PRESET = id; return { presetId: id, config: snapshot }; } async function importSoftwarePreset(presetId = 'default', payload = null) { const id = normalizeSoftwarePresetId(presetId); if (id === 'default') return 'default'; let snapshot = payload; if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) && snapshot.config && typeof snapshot.config === 'object' && !Array.isArray(snapshot.config)) { snapshot = snapshot.config; } if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { throw new Error('invalid preset import payload'); } const normalized = JSON.parse(JSON.stringify(snapshot)); normalized.SOFTWARE_PRESET = id; await saveSoftwarePreset(id, normalized); return loadSoftwarePreset(id); } const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([ 'FFT_SIZE', 'RTA_BPO_MODE', 'INPUT_OFFSET_DB_L', 'INPUT_OFFSET_DB_R', 'MONO_INPUT', 'LR_FRACTIONAL_DELAY_ENABLED', 'LR_FRACTIONAL_DELAY_SAMPLES', 'PPM_DIN_ATTACK_MS', 'PPM_DIN_DECAY_DB_PER_S', 'PPM_DIN_FAST_ATTACK', 'PPM_EBU_ATTACK_MS', 'PPM_EBU_DECAY_DB_PER_S', 'LUFS_I_WINDOW_MIN', 'LUFS_I_NORM_ENABLED', 'PPM_DIN_LOUDNESS_BOXES', 'PPM_DIN_LOUDNESS_OFFSET_DB', 'XY_POINTS', 'GONIO_DISPLAY_GAIN_DB', 'RECORD_OUTPUT_FORMAT', 'RECORD_MP3_BITRATE_KBPS', 'RECORD_PI_TARGET', 'RECORD_AUTO_SPLIT_GAP_SEC', 'RECORD_AUTO_THRESHOLD_DBFS', 'SCREENSAVER_ENABLED', 'SCREENSAVER_IDLE_MIN', 'SCREENSAVER_ACTIVITY_DB', 'SCREENSAVER_LED_GLOW', 'SCREENSAVER_LED_COLOR', 'CLOCK_LED_COLOR', 'HEADER_TEXT_COLOR', 'RTA_BAR_BASE_COLOR', 'SPECTRO_GAMMA', 'SPECTRO_SCROLL_MODE', 'PEAK_HISTORY_SCROLL_MODE', 'PEAK_HISTORY_FILL_ENABLED', 'PEAK_HISTORY_FILL_INVERT', 'PEAK_HISTORY_SLOT', 'PHASE_DISPLAY_GAIN_DB', 'PHASE_AGC_ENABLED', 'PHASE_TRAIL_ENABLED', 'PHASE_AMPLITUDE_MODE', 'CLASSIC_NEEDLES_SLOT', 'WAVEFORM_COLOR_LEFT', 'WAVEFORM_COLOR_RIGHT', 'WAVEFORM_COLOR_DIFF', 'VU_COLOR_NORMAL', 'VU_COLOR_WARN', 'PPM_DIN_COLOR_NORMAL', 'PPM_DIN_COLOR_WARN', 'PPM_EBU_COLOR_NORMAL', 'PPM_EBU_COLOR_WARN', 'TP_COLOR_NORMAL', 'TP_COLOR_WARN', 'RMS_COLOR_NORMAL', 'RMS_COLOR_WARN', 'LUFS_COLOR_I', 'LUFS_COLOR_M', 'LUFS_COLOR_S', 'LUFS_SCALE_LABEL_COLOR', ]); const ALIGNMENT_PROFILES = { ebu: { id: 'ebu', dinMode: 'al_minus6', digitalRefRms: -18, }, ard: { id: 'ard', dinMode: 'al_minus9', digitalRefRms: -15, }, }; const BROADCAST_STANDARDS = { 'EBU R128': { VU_DBFS_REF: -18.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: -1.0, LUFS_RED_START: -1.0, LUFS_YELLOW_START: -2.0, LUFS_GREEN_START: -5.0, VU_RED_START: 0, }, 'ATSC A/85': { VU_DBFS_REF: -20.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: -2.0, LUFS_RED_START: -2.0, LUFS_YELLOW_START: -4.0, LUFS_GREEN_START: -8.0, VU_RED_START: +2, }, 'BBC': { VU_DBFS_REF: -18.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: -1.0, LUFS_RED_START: -1.0, LUFS_YELLOW_START: -3.0, LUFS_GREEN_START: -6.0, VU_RED_START: +1, }, 'DIN': { VU_DBFS_REF: -15.0, PPM_EBU_TOP: +12, PPM_EBU_RED_START: +9, TP_RED_START: 0.0, LUFS_RED_START: 0.0, LUFS_YELLOW_START: -2.0, LUFS_GREEN_START: -4.0, VU_RED_START: +3, } }; const STORAGE_KEY = 'analyzer_config'; const VU_OFFSET_ALIGNMENT_DB = 1.92; const VU_OFFSET_ALIGNMENT_MIGRATION_KEY = 'migrate_vu_offset_alignment_plus2_v1'; const INPUT_OFFSET_DB_MIN = -60; const INPUT_OFFSET_DB_MAX = 20; const LR_DELAY_ZERO_MIGRATION_KEY = 'migrate_lr_delay_zero_to_0_05_v1'; let configLoadedOnce = false; function normalizeSoftwarePresetId(value) { const id = String(value || '').trim().toLowerCase(); return SOFTWARE_PRESET_IDS.includes(id) ? id : 'default'; } function normalizeLayoutPresetId(value) { const id = String(value || '').trim().toLowerCase(); if (LAYOUT_PRESET_IDS.includes(id)) return id; if (id === 'layout1' || id === 'slot1' || id === 'a') return '1'; if (id === 'layout2' || id === 'slot2' || id === 'b') return '2'; if (id === 'layout3' || id === 'slot3' || id === 'c') return '3'; if (id === 'layout4' || id === 'slot4' || id === 'd') return '4'; return ''; } function applySoftwarePreset(presetId = 'default', opts = {}) { const id = normalizeSoftwarePresetId(presetId); const snapshot = SOFTWARE_PRESETS[id] || SOFTWARE_PRESETS.default; Object.assign(CONFIG, JSON.parse(JSON.stringify(snapshot))); CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL); CONFIG.SOFTWARE_PRESET = id; applyAlignmentProfile((CONFIG.ALIGNMENT_MODE === 'ard' || CONFIG.PPM_DIN_MODE === 'al_minus9') ? 'ard' : 'ebu', { persist: false, skipDinStorage: true }); updateInputOffsetGain({ L: CONFIG.INPUT_OFFSET_DB_L, R: CONFIG.INPUT_OFFSET_DB_R }); if (opts.persist) saveConfig(); return id; } function updateVuReference() { const digitalRef = Number.isFinite(CONFIG.DIGITAL_REF_DBFS_RMS) ? CONFIG.DIGITAL_REF_DBFS_RMS : -18; const offset = CONFIG.VU_REF_IS_PLUS4 ? 4 : 0; CONFIG.VU_DBFS_REF = digitalRef + offset; } function updateInputOffsetGain(dbValue = { L: CONFIG.INPUT_OFFSET_DB_L, R: CONFIG.INPUT_OFFSET_DB_R }) { const clampDb = (raw, fallback) => { let val = Number(raw); if (!Number.isFinite(val)) val = Number(fallback); if (!Number.isFinite(val)) val = -5; return Math.max(INPUT_OFFSET_DB_MIN, Math.min(INPUT_OFFSET_DB_MAX, val)); }; let dbL, dbR; if (dbValue && typeof dbValue === 'object') { dbL = clampDb(dbValue.L ?? dbValue.left ?? CONFIG.INPUT_OFFSET_DB_L, CONFIG.INPUT_OFFSET_DB_L); dbR = clampDb(dbValue.R ?? dbValue.right ?? CONFIG.INPUT_OFFSET_DB_R, CONFIG.INPUT_OFFSET_DB_R); } else { const both = clampDb(dbValue, CONFIG.INPUT_OFFSET_DB_L); dbL = both; dbR = both; } CONFIG.INPUT_OFFSET_DB_L = dbL; CONFIG.INPUT_OFFSET_DB_R = dbR; CONFIG.INPUT_OFFSET_GAIN_L = Math.pow(10, dbL / 20); CONFIG.INPUT_OFFSET_GAIN_R = Math.pow(10, dbR / 20); return { L: CONFIG.INPUT_OFFSET_GAIN_L, R: CONFIG.INPUT_OFFSET_GAIN_R }; } function applyAlignmentProfile(mode = 'ebu', opts = {}) { const key = (mode === 'ard' || mode === 'al_minus9') ? 'ard' : (mode === 'ebu' || mode === 'al_minus6') ? 'ebu' : 'ebu'; const profile = ALIGNMENT_PROFILES[key] || ALIGNMENT_PROFILES.ebu; const digitalRef = profile.digitalRefRms; CONFIG.ALIGNMENT_MODE = profile.id; CONFIG.PPM_DIN_MODE = profile.dinMode; CONFIG.DIGITAL_REF_DBFS_RMS = digitalRef; CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU = digitalRef + 3; CONFIG.RMS_REF_DBFS_FOR_REF_DBU = digitalRef; CONFIG.RMS_MODE = 'dbfs'; CONFIG.RMS_REF_DBU = 0; updateVuReference(); if (!opts.skipDinStorage && typeof localStorage !== 'undefined') { try { localStorage.setItem('ppm_din_mode', CONFIG.PPM_DIN_MODE); } catch (_) {} } if (opts.persist) saveConfig(); return profile.id; } function loadConfig(opts = {}) { const force = !!opts.force; if (configLoadedOnce && !force) return; configLoadedOnce = true; let saved = null; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; saved = JSON.parse(raw); for (const k of Object.keys(saved)) if (k in CONFIG) CONFIG[k] = saved[k]; if (typeof saved.PPM_TOP === 'number' && !('PPM_EBU_TOP' in saved)) CONFIG.PPM_EBU_TOP = saved.PPM_TOP; if (typeof saved.PPM_BOTTOM === 'number' && !('PPM_EBU_BOTTOM' in saved)) CONFIG.PPM_EBU_BOTTOM = saved.PPM_BOTTOM; if (typeof saved.PPM_RED_START === 'number' && !('PPM_EBU_RED_START' in saved)) CONFIG.PPM_EBU_RED_START = saved.PPM_RED_START; if (typeof saved.PPM_DIN_TOP === 'number') CONFIG.PPM_DIN_TOP = saved.PPM_DIN_TOP; if (typeof saved.PPM_DIN_BOTTOM === 'number') CONFIG.PPM_DIN_BOTTOM = saved.PPM_DIN_BOTTOM; if (typeof saved.PPM_DIN_RED_START === 'number') CONFIG.PPM_DIN_RED_START = saved.PPM_DIN_RED_START; if (typeof saved.PPM_DIN_ATTACK_MS === 'number') CONFIG.PPM_DIN_ATTACK_MS = saved.PPM_DIN_ATTACK_MS; if (typeof saved.PPM_DIN_DECAY_DB_PER_S === 'number') CONFIG.PPM_DIN_DECAY_DB_PER_S = saved.PPM_DIN_DECAY_DB_PER_S; if (typeof saved.PPM_DIN_HOLD_MS === 'number') CONFIG.PPM_DIN_HOLD_MS = saved.PPM_DIN_HOLD_MS; if (CONFIG.RTA_BAR_LAYOUT === 'classic') CONFIG.RTA_BAR_LAYOUT = 'rtw'; if (CONFIG.RTA_BAR_LAYOUT === 'rtw' && CONFIG.RTA_BPO_MODE !== '1_3') { CONFIG.RTA_BAR_LAYOUT = 'iec'; } if (CONFIG.RTA_BAR_LAYOUT === 'rtw') { CONFIG.RTA_ENGINE = 'iir'; CONFIG.RTA_BPO_MODE = '1_3'; CONFIG.RTA_IIR_ORDER = 6; CONFIG.RTA_FREQ_RANGE = 'norm'; const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC); CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5; } if (!Number.isFinite(CONFIG.DBFS_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP; if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM; // Clamp to sensible bounds while keeping the user-selected display range. CONFIG.DBFS_TOP = Math.max(9, Math.min(24, CONFIG.DBFS_TOP)); CONFIG.DBFS_BOTTOM = Math.max(-160, Math.min(CONFIG.DBFS_TOP - 1, CONFIG.DBFS_BOTTOM)); if (!Number.isFinite(CONFIG.SPECTRO_GAMMA)) CONFIG.SPECTRO_GAMMA = CONFIG_DEFAULTS.SPECTRO_GAMMA; CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, CONFIG.SPECTRO_GAMMA)); if (!Number.isFinite(CONFIG.WAVEFORM_WINDOW_SEC)) CONFIG.WAVEFORM_WINDOW_SEC = CONFIG_DEFAULTS.WAVEFORM_WINDOW_SEC; CONFIG.WAVEFORM_WINDOW_SEC = Math.max(0.05, Math.min(15, CONFIG.WAVEFORM_WINDOW_SEC)); CONFIG.WAVEFORM_MODE = (CONFIG.WAVEFORM_MODE === 'overlay' || CONFIG.WAVEFORM_MODE === 'diff' || CONFIG.WAVEFORM_MODE === 'stacked') ? CONFIG.WAVEFORM_MODE : CONFIG_DEFAULTS.WAVEFORM_MODE; // UI/layout: keep user value, clamp to avoid NaN/layout issues. if (!Number.isFinite(CONFIG.AXIS_GUTTER_LEFT)) CONFIG.AXIS_GUTTER_LEFT = CONFIG_DEFAULTS.AXIS_GUTTER_LEFT; CONFIG.AXIS_GUTTER_LEFT = Math.max(8, Math.min(60, CONFIG.AXIS_GUTTER_LEFT)); if (!Number.isFinite(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB)) CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = 0; CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = Math.max(-7, Math.min(7, CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB)); try { const needsVuMigration = !localStorage.getItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY); const savedVuOffset = Number(saved?.VU_OFFSET_DB); const oldDefaultOffset = Number(CONFIG_DEFAULTS?.VU_OFFSET_DB); const looksLikeOldDefault = !Number.isFinite(savedVuOffset) || Math.abs(savedVuOffset - oldDefaultOffset) < 0.02 || Math.abs(savedVuOffset - (-0.08)) < 0.02 || Math.abs(savedVuOffset - (-0.05)) < 0.02; if (needsVuMigration && looksLikeOldDefault) { CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB; } if (needsVuMigration) localStorage.setItem(VU_OFFSET_ALIGNMENT_MIGRATION_KEY, '1'); } catch (_) {} if (!Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN)) CONFIG.LUFS_I_WINDOW_MIN = CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4; CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(CONFIG.LUFS_I_WINDOW_MIN))); CONFIG.LUFS_I_NORM_ENABLED = !!CONFIG.LUFS_I_NORM_ENABLED; CONFIG.STOPWATCH_DISPLAY_STYLE = (CONFIG.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono'; if (!Number.isFinite(CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS)) CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS = CONFIG_DEFAULTS.STOPWATCH_AUTO_THRESHOLD_DBFS; CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS = Math.max(-120, Math.min(20, Number(CONFIG.STOPWATCH_AUTO_THRESHOLD_DBFS))); try { const needsMigration = !localStorage.getItem(LR_DELAY_ZERO_MIGRATION_KEY); const savedDelay = Number(saved?.LR_FRACTIONAL_DELAY_SAMPLES); if (needsMigration && Number.isFinite(savedDelay) && savedDelay === 0) { CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = 0.996; localStorage.setItem(LR_DELAY_ZERO_MIGRATION_KEY, '1'); } } catch (_) {} if (!Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)) CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES; CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES))); CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL); CONFIG.PPM_DIN_FAST_ATTACK = !!CONFIG.PPM_DIN_FAST_ATTACK; // Migrate stale profiles: detector ballistics are standards profiles, // not user-adjustable display preferences. CONFIG.PPM_DIN_ATTACK_MS = 10; CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5; CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S = 20 / 1.5; CONFIG.PPM_EBU_ATTACK_MS = 10; CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8; CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S = 24 / 2.8; CONFIG.HIFI_PEAK_ALIGNMENT = (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero' || CONFIG.HIFI_PEAK_ALIGNMENT === 'dbfs_zero') ? 'ppm_din_zero' : 'ppm_din_minus5'; if (!Number.isFinite(CONFIG.RECORD_AUTO_THRESHOLD_DBFS)) CONFIG.RECORD_AUTO_THRESHOLD_DBFS = CONFIG_DEFAULTS.RECORD_AUTO_THRESHOLD_DBFS; CONFIG.RECORD_AUTO_THRESHOLD_DBFS = Math.max(-120, Math.min(20, Number(CONFIG.RECORD_AUTO_THRESHOLD_DBFS))); CONFIG.SOFTWARE_PRESET = normalizeSoftwarePresetId(CONFIG.SOFTWARE_PRESET); if (typeof CONFIG.UI_LAST_STYLE !== 'string') CONFIG.UI_LAST_STYLE = CONFIG_DEFAULTS.UI_LAST_STYLE; if (typeof CONFIG.UI_LAST_NON_OPTION_STYLE !== 'string') CONFIG.UI_LAST_NON_OPTION_STYLE = CONFIG_DEFAULTS.UI_LAST_NON_OPTION_STYLE; // Split-View: normalize ids/counts to avoid invalid config values. const normSplitPlot = (v, fallback) => { return ( v === 'phase-wheel' || v === 'realtime' || v === 'goniometer-rtw' || v === 'peak-history' || v === 'classic-needles' || v === 'panel' || v === 'clock' || v === 'none' ) ? v : fallback; }; const normSplitCount3 = (v) => { const n = Number(v); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(3, n | 0)); }; const normSplitMeter = (v, fallback) => { const id = String(v || ''); const ok = (id === 'none' || id === 'vu' || id === 'ppm-ebu' || id === 'ppm-din' || id === 'tp' || id === 'hifi-peak' || id === 'rms' || id === 'lufs' || id === 'stopwatch'); return ok ? id : fallback; }; const normSplitPos = (v, fallback) => { const id = String(v || ''); return (id === 'left' || id === 'center' || id === 'right') ? id : fallback; }; CONFIG.SPLIT_VIEW_LEFT = normSplitPlot(CONFIG.SPLIT_VIEW_LEFT, 'phase-wheel'); CONFIG.SPLIT_VIEW_RIGHT = normSplitPlot(CONFIG.SPLIT_VIEW_RIGHT, 'realtime'); CONFIG.SPLIT_VIEW_TAP_ACTION = (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; CONFIG.QUAD_VIEW_TL = normSplitPlot(CONFIG.QUAD_VIEW_TL, 'phase-wheel'); CONFIG.QUAD_VIEW_TR = normSplitPlot(CONFIG.QUAD_VIEW_TR, 'realtime'); CONFIG.QUAD_VIEW_BL = normSplitPlot(CONFIG.QUAD_VIEW_BL, 'goniometer-rtw'); CONFIG.QUAD_VIEW_BR = normSplitPlot(CONFIG.QUAD_VIEW_BR, 'none'); CONFIG.QUAD_VIEW_TAP_ACTION = (CONFIG.QUAD_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; CONFIG.QUAD_VIEW_METER_COUNT = normSplitCount3(CONFIG.QUAD_VIEW_METER_COUNT); CONFIG.QUAD_VIEW_METER_1 = normSplitMeter(CONFIG.QUAD_VIEW_METER_1, 'vu'); CONFIG.QUAD_VIEW_METER_2 = normSplitMeter(CONFIG.QUAD_VIEW_METER_2, 'ppm-din'); CONFIG.QUAD_VIEW_METER_3 = normSplitMeter(CONFIG.QUAD_VIEW_METER_3, 'lufs'); CONFIG.QUAD_VIEW_METER_1_POS = normSplitPos(CONFIG.QUAD_VIEW_METER_1_POS, 'right'); CONFIG.QUAD_VIEW_METER_2_POS = normSplitPos(CONFIG.QUAD_VIEW_METER_2_POS, 'right'); CONFIG.QUAD_VIEW_METER_3_POS = normSplitPos(CONFIG.QUAD_VIEW_METER_3_POS, 'right'); CONFIG.SPLIT_VIEW_METER_COUNT = normSplitCount3(CONFIG.SPLIT_VIEW_METER_COUNT); CONFIG.SPLIT_VIEW_METER_1 = normSplitMeter(CONFIG.SPLIT_VIEW_METER_1, 'vu'); CONFIG.SPLIT_VIEW_METER_2 = normSplitMeter(CONFIG.SPLIT_VIEW_METER_2, 'ppm-din'); CONFIG.SPLIT_VIEW_METER_3 = normSplitMeter(CONFIG.SPLIT_VIEW_METER_3, 'lufs'); CONFIG.SPLIT_VIEW_METER_1_POS = normSplitPos(CONFIG.SPLIT_VIEW_METER_1_POS, 'right'); CONFIG.SPLIT_VIEW_METER_2_POS = normSplitPos(CONFIG.SPLIT_VIEW_METER_2_POS, 'right'); CONFIG.SPLIT_VIEW_METER_3_POS = normSplitPos(CONFIG.SPLIT_VIEW_METER_3_POS, 'right'); // Migration (older Split-View config): per-side meters → global 3-slot model. const hasNew = saved && ('SPLIT_VIEW_METER_COUNT' in saved); const hasOld = saved && ('SPLIT_VIEW_LEFT_METER_COUNT' in saved || 'SPLIT_VIEW_RIGHT_METER_COUNT' in saved); if (!hasNew && hasOld) { const oldClamp2 = (v) => { const n = Number(v); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(2, n | 0)); }; const lCount = oldClamp2(saved?.SPLIT_VIEW_LEFT_METER_COUNT); const rCount = oldClamp2(saved?.SPLIT_VIEW_RIGHT_METER_COUNT); const slots = []; if (lCount >= 1) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_LEFT_METER_1, 'vu'), pos: 'left' }); if (lCount >= 2) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_LEFT_METER_2, 'ppm-din'), pos: 'left' }); if (rCount >= 1) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_RIGHT_METER_1, 'vu'), pos: 'right' }); if (rCount >= 2) slots.push({ id: normSplitMeter(saved?.SPLIT_VIEW_RIGHT_METER_2, 'ppm-din'), pos: 'right' }); CONFIG.SPLIT_VIEW_METER_COUNT = Math.max(0, Math.min(3, slots.length)); if (slots[0]) { CONFIG.SPLIT_VIEW_METER_1 = slots[0].id; CONFIG.SPLIT_VIEW_METER_1_POS = slots[0].pos; } if (slots[1]) { CONFIG.SPLIT_VIEW_METER_2 = slots[1].id; CONFIG.SPLIT_VIEW_METER_2_POS = slots[1].pos; } if (slots[2]) { CONFIG.SPLIT_VIEW_METER_3 = slots[2].id; CONFIG.SPLIT_VIEW_METER_3_POS = slots[2].pos; } } // Derived state; keep it deterministic. CONFIG.Y_TICKS = []; if (!Number.isFinite(CONFIG.GONIO_MANUAL_GAIN_DB)) { const fallback = Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0; CONFIG.GONIO_MANUAL_GAIN_DB = Math.max(-35, Math.min(35, Math.round(fallback / 5) * 5)); } CONFIG.CORR_RESPONSE_S = Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0; CONFIG.CORR_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)); const persistenceMode = String(CONFIG.GONIO_PERSISTENCE_MODE || 'fast').toLowerCase(); CONFIG.GONIO_PERSISTENCE_MODE = ['fast', 'medium', 'slow', 'custom'].includes(persistenceMode) ? persistenceMode : 'fast'; CONFIG.GONIO_LINE_FADE_MS = Math.max(0, Math.min(600, Number(CONFIG.GONIO_LINE_FADE_MS) || 0)); } catch (e) { console.warn('Config load error:', e); } const loadedMode = (CONFIG.ALIGNMENT_MODE === 'ard' || CONFIG.PPM_DIN_MODE === 'al_minus9') ? 'ard' : 'ebu'; applyAlignmentProfile(loadedMode, { persist: false, skipDinStorage: true }); // Migration: older configs only stored INPUT_OFFSET_DB. const hasPerChannelOffset = !!(saved && ('INPUT_OFFSET_DB_L' in saved || 'INPUT_OFFSET_DB_R' in saved)); if (!hasPerChannelOffset && Number.isFinite(saved?.INPUT_OFFSET_DB)) { CONFIG.INPUT_OFFSET_DB_L = saved.INPUT_OFFSET_DB; CONFIG.INPUT_OFFSET_DB_R = saved.INPUT_OFFSET_DB; } if (!(saved && ('RECORD_PI_TARGET' in saved))) { CONFIG.RECORD_PI_TARGET = (CONFIG.RECORD_TARGET === 'volumio') ? 'volumio' : 'analyzer'; } updateInputOffsetGain({ L: CONFIG.INPUT_OFFSET_DB_L, R: CONFIG.INPUT_OFFSET_DB_R, }); try { saveConfig(); } catch (_) {} } function saveConfig() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(CONFIG)); } catch (e) { console.warn('Config save error:', e); } } function buildPhoenixGlobalConfigPayload() { const rawFft = Number(CONFIG.FFT_SIZE); const fftSize = (rawFft === 2048 || rawFft === 4096 || rawFft === 8192 || rawFft === 16384) ? rawFft : (rawFft < 3072 ? 2048 : (rawFft < 6144 ? 4096 : (rawFft < 12288 ? 8192 : 16384))); const rawXyPoints = Number(CONFIG.XY_POINTS); const xyPoints = [128, 256, 512, 1024, 2048].includes(rawXyPoints) ? rawXyPoints : (rawXyPoints < 192 ? 128 : (rawXyPoints < 384 ? 256 : (rawXyPoints < 768 ? 512 : (rawXyPoints < 1536 ? 1024 : 2048)))); const normalizeColor = (value, fallback) => { const color = String(value || '').trim(); return color || fallback; }; const normalizeMeterSlot = (value, fallback) => { const raw = String(value || '').trim().toLowerCase(); if (raw === 'vu') return 'vu'; if (raw === 'ppm' || raw === 'ppm-ebu' || raw === 'ppm_ebu') return 'ppm-ebu'; if (raw === 'ppm-din' || raw === 'ppm_din') return 'ppm-din'; if (raw === 'tp') return 'tp'; if (raw === 'hifi-peak' || raw === 'hifi_peak') return 'hifi-peak'; if (raw === 'rms') return 'rms'; if (raw === 'lufs') return 'lufs'; if (raw === 'stopwatch') return 'stopwatch'; return fallback; }; const normalizeSpectroScroll = (value) => { const number = Number(value); return [0.5, 1, 2, 4, 6].includes(number) ? number : 1; }; const normalizePeakHistoryScroll = (value) => { const allowed = new Set([0.5, 1, 2, 4, 6]); const num = Number(value); return allowed.has(num) ? num : 1; }; const normalizePhaseAmplitudeMode = (value) => { return String(value || '').trim().toLowerCase() === 'ppm-din' ? 'ppm-din' : 'bandpass'; }; const normalizeRtaBpoMode = (value) => { const mode = String(value || '').trim(); return (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6'; }; const phaseAmplitudeMode = normalizePhaseAmplitudeMode(CONFIG.PHASE_AMPLITUDE_MODE); return { fftSize, rtaBpoMode: normalizeRtaBpoMode(CONFIG.RTA_BPO_MODE), inputOffsetDbL: Number.isFinite(CONFIG.INPUT_OFFSET_DB_L) ? CONFIG.INPUT_OFFSET_DB_L : -5, inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5, monoInput: !!CONFIG.MONO_INPUT, lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED, lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996), ppmDinAttackMs: 10, ppmDinDecayDbPerS: 20 / 1.5, ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK, ppmEbuAttackMs: 10, ppmEbuDecayDbPerS: 24 / 2.8, lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : (CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4), lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED, ppmDinLoudnessBoxes: !!CONFIG.PPM_DIN_LOUDNESS_BOXES, ppmDinLoudnessOffsetDb: Number.isFinite(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB) ? CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB : (CONFIG_DEFAULTS.PPM_DIN_LOUDNESS_OFFSET_DB ?? 0), xyPoints, gonioDisplayGainDb: Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : (CONFIG_DEFAULTS.GONIO_DISPLAY_GAIN_DB ?? -5), recordOutputFormat: (CONFIG.RECORD_OUTPUT_FORMAT === 'webm') ? 'webm' : ((CONFIG.RECORD_OUTPUT_FORMAT === 'mp3') ? 'mp3' : 'wav'), recordMp3BitrateKbps: [64, 128, 192, 256, 320].includes(Number(CONFIG.RECORD_MP3_BITRATE_KBPS)) ? Number(CONFIG.RECORD_MP3_BITRATE_KBPS) : 192, recordTarget: (CONFIG.RECORD_TARGET === 'analyzer' || CONFIG.RECORD_TARGET === 'volumio') ? CONFIG.RECORD_TARGET : ((CONFIG.RECORD_PI_TARGET === 'volumio') ? 'volumio' : 'analyzer'), recordAutoSplitGapSec: Number.isFinite(CONFIG.RECORD_AUTO_SPLIT_GAP_SEC) ? CONFIG.RECORD_AUTO_SPLIT_GAP_SEC : (CONFIG_DEFAULTS.RECORD_AUTO_SPLIT_GAP_SEC ?? 1.5), recordAutoThresholdDbfs: Number.isFinite(CONFIG.RECORD_AUTO_THRESHOLD_DBFS) ? CONFIG.RECORD_AUTO_THRESHOLD_DBFS : (CONFIG_DEFAULTS.RECORD_AUTO_THRESHOLD_DBFS ?? -50), screensaverEnabled: CONFIG.SCREENSAVER_ENABLED !== false, screensaverIdleMin: Number.isFinite(CONFIG.SCREENSAVER_IDLE_MIN) ? CONFIG.SCREENSAVER_IDLE_MIN : (CONFIG_DEFAULTS.SCREENSAVER_IDLE_MIN ?? 5), screensaverActivityDb: Number.isFinite(CONFIG.SCREENSAVER_ACTIVITY_DB) ? CONFIG.SCREENSAVER_ACTIVITY_DB : (CONFIG_DEFAULTS.SCREENSAVER_ACTIVITY_DB ?? -50), screensaverLedGlow: CONFIG.SCREENSAVER_LED_GLOW !== false, screensaverLedColor: normalizeColor(CONFIG.SCREENSAVER_LED_COLOR, CONFIG_DEFAULTS.SCREENSAVER_LED_COLOR || '#ff0000'), clockLedColor: normalizeColor(CONFIG.CLOCK_LED_COLOR, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'), headerTextColor: normalizeColor(CONFIG.HEADER_TEXT_COLOR, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'), rtaBarBaseColor: normalizeColor(CONFIG.RTA_BAR_BASE_COLOR, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'), spectroGamma: Number.isFinite(CONFIG.SPECTRO_GAMMA) ? Math.max(0.3, Math.min(1.2, CONFIG.SPECTRO_GAMMA)) : 0.9, spectroScrollMode: normalizeSpectroScroll(CONFIG.SPECTRO_SCROLL_MODE), peakHistoryScrollMode: normalizePeakHistoryScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE), peakHistoryFillEnabled: !!CONFIG.PEAK_HISTORY_FILL_ENABLED, peakHistoryFillInvert: !!CONFIG.PEAK_HISTORY_FILL_INVERT, peakHistorySlot: normalizeMeterSlot(CONFIG.PEAK_HISTORY_SLOT, CONFIG_DEFAULTS.PEAK_HISTORY_SLOT || 'ppm-din'), phaseDisplayGainDb: Number.isFinite(CONFIG.PHASE_DISPLAY_GAIN_DB) ? CONFIG.PHASE_DISPLAY_GAIN_DB : (CONFIG_DEFAULTS.PHASE_DISPLAY_GAIN_DB ?? 0), phaseAgcEnabled: phaseAmplitudeMode === 'ppm-din' ? false : !!CONFIG.PHASE_AGC_ENABLED, phaseTrailEnabled: !!CONFIG.PHASE_TRAIL_ENABLED, phaseAmplitudeMode, classicNeedlesSlot: normalizeMeterSlot(CONFIG.CLASSIC_NEEDLES_SLOT, CONFIG_DEFAULTS.CLASSIC_NEEDLES_SLOT || 'vu'), waveformColorLeft: normalizeColor(CONFIG.WAVEFORM_COLOR_LEFT, CONFIG_DEFAULTS.WAVEFORM_COLOR_LEFT || '#00e7ff'), waveformColorRight: normalizeColor(CONFIG.WAVEFORM_COLOR_RIGHT, CONFIG_DEFAULTS.WAVEFORM_COLOR_RIGHT || '#ff6b81'), waveformColorDiff: normalizeColor(CONFIG.WAVEFORM_COLOR_DIFF, CONFIG_DEFAULTS.WAVEFORM_COLOR_DIFF || '#00e7ff'), vuColorNormal: normalizeColor(CONFIG.VU_COLOR_NORMAL, CONFIG_DEFAULTS.VU_COLOR_NORMAL || '#ffe066'), vuColorWarn: normalizeColor(CONFIG.VU_COLOR_WARN, CONFIG_DEFAULTS.VU_COLOR_WARN || '#ff3b3b'), ppmDinColorNormal: normalizeColor(CONFIG.PPM_DIN_COLOR_NORMAL, CONFIG_DEFAULTS.PPM_DIN_COLOR_NORMAL || '#ffe066'), ppmDinColorWarn: normalizeColor(CONFIG.PPM_DIN_COLOR_WARN, CONFIG_DEFAULTS.PPM_DIN_COLOR_WARN || '#ff3b3b'), ppmEbuColorNormal: normalizeColor(CONFIG.PPM_EBU_COLOR_NORMAL, CONFIG_DEFAULTS.PPM_EBU_COLOR_NORMAL || '#ffe066'), ppmEbuColorWarn: normalizeColor(CONFIG.PPM_EBU_COLOR_WARN, CONFIG_DEFAULTS.PPM_EBU_COLOR_WARN || '#ff3b3b'), tpColorNormal: normalizeColor(CONFIG.TP_COLOR_NORMAL, CONFIG_DEFAULTS.TP_COLOR_NORMAL || '#ffe066'), tpColorWarn: normalizeColor(CONFIG.TP_COLOR_WARN, CONFIG_DEFAULTS.TP_COLOR_WARN || '#ff3b3b'), rmsColorNormal: normalizeColor(CONFIG.RMS_COLOR_NORMAL, CONFIG_DEFAULTS.RMS_COLOR_NORMAL || '#34d399'), rmsColorWarn: normalizeColor(CONFIG.RMS_COLOR_WARN, CONFIG_DEFAULTS.RMS_COLOR_WARN || '#ff3b3b'), lufsColorI: normalizeColor(CONFIG.LUFS_COLOR_I, CONFIG_DEFAULTS.LUFS_COLOR_I || '#34d399'), lufsColorM: normalizeColor(CONFIG.LUFS_COLOR_M, CONFIG_DEFAULTS.LUFS_COLOR_M || '#ffe066'), lufsColorS: normalizeColor(CONFIG.LUFS_COLOR_S, CONFIG_DEFAULTS.LUFS_COLOR_S || '#ff3b3b'), lufsScaleLabelColor: normalizeColor(CONFIG.LUFS_SCALE_LABEL_COLOR, CONFIG_DEFAULTS.LUFS_SCALE_LABEL_COLOR || '#8fd3d4'), }; } function applyPhoenixGlobalConfig(payload = {}) { if (!payload || typeof payload !== 'object') return null; const normalizeColor = (value, fallback) => { const color = String(value || '').trim(); return color || fallback; }; const normalizeMeterSlot = (value, fallback) => { const raw = String(value || '').trim().toLowerCase(); if (raw === 'vu') return 'vu'; if (raw === 'ppm' || raw === 'ppm-ebu' || raw === 'ppm_ebu') return 'ppm-ebu'; if (raw === 'ppm-din' || raw === 'ppm_din') return 'ppm-din'; if (raw === 'tp') return 'tp'; if (raw === 'hifi-peak' || raw === 'hifi_peak') return 'hifi-peak'; if (raw === 'rms') return 'rms'; if (raw === 'lufs') return 'lufs'; if (raw === 'stopwatch') return 'stopwatch'; return fallback; }; const normalizeSpectroScroll = (value) => { const number = Number(value); return [0.5, 1, 2, 4, 6].includes(number) ? number : 1; }; const fft = Number(payload.fftSize); if (Number.isFinite(fft)) { CONFIG.FFT_SIZE = (fft === 2048 || fft === 4096 || fft === 8192 || fft === 16384) ? fft : (fft < 3072 ? 2048 : (fft < 6144 ? 4096 : (fft < 12288 ? 8192 : 16384))); } if ('rtaBpoMode' in payload) { const mode = String(payload.rtaBpoMode || '').trim(); applyRtaBpoSelection(mode); } if (CONFIG.RTA_BAR_LAYOUT === 'rtw') { CONFIG.RTA_ENGINE = 'iir'; CONFIG.RTA_BPO_MODE = '1_3'; CONFIG.RTA_IIR_ORDER = 6; CONFIG.RTA_FREQ_RANGE = 'norm'; } const spectroGamma = Number(payload.spectroGamma); if (Number.isFinite(spectroGamma)) CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, spectroGamma)); if ('spectroScrollMode' in payload) CONFIG.SPECTRO_SCROLL_MODE = normalizeSpectroScroll(payload.spectroScrollMode); CONFIG.PPM_DIN_ATTACK_MS = 10; CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5; if ('ppmDinFastAttack' in payload) CONFIG.PPM_DIN_FAST_ATTACK = !!payload.ppmDinFastAttack; CONFIG.PPM_EBU_ATTACK_MS = 10; CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8; const lufsWin = Number(payload.lufsIWindowMin); if (Number.isFinite(lufsWin)) CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(lufsWin))); if ('lufsINormEnabled' in payload) CONFIG.LUFS_I_NORM_ENABLED = !!payload.lufsINormEnabled; if ('ppmDinLoudnessBoxes' in payload) CONFIG.PPM_DIN_LOUDNESS_BOXES = !!payload.ppmDinLoudnessBoxes; const ppmDinLoudnessOffsetDb = Number(payload.ppmDinLoudnessOffsetDb); if (Number.isFinite(ppmDinLoudnessOffsetDb)) CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB = Math.max(-7, Math.min(7, ppmDinLoudnessOffsetDb)); const xyPoints = Number(payload.xyPoints); if (Number.isFinite(xyPoints)) { CONFIG.XY_POINTS = [128, 256, 512, 1024, 2048].includes(xyPoints) ? xyPoints : (xyPoints < 192 ? 128 : (xyPoints < 384 ? 256 : (xyPoints < 768 ? 512 : (xyPoints < 1536 ? 1024 : 2048)))); } const gonioDisplayGainDb = Number(payload.gonioDisplayGainDb); if (Number.isFinite(gonioDisplayGainDb)) { CONFIG.GONIO_DISPLAY_GAIN_DB = Math.max(-35, Math.min(35, Math.round(gonioDisplayGainDb / 5) * 5)); } if ('recordOutputFormat' in payload) { const fmt = String(payload.recordOutputFormat || '').toLowerCase(); CONFIG.RECORD_OUTPUT_FORMAT = (fmt === 'webm') ? 'webm' : ((fmt === 'mp3') ? 'mp3' : 'wav'); } const mp3Rate = Number(payload.recordMp3BitrateKbps); if ([64, 128, 192, 256, 320].includes(mp3Rate)) CONFIG.RECORD_MP3_BITRATE_KBPS = mp3Rate; if ('recordTarget' in payload) { const target = String(payload.recordTarget || '').toLowerCase(); CONFIG.RECORD_PI_TARGET = (target === 'volumio') ? 'volumio' : 'analyzer'; if (CONFIG.RECORD_TARGET !== 'download') { CONFIG.RECORD_TARGET = CONFIG.RECORD_PI_TARGET; } } const autoGap = Number(payload.recordAutoSplitGapSec); if (Number.isFinite(autoGap)) CONFIG.RECORD_AUTO_SPLIT_GAP_SEC = Math.max(0.5, Math.min(3, Math.round(autoGap * 2) / 2)); const autoThr = Number(payload.recordAutoThresholdDbfs); if (Number.isFinite(autoThr)) CONFIG.RECORD_AUTO_THRESHOLD_DBFS = Math.max(-120, Math.min(20, autoThr)); if ('screensaverEnabled' in payload) CONFIG.SCREENSAVER_ENABLED = !!payload.screensaverEnabled; const idle = Number(payload.screensaverIdleMin); if (Number.isFinite(idle)) CONFIG.SCREENSAVER_IDLE_MIN = Math.max(0, Math.min(120, idle)); const activity = Number(payload.screensaverActivityDb); if (Number.isFinite(activity)) CONFIG.SCREENSAVER_ACTIVITY_DB = Math.max(-120, Math.min(0, activity)); if ('screensaverLedGlow' in payload) CONFIG.SCREENSAVER_LED_GLOW = !!payload.screensaverLedGlow; if ('screensaverLedColor' in payload) { CONFIG.SCREENSAVER_LED_COLOR = normalizeColor(payload.screensaverLedColor, CONFIG_DEFAULTS.SCREENSAVER_LED_COLOR || '#ff0000'); } if ('clockLedColor' in payload) { CONFIG.CLOCK_LED_COLOR = normalizeColor(payload.clockLedColor, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'); } if ('headerTextColor' in payload) { CONFIG.HEADER_TEXT_COLOR = normalizeColor(payload.headerTextColor, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'); } if ('rtaBarBaseColor' in payload) { CONFIG.RTA_BAR_BASE_COLOR = normalizeColor(payload.rtaBarBaseColor, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'); } const peakHistoryScrollMode = Number(payload.peakHistoryScrollMode); if ([0.5, 1, 2, 4, 6].includes(peakHistoryScrollMode)) { CONFIG.PEAK_HISTORY_SCROLL_MODE = peakHistoryScrollMode; } if ('peakHistoryFillEnabled' in payload) CONFIG.PEAK_HISTORY_FILL_ENABLED = !!payload.peakHistoryFillEnabled; if ('peakHistoryFillInvert' in payload) CONFIG.PEAK_HISTORY_FILL_INVERT = !!payload.peakHistoryFillInvert; if ('peakHistorySlot' in payload) { CONFIG.PEAK_HISTORY_SLOT = normalizeMeterSlot(payload.peakHistorySlot, CONFIG_DEFAULTS.PEAK_HISTORY_SLOT || 'ppm-din'); } const phaseDisplayGainDb = Number(payload.phaseDisplayGainDb); if (Number.isFinite(phaseDisplayGainDb)) { const clamped = Math.max(-35, Math.min(35, phaseDisplayGainDb)); CONFIG.PHASE_DISPLAY_GAIN_DB = Math.round(clamped / 5) * 5; } if ('phaseAmplitudeMode' in payload) { CONFIG.PHASE_AMPLITUDE_MODE = String(payload.phaseAmplitudeMode || '').trim().toLowerCase() === 'ppm-din' ? 'ppm-din' : 'bandpass'; } if ('phaseAgcEnabled' in payload) { CONFIG.PHASE_AGC_ENABLED = (CONFIG.PHASE_AMPLITUDE_MODE === 'ppm-din') ? false : !!payload.phaseAgcEnabled; } if (CONFIG.PHASE_AMPLITUDE_MODE === 'ppm-din') { CONFIG.PHASE_AGC_ENABLED = false; } else if (CONFIG.PHASE_AGC_ENABLED) { CONFIG.PHASE_DISPLAY_GAIN_DB = 0; } if ('phaseTrailEnabled' in payload) CONFIG.PHASE_TRAIL_ENABLED = !!payload.phaseTrailEnabled; if ('classicNeedlesSlot' in payload) { CONFIG.CLASSIC_NEEDLES_SLOT = normalizeMeterSlot(payload.classicNeedlesSlot, CONFIG_DEFAULTS.CLASSIC_NEEDLES_SLOT || 'vu'); } if ('waveformColorLeft' in payload) { CONFIG.WAVEFORM_COLOR_LEFT = normalizeColor(payload.waveformColorLeft, CONFIG_DEFAULTS.WAVEFORM_COLOR_LEFT || '#00e7ff'); } if ('waveformColorRight' in payload) { CONFIG.WAVEFORM_COLOR_RIGHT = normalizeColor(payload.waveformColorRight, CONFIG_DEFAULTS.WAVEFORM_COLOR_RIGHT || '#ff6b81'); } if ('waveformColorDiff' in payload) { CONFIG.WAVEFORM_COLOR_DIFF = normalizeColor(payload.waveformColorDiff, CONFIG_DEFAULTS.WAVEFORM_COLOR_DIFF || '#00e7ff'); } if ('vuColorNormal' in payload) CONFIG.VU_COLOR_NORMAL = normalizeColor(payload.vuColorNormal, CONFIG_DEFAULTS.VU_COLOR_NORMAL || '#ffe066'); if ('vuColorWarn' in payload) CONFIG.VU_COLOR_WARN = normalizeColor(payload.vuColorWarn, CONFIG_DEFAULTS.VU_COLOR_WARN || '#ff3b3b'); if ('ppmDinColorNormal' in payload) CONFIG.PPM_DIN_COLOR_NORMAL = normalizeColor(payload.ppmDinColorNormal, CONFIG_DEFAULTS.PPM_DIN_COLOR_NORMAL || '#ffe066'); if ('ppmDinColorWarn' in payload) CONFIG.PPM_DIN_COLOR_WARN = normalizeColor(payload.ppmDinColorWarn, CONFIG_DEFAULTS.PPM_DIN_COLOR_WARN || '#ff3b3b'); if ('ppmEbuColorNormal' in payload) CONFIG.PPM_EBU_COLOR_NORMAL = normalizeColor(payload.ppmEbuColorNormal, CONFIG_DEFAULTS.PPM_EBU_COLOR_NORMAL || '#ffe066'); if ('ppmEbuColorWarn' in payload) CONFIG.PPM_EBU_COLOR_WARN = normalizeColor(payload.ppmEbuColorWarn, CONFIG_DEFAULTS.PPM_EBU_COLOR_WARN || '#ff3b3b'); if ('tpColorNormal' in payload) CONFIG.TP_COLOR_NORMAL = normalizeColor(payload.tpColorNormal, CONFIG_DEFAULTS.TP_COLOR_NORMAL || '#ffe066'); if ('tpColorWarn' in payload) CONFIG.TP_COLOR_WARN = normalizeColor(payload.tpColorWarn, CONFIG_DEFAULTS.TP_COLOR_WARN || '#ff3b3b'); if ('rmsColorNormal' in payload) CONFIG.RMS_COLOR_NORMAL = normalizeColor(payload.rmsColorNormal, CONFIG_DEFAULTS.RMS_COLOR_NORMAL || '#34d399'); if ('rmsColorWarn' in payload) CONFIG.RMS_COLOR_WARN = normalizeColor(payload.rmsColorWarn, CONFIG_DEFAULTS.RMS_COLOR_WARN || '#ff3b3b'); if ('lufsColorI' in payload) CONFIG.LUFS_COLOR_I = normalizeColor(payload.lufsColorI, CONFIG_DEFAULTS.LUFS_COLOR_I || '#34d399'); if ('lufsColorM' in payload) CONFIG.LUFS_COLOR_M = normalizeColor(payload.lufsColorM, CONFIG_DEFAULTS.LUFS_COLOR_M || '#ffe066'); if ('lufsColorS' in payload) CONFIG.LUFS_COLOR_S = normalizeColor(payload.lufsColorS, CONFIG_DEFAULTS.LUFS_COLOR_S || '#ff3b3b'); if ('lufsScaleLabelColor' in payload) { CONFIG.LUFS_SCALE_LABEL_COLOR = normalizeColor(payload.lufsScaleLabelColor, CONFIG_DEFAULTS.LUFS_SCALE_LABEL_COLOR || '#8fd3d4'); } if ('monoInput' in payload) CONFIG.MONO_INPUT = !!payload.monoInput; if ('lrFractionalDelayEnabled' in payload) CONFIG.LR_FRACTIONAL_DELAY_ENABLED = !!payload.lrFractionalDelayEnabled; const frac = Number(payload.lrFractionalDelaySamples); if (Number.isFinite(frac)) CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, frac)); updateInputOffsetGain({ L: Number.isFinite(Number(payload.inputOffsetDbL)) ? Number(payload.inputOffsetDbL) : CONFIG.INPUT_OFFSET_DB_L, R: Number.isFinite(Number(payload.inputOffsetDbR)) ? Number(payload.inputOffsetDbR) : CONFIG.INPUT_OFFSET_DB_R, }); return buildPhoenixGlobalConfigPayload(); } // Preset anwenden (wie Button „Apply"") function applyBroadcastStandard(name) { const p = BROADCAST_STANDARDS[name]; if (!p) return; CONFIG.VU_DBFS_REF = p.VU_DBFS_REF; if (typeof p.PPM_EBU_TOP === 'number') CONFIG.PPM_EBU_TOP = p.PPM_EBU_TOP; if (typeof p.PPM_EBU_RED_START === 'number') CONFIG.PPM_EBU_RED_START = p.PPM_EBU_RED_START; if (typeof p.PPM_DIN_TOP === 'number') CONFIG.PPM_DIN_TOP = p.PPM_DIN_TOP; if (typeof p.PPM_DIN_RED_START === 'number') CONFIG.PPM_DIN_RED_START = p.PPM_DIN_RED_START; CONFIG.TP_RED_START = p.TP_RED_START; CONFIG.LUFS_RED_START = p.LUFS_RED_START; CONFIG.LUFS_YELLOW_START = p.LUFS_YELLOW_START; CONFIG.LUFS_GREEN_START = p.LUFS_GREEN_START; CONFIG.VU_RED_START = p.VU_RED_START; // Offsets zurücksetzen (wie bisher) CONFIG.VU_OFFSET_DB = VU_OFFSET_ALIGNMENT_DB; CONFIG.TP_OFFSET_DB = 0; CONFIG.RMS_OFFSET_DB = 0; saveConfig(); } function resetConfigToDefaults() { applySoftwarePreset('default', { persist: false }); } // Export everything at the bottom export { CONFIG, SOFTWARE_PRESET_IDS, LAYOUT_PRESET_IDS, PHOENIX_GLOBAL_OPTION_KEYS, BROADCAST_STANDARDS, loadConfig, saveConfig, buildPhoenixGlobalConfigPayload, applyPhoenixGlobalConfig, applyRtaBpoSelection, applyBroadcastStandard, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, applyAlignmentProfile, updateVuReference, updateInputOffsetGain, resetConfigToDefaults };