Files
Phoenix/www/core/config.js
T

1304 lines
59 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_IIR_FILTERBANK: 'legacy', // 'legacy' | 'butterworth'
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
// AlignmentLevel (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 dBuWert 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 PeakHold
// auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß
// Rundfunkpraxis werden PeakHoldStriche häufig 12 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;
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';
CONFIG.RTA_IIR_FILTERBANK = CONFIG.RTA_IIR_FILTERBANK === 'butterworth'
? 'butterworth'
: 'legacy';
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
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_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
};