// main.js — App-Bootstrap: Env bauen, Views/Audio/Options verdrahten, Loop fahren // --- One-time migration: enforce new dBFS range [+9 .. -63] try { const k2 = 'MIGRATE_DBFS_BOUNDS_9__63'; // --- Sanity: guard AXIS_GUTTER_LEFT to avoid layout/NaN issues try { const k = 'MIGRATE_SANITY_GUTTER'; if (!localStorage.getItem(k)) { const g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT); if (!Number.isFinite(g) || g < 8) CONFIG.AXIS_GUTTER_LEFT = 14; localStorage.setItem(k, '1'); } } catch(_) {} if (!localStorage.getItem(k2)) { if (typeof CONFIG?.DBFS_TOP === 'number' && CONFIG.DBFS_TOP !== 9) CONFIG.DBFS_TOP = 9; if (typeof CONFIG?.DBFS_BOTTOM === 'number' && CONFIG.DBFS_BOTTOM !== -63) CONFIG.DBFS_BOTTOM = -63; localStorage.setItem(k2, '1'); } } catch(_) {} import { CONFIG, applyRtaBpoSelection, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js'; import * as utils from './core/utils.js'; import { meterFacade, registerMeter } from './core/registry.js'; import { initAudio, audioLost, reloadAudio } from './core/audio.js'; import { createScreensaver } from './core/screensaver.js'; // Views import * as viewGoni from './views/goniometer_rtw.js'; import * as viewPhaseWheel from './views/phase_wheel.js'; import * as viewPanel from './views/panel.js'; import * as viewRealtime from './views/realtime.js'; import * as viewClassicNeedles from './views/classic_needles.js'; import * as viewPeakHistory from './views/peak_history.js'; import * as viewSpectrogram from './views/spectrogram.js'; import * as viewWaveform from './views/waveform.js'; import * as viewRecorder from './views/recorder.js'; import * as viewSplit from './views/split_view.js'; import * as viewQuad from './views/quad_view.js'; // Meters import * as vu from './meters/vu.js'; import * as ppmEbu from './meters/ppm_ebu.js'; import * as ppmDin from './meters/ppm_din.js'; import * as tp from './meters/tp.js'; import * as hifiPeak from './meters/hifi_peak.js'; import * as rms from './meters/rms.js'; import * as lufs from './meters/lufs.js'; import * as stopwatch from './meters/stopwatch.js'; // Options UI import { setupOptions } from './ui/options.js'; // --- Canvas & Context --------------------------------------------------------- const C = document.getElementById('cv'); const g = C.getContext('2d'); function resizeCanvas() { const dpr = Math.max(1, window.devicePixelRatio || 1); const width = innerWidth * dpr; const height = innerHeight * dpr; C.width = width; C.height = height; C.style.width = `${innerWidth}px`; C.style.height = `${innerHeight}px`; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.textBaseline = 'alphabetic'; g.font = 'bold 14px ui-monospace, monospace'; g.lineWidth = 1.6; } addEventListener('resize', resizeCanvas); resizeCanvas(); // --- HUD --------------------------------------------------------------------- const styleSel = document.getElementById('styleSel'); const slotWrap = document.getElementById('slotWrap'); const slotLabel = document.getElementById('slotLabel'); const slotSel1 = document.getElementById('slotSel1'); const slotSel2 = document.getElementById('slotSel2'); const slotSel3 = document.getElementById('slotSel3'); const slotSel4 = document.getElementById('slotSel4'); const slotSel5 = document.getElementById('slotSel5'); const slotSelects = [slotSel1, slotSel2, slotSel3, slotSel4, slotSel5]; const peakHistScrollWrap = document.getElementById('peakHistScrollWrap'); const peakHistScrollSel = document.getElementById('peakHistScrollSel'); const goniAgcToggle = document.getElementById('goniAgcToggle'); const goniAgcChk = document.getElementById('goniAgcChk'); const spectroLegendWrap = document.getElementById('spectroLegendWrap'); const waveWindowWrap = document.getElementById('waveWindowWrap'); const waveWindowSel = document.getElementById('waveWindowSel'); const waveModeWrap = document.getElementById('waveModeWrap'); const waveModeSel = document.getElementById('waveModeSel'); const rtaBpoWrap = document.getElementById('rtaBpoWrap'); const rtaBpoSel = document.getElementById('rtaBpoSel'); const splitViewWrap = document.getElementById('splitViewWrap'); const splitLeftSel = document.getElementById('splitLeftSel'); const splitRightSel = document.getElementById('splitRightSel'); const splitMeterBtnWrap = document.getElementById('splitMeterBtnWrap'); const splitMeterBtn = document.getElementById('splitMeterBtn'); const splitMeterPopup = document.getElementById('splitMeterPopup'); const splitMeterPopupTitle = document.getElementById('splitMeterPopupTitle'); const splitMeterPopupClose = document.getElementById('splitMeterPopupClose'); const splitMeterRow1 = document.getElementById('splitMeterRow1'); const splitMeterRow2 = document.getElementById('splitMeterRow2'); const splitMeterRow3 = document.getElementById('splitMeterRow3'); const splitMeterSel1 = document.getElementById('splitMeterSel1'); const splitMeterSel2 = document.getElementById('splitMeterSel2'); const splitMeterSel3 = document.getElementById('splitMeterSel3'); const quadViewWrap = document.getElementById('quadViewWrap'); const quadPlotBtn = document.getElementById('quadPlotBtn'); const quadMeterBtnWrap = document.getElementById('quadMeterBtnWrap'); const quadMeterBtn = document.getElementById('quadMeterBtn'); const quadPlotPopup = document.getElementById('quadPlotPopup'); const quadPlotPopupClose = document.getElementById('quadPlotPopupClose'); const quadPlotSelTL = document.getElementById('quadPlotSelTL'); const quadPlotSelTR = document.getElementById('quadPlotSelTR'); const quadPlotSelBL = document.getElementById('quadPlotSelBL'); const quadPlotSelBR = document.getElementById('quadPlotSelBR'); const gainWrap = document.getElementById('gainWrap'); const gainSel = document.getElementById('gainSel'); const resetPeakBtn= document.getElementById('resetPeakBtn'); const phaseTrailToggle = document.getElementById('phaseTrailToggle'); const phaseTrailChk = document.getElementById('phaseTrailChk'); const recorderHud = document.getElementById('recorderHud'); const recTargetSel = document.getElementById('recTargetSel'); const recStartBtn = document.getElementById('recStartBtn'); const recStopBtn = document.getElementById('recStopBtn'); const recAutoBtn = document.getElementById('recAutoBtn'); const recLabel = document.getElementById('recLabel'); const recList = document.getElementById('recList'); const recTimer = document.getElementById('recTimer'); const recProcessing = document.getElementById('recProcessing'); const recWarning = document.getElementById('recWarning'); const recWarningAck = document.getElementById('recWarningAck'); const recWarningBtn = document.getElementById('recWarningBtn'); const calibrationNotice = document.getElementById('calibrationNotice'); const calibrationNoticeBtn = document.getElementById('calibrationNoticeBtn'); const CALIBRATION_NOTICE_KEY = 'calibration_notice_seen_v1'; const REC_WARNING_KEY = 'recorder_warning_ack_v1'; let calibrationNoticeSeen = false; try { calibrationNoticeSeen = localStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {} if (!calibrationNoticeSeen) { try { calibrationNoticeSeen = sessionStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {} } const recorderState = { status: 'idle', target: null, error: '', startTs: 0, lastDurationMs: 0, history: [], timerRaf: null, activeSession: null, processingJobs: 0, nextSessionId: 1, nextRecorderSlot: 'A', activeRecorderSlot: null, processingRecorderSlots: [], autoEnabled: false, autoMonitorRaf: null, autoSilenceSince: 0, autoPendingStart: false, autoPendingStop: false, }; const RECORDER_HISTORY_LIMIT = 100; function getRecorderAutoSplitGapMs() { const raw = Number(CONFIG.RECORD_AUTO_SPLIT_GAP_SEC); const sec = Math.max(0.5, Math.min(3, Number.isFinite(raw) ? raw : 1.5)); return sec * 1000; } function getRecorderAutoThresholdDbfs() { const raw = Number(CONFIG.RECORD_AUTO_THRESHOLD_DBFS); return Math.max(-120, Math.min(20, Number.isFinite(raw) ? raw : -50)); } const optionsPanelNew = document.getElementById('optionsPanelNew'); const errBox = document.getElementById('err'); const REALTIME_GAIN_CHOICES = [-10, -5, 0, 3, 15, 20, 25]; const WAVE_WINDOW_CHOICES = [0.05, 0.1, 0.5, 1, 2, 5, 10, 15]; const WAVE_MODE_CHOICES = [ ['stacked', 'Gestapelt'], ['overlay', 'Überlagert'], ['diff', 'Differenz (L−R)'], ]; const RTA_BPO_CHOICES = [ ['1_3', '1/3 Oktave'], ['1_6', '1/6 Oktave'], ['1_12', '1/12 Oktave'], ]; const PEAK_HISTORY_SCROLL_CHOICES = [0.5, 1, 2, 4, 6]; const RECORDER_TARGETS = [ { id: 'download', label: 'Download auf diesem Rechner' }, { id: 'analyzer', label: 'Pi: /home/analyzer/Aufnahmen' }, { id: 'volumio', label: 'Pi: /home/volumio/Aufnahmen' }, ]; if (!recorderState.target) { recorderState.target = CONFIG.RECORD_TARGET || RECORDER_TARGETS[0]?.id || 'download'; } const GONIO_GAIN_MIN_DB = -35; const GONIO_GAIN_MAX_DB = 35; if (recTargetSel) { recTargetSel.innerHTML = ''; RECORDER_TARGETS.forEach((t) => { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = t.label; recTargetSel.appendChild(opt); }); recorderState.target = CONFIG.RECORD_TARGET || RECORDER_TARGETS[0]?.id || 'download'; recTargetSel.disabled = false; } function renderRecordingList() { if (!recList) return; recList.innerHTML = ''; const title = document.createElement('h4'); title.textContent = 'Aufnahmen'; recList.appendChild(title); if (!recorderState.history || !recorderState.history.length) { const empty = document.createElement('div'); empty.textContent = 'Keine Aufnahmen'; recList.appendChild(empty); return; } recorderState.history.forEach((entry) => { const row = document.createElement('div'); row.className = 'rec-item'; const name = document.createElement('div'); name.className = 'rec-name'; name.textContent = entry.name; const actions = document.createElement('div'); actions.style.display = 'flex'; actions.style.gap = '6px'; const btnSave = document.createElement('button'); btnSave.className = 'btn btn-compact'; btnSave.textContent = entry.saving ? 'Speichert…' : (entry.saved ? 'Gespeichert' : (entry.released ? 'Download gestartet' : (entry.downloadRequested ? 'Erneut laden' : 'Speichern'))); btnSave.disabled = !!entry.saving || !!entry.saved || !!entry.released; btnSave.onclick = () => { void saveRecordingEntry(entry); }; const btnDel = document.createElement('button'); btnDel.className = 'btn btn-compact'; btnDel.textContent = 'Löschen'; btnDel.onclick = () => { removeRecordingFromHistory(entry); }; actions.appendChild(btnSave); actions.appendChild(btnDel); row.appendChild(name); row.appendChild(actions); recList.appendChild(row); }); } function downloadRecordingEntry(entry, { rerender = true } = {}) { if (!entry?.url) return false; const a = document.createElement('a'); a.href = entry.url; a.download = entry.name || 'recording.webm'; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); entry.downloadRequested = true; if (rerender) renderRecordingList(); return true; } function releaseRecordingEntryBlob(entry) { if (!entry?.url) return false; try { URL.revokeObjectURL(entry.url); } catch (_) {} entry.url = ''; entry.released = true; return true; } function isPhoenixRecorderSaveAvailable() { return String(env?.audio?.backendMode || 'phoenix') === 'phoenix' && !!env?.audio?.baseUrl; } function getRecorderSaveTarget() { const raw = CONFIG.RECORD_TARGET || recorderState.target || 'download'; return (raw === 'analyzer' || raw === 'volumio') ? raw : 'download'; } async function saveRecordingEntryToPhoenix(entry) { if (!entry?.url) throw new Error('Keine Aufnahme vorhanden'); const baseUrl = String(env?.audio?.baseUrl || '').trim(); if (!baseUrl) throw new Error('Phoenix nicht verbunden'); const target = getRecorderSaveTarget(); if (target !== 'analyzer' && target !== 'volumio') { throw new Error('Ungültiges Phoenix-Ziel'); } const filename = encodeURIComponent(String(entry.name || 'recording.wav')); const blob = await fetch(entry.url).then(async (res) => { if (!res.ok) throw new Error(`Blob-Zugriff fehlgeschlagen (${res.status})`); return await res.blob(); }); const res = await fetch(`${baseUrl}/api/v1/recordings/save/${target}/${filename}`, { method: 'POST', cache: 'no-store', headers: { 'content-type': blob.type || 'audio/wav' }, body: blob, }); if (!res.ok) { throw new Error(`Speichern fehlgeschlagen (${res.status})`); } return await res.json(); } async function saveRecordingEntry(entry, { rerender = true } = {}) { if (!entry || entry.saving || entry.saved) return false; entry.saving = true; if (rerender) renderRecordingList(); try { if (getRecorderSaveTarget() !== 'download') { if (!isPhoenixRecorderSaveAvailable()) { throw new Error('Phoenix-Speicherweg ist aktuell nicht verfügbar'); } const result = await saveRecordingEntryToPhoenix(entry); entry.saved = true; entry.savedPath = result?.path || ''; releaseRecordingEntryBlob(entry); } else { const downloaded = downloadRecordingEntry(entry, { rerender: false }); if (!downloaded) return false; entry.saved = true; releaseRecordingEntryBlob(entry); } return true; } catch (err) { recorderState.error = err?.message || 'Speichern fehlgeschlagen'; showErr(`Recorder: ${recorderState.error}`); return false; } finally { entry.saving = false; if (rerender) renderRecordingList(); } } if (gainSel) { gainSel.innerHTML = ''; REALTIME_GAIN_CHOICES.forEach((db) => { const opt = document.createElement('option'); const label = `${db > 0 ? '+' : ''}${db} dB`; opt.value = String(db); opt.textContent = label; gainSel.appendChild(opt); }); } if (waveWindowSel) { waveWindowSel.innerHTML = ''; WAVE_WINDOW_CHOICES.forEach((sec) => { const opt = document.createElement('option'); opt.value = String(sec); opt.textContent = formatWaveWindowLabel(sec); waveWindowSel.appendChild(opt); }); } if (waveModeSel) { waveModeSel.innerHTML = ''; WAVE_MODE_CHOICES.forEach(([val, label]) => { const opt = document.createElement('option'); opt.value = val; opt.textContent = label; waveModeSel.appendChild(opt); }); } if (rtaBpoSel) { rtaBpoSel.innerHTML = ''; RTA_BPO_CHOICES.forEach(([val, label]) => { const opt = document.createElement('option'); opt.value = val; opt.textContent = label; rtaBpoSel.appendChild(opt); }); } function showErr(msg){ if (!msg) return; console.error('App Error:', msg); errBox.textContent = String(msg); errBox.style.display = 'block'; setTimeout(hideErr, 5000); } function hideErr(){ errBox.style.display = 'none'; } function getPersistentItem(key) { try { const v = localStorage.getItem(key); if (v !== null && v !== undefined) return v; } catch (_) {} return null; } function setPersistentItem(key, value) { const str = String(value ?? ''); try { localStorage.setItem(key, str); } catch (_) {} } // Persistenz loadConfig(); const STYLE_STORAGE_KEY = 'analyzer_style'; const LAST_NON_OPTION_STYLE_KEY = 'analyzer_last_non_option_style'; const SLOT_FALLBACK_ID = 'vu'; const SLOT_OPTIONS = [ ['vu','VU'], ['ppm-ebu','PPM (EBU Type IIb)'], ['ppm-din','PPM (DIN Type I)'], ['tp','True Peak'], ['hifi-peak','HiFi Peak'], ['rms','True RMS'], ['lufs','LUFS (EBU R128)'], ['stopwatch','Stoppuhr'], ['none','(leer)'], ]; const SLOT_VALUE_SET = new Set(SLOT_OPTIONS.map(([val]) => val)); const SLOT_STORAGE_KEY = 'analyzer_slots_v2'; const SLOT_STORAGE_VIEW_PREFIX = `${SLOT_STORAGE_KEY}__`; const SLOT_CUSTOMIZED_KEY = 'analyzer_slots_customized_v1'; const LEGACY_SLOT_KEY = 'analyzer_slots'; const VIEWS_DISALLOW_EMPTY_SLOT = new Set(['peak-history', 'classic-needles']); const VIEW_SLOT_COUNTS = { 'realtime': 1, 'classic-needles': 1, 'peak-history': 1, 'goniometer-rtw': 3, 'phase-wheel': 3, 'panel': 5, 'spectrogram': 1, 'waveform': 1, 'recorder': 3, 'split-view': 0, 'quad-view': 0, 'clock': 0, }; const VIEW_SLOT_DEFAULTS = { 'realtime': ['ppm-din'], 'classic-needles': ['vu'], 'peak-history': ['ppm-din'], 'goniometer-rtw': ['ppm-ebu', 'ppm-din', 'lufs'], 'phase-wheel': ['vu', 'ppm-ebu', 'tp'], 'panel': ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms'], 'spectrogram': ['ppm-din'], 'waveform': ['ppm-din'], 'recorder': ['ppm-ebu', 'ppm-din', 'rms'], 'split-view': [], 'quad-view': [], 'clock': [], }; const CONFIG_BACKED_GLOBAL_SLOTS = Object.freeze({ 'peak-history': 'PEAK_HISTORY_SLOT', 'classic-needles': 'CLASSIC_NEEDLES_SLOT', }); function resolveStyleId(viewId){ return (viewId in VIEW_SLOT_DEFAULTS) ? viewId : 'panel'; } function sanitizeSlotId(id){ if (id === 'ppm') return 'ppm-ebu'; return SLOT_VALUE_SET.has(id) ? id : null; } function normalizeSlots(viewId, arr){ const resolved = resolveStyleId(viewId); const need = (resolved in VIEW_SLOT_COUNTS) ? VIEW_SLOT_COUNTS[resolved] : 1; const defaults = (resolved in VIEW_SLOT_DEFAULTS) ? VIEW_SLOT_DEFAULTS[resolved] : VIEW_SLOT_DEFAULTS.panel; const disallowEmpty = VIEWS_DISALLOW_EMPTY_SLOT.has(resolved); const out = []; for (let i = 0; i < need; i++){ let desired = Array.isArray(arr) ? sanitizeSlotId(arr[i]) : null; if (disallowEmpty && desired === 'none') desired = null; out[i] = desired ?? defaults[i] ?? defaults[0] ?? SLOT_FALLBACK_ID; } return out; } function loadLegacySlots(){ try { const raw = getPersistentItem(LEGACY_SLOT_KEY); if (!raw) return null; const arr = JSON.parse(raw); if (!Array.isArray(arr)) return null; const legacy = {}; for (const key of Object.keys(VIEW_SLOT_DEFAULTS)){ legacy[key] = normalizeSlots(key, arr); } return legacy; } catch { return null; } } function saveSlotState(state){ try { setPersistentItem(SLOT_STORAGE_KEY, JSON.stringify(state)); } catch (_) {} // Also persist per-view to be resilient against any single corrupted value. try { for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { setPersistentItem(`${SLOT_STORAGE_VIEW_PREFIX}${key}`, JSON.stringify(state?.[key] || [])); } } catch (_) {} } function loadSlotCustomizedState() { try { const raw = getPersistentItem(SLOT_CUSTOMIZED_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); return (parsed && typeof parsed === 'object') ? parsed : {}; } catch { return {}; } } function saveSlotCustomizedState(state) { try { setPersistentItem(SLOT_CUSTOMIZED_KEY, JSON.stringify(state)); } catch (_) {} } function loadSlotState(){ const readPersistentJson = (key) => { const candidates = []; try { candidates.push(localStorage.getItem(key)); } catch (_) {} for (const raw of candidates) { if (!raw) continue; try { return JSON.parse(raw); } catch (_) {} } return null; }; const loadPerViewSlots = () => { const out = {}; let any = false; for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { const parsed = readPersistentJson(`${SLOT_STORAGE_VIEW_PREFIX}${key}`); if (Array.isArray(parsed)) { out[key] = parsed; any = true; } } return any ? out : null; }; let source = readPersistentJson(SLOT_STORAGE_KEY); if (!(source && typeof source === 'object')) source = null; if (!source) source = loadPerViewSlots(); if (!source) source = loadLegacySlots(); if (!source) source = {}; const state = {}; const customized = loadSlotCustomizedState(); for (const key of Object.keys(VIEW_SLOT_DEFAULTS)){ state[key] = normalizeSlots(key, source?.[key]); } // Classic-Needles soll standardmäßig immer mit VU starten und erst nach manueller Änderung // den zuletzt gewählten Slot beibehalten. if (customized['classic-needles'] !== true) { state['classic-needles'] = normalizeSlots('classic-needles', VIEW_SLOT_DEFAULTS['classic-needles']); } saveSlotState(state); return state; } function syncConfigBackedSlots() { let changed = false; for (const [viewId, configKey] of Object.entries(CONFIG_BACKED_GLOBAL_SLOTS)) { const fallback = (VIEW_SLOT_DEFAULTS[viewId] && VIEW_SLOT_DEFAULTS[viewId][0]) || SLOT_FALLBACK_ID; const desired = sanitizeSlotId(CONFIG?.[configKey]); const normalized = normalizeSlots(viewId, [desired && desired !== 'none' ? desired : fallback]); const current = normalizeSlots(viewId, slotsState?.[viewId]); if (current[0] !== normalized[0]) { slotsState[viewId] = normalized; changed = true; } CONFIG[configKey] = normalized[0] || fallback; } if (changed) { saveSlotState(slotsState); } return changed; } let slotsState = loadSlotState(); syncConfigBackedSlots(); const DEFAULT_STYLE = 'realtime'; const storedStyleRaw = getPersistentItem(STYLE_STORAGE_KEY); const cfgStyleRaw = (typeof CONFIG?.UI_LAST_STYLE === 'string' && CONFIG.UI_LAST_STYLE) ? CONFIG.UI_LAST_STYLE : null; let style = storedStyleRaw || cfgStyleRaw || DEFAULT_STYLE; if (style === 'options') style = 'options-panel'; const VALID_STYLES = new Set(['realtime','split-view','quad-view','classic-needles','peak-history','panel','goniometer-rtw','phase-wheel','spectrogram','waveform','recorder','clock','options-panel']); if (!VALID_STYLES.has(style)) style = DEFAULT_STYLE; // First boot: ensure the Real Time Analyzer is used and persisted. if (!storedStyleRaw) { style = DEFAULT_STYLE; try { setPersistentItem(STYLE_STORAGE_KEY, style); } catch (_) {} try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, style); } catch (_) {} } let lastNonOptionStyle = isOptionsStyle(style) ? DEFAULT_STYLE : style; if (isOptionsStyle(style)) { const savedLast = getPersistentItem(LAST_NON_OPTION_STYLE_KEY); if (savedLast && VALID_STYLES.has(savedLast) && !isOptionsStyle(savedLast)) { lastNonOptionStyle = savedLast; } const cfgLast = (typeof CONFIG?.UI_LAST_NON_OPTION_STYLE === 'string' && CONFIG.UI_LAST_NON_OPTION_STYLE) ? CONFIG.UI_LAST_NON_OPTION_STYLE : null; if (cfgLast && VALID_STYLES.has(cfgLast) && !isOptionsStyle(cfgLast)) { lastNonOptionStyle = cfgLast; } } styleSel.value = style; const popupStates = { split: { open: false, viewId: null }, quad: { open: false, viewId: null }, }; function getSlotsForStyle(viewId = style){ const resolved = resolveStyleId(viewId); return (slotsState[resolved] || VIEW_SLOT_DEFAULTS[resolved] || []).slice(); } function getRequiredPlotSlots(viewId) { const resolved = resolveStyleId(viewId); if (resolved === 'peak-history' || resolved === 'classic-needles' || resolved === 'panel') { const slots = getSlotsForStyle(resolved).filter((slotId) => slotId && slotId !== 'none'); if (slots.length) return slots; } if (resolved === 'peak-history') return ['ppm-din']; if (resolved === 'classic-needles') return ['vu']; if (resolved === 'panel') { return (VIEW_SLOT_DEFAULTS.panel || []).filter((slotId) => slotId && slotId !== 'none'); } return []; } function getActiveMeterIds(viewId = style) { const resolvedView = resolveStyleId(viewId); const pushPlotRequiredMeters = (plotId, out) => { if (!plotId || !out) return; out.push(...getRequiredPlotSlots(plotId)); }; const pushViewSlotMeters = (viewId, out) => { if (!viewId || !out) return; const slots = getSlotsForStyle(viewId); for (const s of (slots || [])) { if (s && s !== 'none') out.push(s); } }; if (resolvedView === 'split-view') { const ids = []; const count = Math.max(0, Math.min(3, Number(CONFIG.SPLIT_VIEW_METER_COUNT) || 0)); const pushIf = (v) => { if (v && v !== 'none') ids.push(v); }; if (count >= 1) pushIf(CONFIG.SPLIT_VIEW_METER_1); if (count >= 2) pushIf(CONFIG.SPLIT_VIEW_METER_2); if (count >= 3) pushIf(CONFIG.SPLIT_VIEW_METER_3); pushPlotRequiredMeters(CONFIG.SPLIT_VIEW_LEFT, ids); pushPlotRequiredMeters(CONFIG.SPLIT_VIEW_RIGHT, ids); if (popupStates.split.open && popupStates.split.viewId) { pushViewSlotMeters(popupStates.split.viewId, ids); pushPlotRequiredMeters(popupStates.split.viewId, ids); } return Array.from(new Set(ids)); } if (resolvedView === 'quad-view') { const ids = []; const count = Math.max(0, Math.min(3, Number(CONFIG.QUAD_VIEW_METER_COUNT) || 0)); const pushIf = (v) => { if (v && v !== 'none') ids.push(v); }; if (count >= 1) pushIf(CONFIG.QUAD_VIEW_METER_1); if (count >= 2) pushIf(CONFIG.QUAD_VIEW_METER_2); if (count >= 3) pushIf(CONFIG.QUAD_VIEW_METER_3); pushPlotRequiredMeters(CONFIG.QUAD_VIEW_TL, ids); pushPlotRequiredMeters(CONFIG.QUAD_VIEW_TR, ids); pushPlotRequiredMeters(CONFIG.QUAD_VIEW_BL, ids); pushPlotRequiredMeters(CONFIG.QUAD_VIEW_BR, ids); if (popupStates.quad.open && popupStates.quad.viewId) { pushViewSlotMeters(popupStates.quad.viewId, ids); pushPlotRequiredMeters(popupStates.quad.viewId, ids); } return Array.from(new Set(ids)); } const resolvedSlots = getSlotsForStyle(resolvedView); return Array.from(new Set(resolvedSlots.filter((v) => v && v !== 'none'))); } function getActivePlotIds(viewId = style) { const resolvedView = resolveStyleId(viewId); const ids = []; const pushIf = (plotId) => { const resolved = resolveStyleId(plotId); if (resolved && resolved !== 'none') ids.push(resolved); }; if (resolvedView === 'split-view') { pushIf(CONFIG.SPLIT_VIEW_LEFT); pushIf(CONFIG.SPLIT_VIEW_RIGHT); if (popupStates.split.open && popupStates.split.viewId) pushIf(popupStates.split.viewId); return Array.from(new Set(ids)); } if (resolvedView === 'quad-view') { pushIf(CONFIG.QUAD_VIEW_TL); pushIf(CONFIG.QUAD_VIEW_TR); pushIf(CONFIG.QUAD_VIEW_BL); pushIf(CONFIG.QUAD_VIEW_BR); if (popupStates.quad.open && popupStates.quad.viewId) pushIf(popupStates.quad.viewId); return Array.from(new Set(ids)); } pushIf(resolvedView); return Array.from(new Set(ids)); } function buildProcessingProfile(viewId = style) { const renderView = resolveStyleId(viewId); const profile = { needXy: false, needRta: false, needVu: false, needPpmDin: false, needPpmEbu: false, needTp: false, needRms: false, needWaveform: false, }; const plotIds = getActivePlotIds(renderView); for (const plotId of plotIds) { if (plotId === 'realtime') { profile.needRta = true; } else if (plotId === 'goniometer-rtw') { profile.needXy = true; profile.needRms = true; } else if (plotId === 'phase-wheel') { profile.needXy = true; if ((CONFIG.PHASE_AMPLITUDE_MODE || 'bandpass') === 'ppm-din') { profile.needPpmDin = true; } } else if (plotId === 'waveform') { profile.needWaveform = true; } else if (plotId === 'clock') { const clockStyle = CONFIG.CLOCK_STYLE || 'analog'; if (clockStyle === 'analog-ppm' || clockStyle === 'digital-ppm') { profile.needPpmDin = true; } } } const meterIds = getActiveMeterIds(renderView); for (const meterId of meterIds) { if (meterId === 'vu') profile.needVu = true; else if (meterId === 'ppm-din') profile.needPpmDin = true; else if (meterId === 'ppm-ebu') profile.needPpmEbu = true; else if (meterId === 'tp') profile.needTp = true; else if (meterId === 'hifi-peak') profile.needTp = true; else if (meterId === 'rms') profile.needRms = true; else if (meterId === 'lufs') profile.needTp = true; } if (CONFIG.SCREENSAVER_ENABLED !== false) { profile.needRms = true; } if (recorderState.autoEnabled) { profile.needRms = true; } return profile; } function getVisibleSlotCount(viewId = style){ const resolved = resolveStyleId(viewId); return (resolved in VIEW_SLOT_COUNTS) ? VIEW_SLOT_COUNTS[resolved] : 1; } function setSlotForView(viewId, idx, value){ const resolved = resolveStyleId(viewId); if (idx >= getVisibleSlotCount(resolved)) return; const valid = sanitizeSlotId(value); if (!valid) return; if (VIEWS_DISALLOW_EMPTY_SLOT.has(resolved) && valid === 'none') return; const current = Array.isArray(slotsState[resolved]) ? slotsState[resolved].slice() : []; current[idx] = valid; slotsState[resolved] = normalizeSlots(resolved, current); saveSlotState(slotsState); const customized = loadSlotCustomizedState(); customized[resolved] = true; saveSlotCustomizedState(customized); const configBackedKey = CONFIG_BACKED_GLOBAL_SLOTS[resolved]; if (configBackedKey) { CONFIG[configBackedKey] = slotsState[resolved]?.[0] || VIEW_SLOT_DEFAULTS[resolved]?.[0] || SLOT_FALLBACK_ID; try { saveConfig?.(); } catch (_) {} try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {} } } function resetAllSlotsToDefaults() { const next = {}; for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { next[key] = normalizeSlots(key, VIEW_SLOT_DEFAULTS[key]); } slotsState = next; syncConfigBackedSlots(); saveSlotState(slotsState); saveSlotCustomizedState({}); refreshSlotEditors(); } function initSlotSelect(sel, idx){ if (!sel) return; sel.dataset.slotIndex = String(idx); sel.innerHTML = ''; for (const [val,label] of SLOT_OPTIONS){ const o = document.createElement('option'); o.value = val; o.textContent = label; sel.appendChild(o); } sel.onchange = () => { if (isOptionsStyle(style)) return; setSlotForView(style, idx, sel.value); syncSlotSelectValues(style); }; } slotSelects.forEach((sel, idx) => initSlotSelect(sel, idx)); if (phaseTrailChk) { phaseTrailChk.addEventListener('change', () => { CONFIG.PHASE_TRAIL_ENABLED = !!phaseTrailChk.checked; saveConfig(); }); } if (goniAgcChk) { goniAgcChk.addEventListener('change', () => { setGoniAgcEnabled(!!goniAgcChk.checked); saveConfig(); }); } function syncSlotSelectValues(viewId = style){ const resolved = resolveStyleId(viewId); const slots = getSlotsForStyle(resolved); const visibleCount = getVisibleSlotCount(resolved); const disallowEmpty = VIEWS_DISALLOW_EMPTY_SLOT.has(resolved); const tip = disallowEmpty ? "Hinweis: Diese Anzeige ist vom Slot abhängig; '(leer)' ist hier nicht verfügbar." : ''; slotSelects.forEach((sel, idx) => { if (!sel) return; const show = idx < visibleCount; sel.style.display = show ? 'inline-block' : 'none'; if (show) { sel.title = tip; for (const opt of sel.options) { if (opt && opt.value === 'none') { opt.disabled = disallowEmpty; opt.title = tip; } } sel.value = slots[idx] || SLOT_FALLBACK_ID; } else { sel.title = ''; } }); } function ensureNoEmptySlotsForView(viewId = style){ const resolved = resolveStyleId(viewId); if (!VIEWS_DISALLOW_EMPTY_SLOT.has(resolved)) return; const current = Array.isArray(slotsState?.[resolved]) ? slotsState[resolved].slice() : []; if (!current.includes('none')) return; slotsState[resolved] = normalizeSlots(resolved, current); saveSlotState(slotsState); } function updateSlotLabel(viewId = style){ if (!slotLabel) return; const count = getVisibleSlotCount(viewId); slotLabel.textContent = count === 1 ? 'Slot' : 'Slots'; } function refreshSlotEditors(){ if (!slotWrap) return; const isOptionsView = isOptionsStyle(style); const isClockView = style === 'clock'; const isSplitView = style === 'split-view'; const isQuadView = style === 'quad-view'; const visibleCount = getVisibleSlotCount(style); if (isOptionsView || isClockView) { slotWrap.style.display = 'none'; if (splitViewWrap) splitViewWrap.style.display = 'none'; if (quadViewWrap) quadViewWrap.style.display = 'none'; setSplitMeterPopupOpen(false); setQuadPlotPopupOpen(false); if (gainWrap) gainWrap.style.display = 'none'; if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; if (waveWindowWrap) waveWindowWrap.style.display = 'none'; if (waveModeWrap) waveModeWrap.style.display = 'none'; if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; if (goniAgcToggle) goniAgcToggle.style.display = 'none'; if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; if (waveWindowSel) { waveWindowSel.value = String(getNearestWaveWindow(CONFIG.WAVEFORM_WINDOW_SEC ?? 1)); } if (waveModeSel) { waveModeSel.value = normalizeWaveMode(CONFIG.WAVEFORM_MODE); } if (rtaBpoSel) { rtaBpoSel.value = normalizeRtaBpo(CONFIG.RTA_BPO_MODE); } if (recorderHud) recorderHud.style.display = 'none'; return; } if (isSplitView) { slotWrap.style.display = 'none'; if (quadViewWrap) quadViewWrap.style.display = 'none'; setQuadPlotPopupOpen(false); if (gainWrap) gainWrap.style.display = 'none'; if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; if (waveWindowWrap) waveWindowWrap.style.display = 'none'; if (waveModeWrap) waveModeWrap.style.display = 'none'; if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; if (goniAgcToggle) goniAgcToggle.style.display = 'none'; if (phaseTrailToggle) phaseTrailToggle.style.display = 'none'; if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; if (recorderHud) recorderHud.style.display = 'none'; syncSplitViewControls(); return; } if (isQuadView) { slotWrap.style.display = 'none'; if (splitViewWrap) splitViewWrap.style.display = 'none'; if (gainWrap) gainWrap.style.display = 'none'; if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; if (waveWindowWrap) waveWindowWrap.style.display = 'none'; if (waveModeWrap) waveModeWrap.style.display = 'none'; if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; if (goniAgcToggle) goniAgcToggle.style.display = 'none'; if (phaseTrailToggle) phaseTrailToggle.style.display = 'none'; if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; if (recorderHud) recorderHud.style.display = 'none'; syncQuadViewControls(); return; } if (splitViewWrap) splitViewWrap.style.display = 'none'; if (quadViewWrap) quadViewWrap.style.display = 'none'; setSplitMeterPopupOpen(false); setQuadPlotPopupOpen(false); const hideSlots = visibleCount === 0; if (hideSlots) { slotWrap.style.display = 'none'; if (gainWrap) gainWrap.style.display = 'none'; if (spectroLegendWrap) spectroLegendWrap.style.display = 'none'; if (waveWindowWrap) waveWindowWrap.style.display = 'none'; if (waveModeWrap) waveModeWrap.style.display = 'none'; if (rtaBpoWrap) rtaBpoWrap.style.display = 'none'; if (goniAgcToggle) goniAgcToggle.style.display = 'none'; if (phaseTrailToggle) phaseTrailToggle.style.display = 'none'; if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none'; if (recorderHud) recorderHud.style.display = 'none'; return; } ensureNoEmptySlotsForView(style); slotWrap.style.display = 'flex'; if (recorderHud) recorderHud.style.display = (style === 'recorder') ? 'flex' : 'none'; updateSlotLabel(style); syncSlotSelectValues(style); syncRealtimeGainControl(); syncWaveWindowControl(); syncWaveModeControl(); syncRtaBpoControl(); syncSpectrogramLegendVisibility(); syncPhaseTrailToggle(); syncGoniAgcToggle(); syncPeakHistScrollControl(); } const SPLIT_PLOT_OPTIONS = [ ['none', '(leer)'], ['phase-wheel', 'Phase Wheel'], ['realtime', 'Real Time Analyzer'], ['goniometer-rtw', 'XY-Display'], ['peak-history', 'Peak History'], ['classic-needles', 'Classic Needles'], ['panel', 'Meters'], ['clock', 'Studio Clock'], ['waveform', 'Waveform'], ['spectrogram', 'Spectrogram'], ]; const SPLIT_PLOT_VALUE_SET = new Set(SPLIT_PLOT_OPTIONS.map(([id]) => id)); function sanitizeSplitPlotId(id) { return SPLIT_PLOT_VALUE_SET.has(id) ? id : null; } function initSplitPlotSelect(sel, onChange) { if (!sel) return; sel.innerHTML = ''; for (const [val, label] of SPLIT_PLOT_OPTIONS) { const o = document.createElement('option'); o.value = val; o.textContent = label; sel.appendChild(o); } sel.addEventListener('change', () => onChange(sel.value)); } initSplitPlotSelect(splitLeftSel, (val) => { const clean = sanitizeSplitPlotId(val) ?? 'none'; CONFIG.SPLIT_VIEW_LEFT = clean; saveConfig(); }); initSplitPlotSelect(splitRightSel, (val) => { const clean = sanitizeSplitPlotId(val) ?? 'none'; CONFIG.SPLIT_VIEW_RIGHT = clean; saveConfig(); }); function clampMeterCount3(v) { const n = Number(v); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(3, n | 0)); } function initSplitMeterTypeSelect(sel, onChange) { if (!sel) return; sel.innerHTML = ''; for (const [val, label] of SLOT_OPTIONS) { const o = document.createElement('option'); o.value = val; o.textContent = label; sel.appendChild(o); } sel.addEventListener('change', () => onChange(sel.value)); } let meterPopupPrefix = 'SPLIT_VIEW'; function getMeterKey(prefix, idx) { const i = Math.max(1, Math.min(3, Number(idx) || 1)); return `${prefix}_METER_${i}`; } function getMeterPosKey(prefix, idx) { const i = Math.max(1, Math.min(3, Number(idx) || 1)); return `${prefix}_METER_${i}_POS`; } function getMeterCountKey(prefix) { return `${prefix}_METER_COUNT`; } function readMeterFromConfig(prefix, idx) { const key = getMeterKey(prefix, idx); return sanitizeSlotId(CONFIG?.[key]) ?? 'vu'; } function writeMeterToConfig(prefix, idx, val) { const key = getMeterKey(prefix, idx); CONFIG[key] = sanitizeSlotId(val) ?? 'vu'; saveConfig(); } function setSplitMeterPopupOpen(open) { if (!splitMeterPopup) return; splitMeterPopup.style.display = open ? 'flex' : 'none'; } function syncSplitMeterPopup() { const meterCount = clampMeterCount3(CONFIG?.[getMeterCountKey(meterPopupPrefix)]); if (splitMeterRow1) splitMeterRow1.style.display = meterCount >= 1 ? 'flex' : 'none'; if (splitMeterRow2) splitMeterRow2.style.display = meterCount >= 2 ? 'flex' : 'none'; if (splitMeterRow3) splitMeterRow3.style.display = meterCount >= 3 ? 'flex' : 'none'; if (splitMeterSel1) splitMeterSel1.value = readMeterFromConfig(meterPopupPrefix, 1); if (splitMeterSel2) splitMeterSel2.value = readMeterFromConfig(meterPopupPrefix, 2); if (splitMeterSel3) splitMeterSel3.value = readMeterFromConfig(meterPopupPrefix, 3); } initSplitMeterTypeSelect(splitMeterSel1, (val) => { if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return; writeMeterToConfig(meterPopupPrefix, 1, val); syncSplitMeterPopup(); }); initSplitMeterTypeSelect(splitMeterSel2, (val) => { if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return; writeMeterToConfig(meterPopupPrefix, 2, val); syncSplitMeterPopup(); }); initSplitMeterTypeSelect(splitMeterSel3, (val) => { if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return; writeMeterToConfig(meterPopupPrefix, 3, val); syncSplitMeterPopup(); }); function setQuadPlotPopupOpen(open) { if (!quadPlotPopup) return; quadPlotPopup.style.display = open ? 'flex' : 'none'; } function syncQuadPlotPopup() { if (quadPlotSelTL) quadPlotSelTL.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_TL) ?? 'phase-wheel'; if (quadPlotSelTR) quadPlotSelTR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_TR) ?? 'realtime'; if (quadPlotSelBL) quadPlotSelBL.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BL) ?? 'goniometer-rtw'; if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none'; } initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); }); initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); }); initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); }); initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); }); if (splitMeterBtn) { splitMeterBtn.addEventListener('click', () => { if (style !== 'split-view' || isOptionsStyle(style)) return; if (splitMeterPopupTitle) splitMeterPopupTitle.textContent = 'Split-View Slots'; meterPopupPrefix = 'SPLIT_VIEW'; syncSplitMeterPopup(); setSplitMeterPopupOpen(true); }); } if (quadMeterBtn) { const openQuadSlots = (ev) => { if (isOptionsStyle(style) || style !== 'quad-view') return; try { ev?.preventDefault?.(); } catch (_) {} if (splitMeterPopupTitle) splitMeterPopupTitle.textContent = 'Quad-View Slots'; meterPopupPrefix = 'QUAD_VIEW'; syncSplitMeterPopup(); setSplitMeterPopupOpen(true); }; quadMeterBtn.addEventListener('click', openQuadSlots); } if (quadPlotBtn) { const openQuadPlots = (ev) => { if (isOptionsStyle(style) || style !== 'quad-view') return; try { ev?.preventDefault?.(); } catch (_) {} syncQuadPlotPopup(); setQuadPlotPopupOpen(true); }; quadPlotBtn.addEventListener('click', openQuadPlots); } if (splitMeterPopupClose) { splitMeterPopupClose.addEventListener('click', () => setSplitMeterPopupOpen(false)); } if (splitMeterPopup) { splitMeterPopup.addEventListener('pointerdown', (e) => { if (e?.target === splitMeterPopup) setSplitMeterPopupOpen(false); }); const box = splitMeterPopup.querySelector?.('.split-popup__box'); box?.addEventListener?.('pointerdown', (e) => e.stopPropagation()); } if (quadPlotPopupClose) { quadPlotPopupClose.addEventListener('click', () => setQuadPlotPopupOpen(false)); } if (quadPlotPopup) { quadPlotPopup.addEventListener('pointerdown', (e) => { if (e?.target === quadPlotPopup) setQuadPlotPopupOpen(false); }); const box = quadPlotPopup.querySelector?.('.split-popup__box'); box?.addEventListener?.('pointerdown', (e) => e.stopPropagation()); } addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (splitMeterPopup && splitMeterPopup.style.display !== 'none') setSplitMeterPopupOpen(false); if (quadPlotPopup && quadPlotPopup.style.display !== 'none') setQuadPlotPopupOpen(false); if (env?.splitPopup?.open) { env.splitPopup.open = false; env.splitPopup.viewId = null; } if (env?.quadPopup?.open) { env.quadPopup.open = false; env.quadPopup.viewId = null; } }); function syncSplitViewControls() { if (!splitViewWrap || !splitLeftSel || !splitRightSel) return; const show = style === 'split-view' && !isOptionsStyle(style); splitViewWrap.style.display = show ? 'flex' : 'none'; if (!show) return; splitLeftSel.value = sanitizeSplitPlotId(CONFIG.SPLIT_VIEW_LEFT) ?? 'phase-wheel'; splitRightSel.value = sanitizeSplitPlotId(CONFIG.SPLIT_VIEW_RIGHT) ?? 'realtime'; const meterCount = clampMeterCount3(CONFIG.SPLIT_VIEW_METER_COUNT); const showMetersBtn = meterCount >= 1; if (splitMeterBtnWrap) splitMeterBtnWrap.style.display = showMetersBtn ? 'inline-flex' : 'none'; if (showMetersBtn) syncSplitMeterPopup(); else setSplitMeterPopupOpen(false); } syncSplitViewControls(); function syncQuadViewControls() { if (!quadViewWrap) return; const show = style === 'quad-view' && !isOptionsStyle(style); quadViewWrap.style.display = show ? 'flex' : 'none'; if (!show) { setQuadPlotPopupOpen(false); return; } const meterCount = clampMeterCount3(CONFIG.QUAD_VIEW_METER_COUNT); const showMetersBtn = meterCount >= 1; if (quadMeterBtnWrap) quadMeterBtnWrap.style.display = showMetersBtn ? 'inline-flex' : 'none'; if (!showMetersBtn) setSplitMeterPopupOpen(false); } syncQuadViewControls(); refreshSlotEditors(); function syncRealtimeGainControl(){ const isRealtime = (style === 'realtime'); const iirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir'; if (gainWrap && gainSel) { const showGain = isRealtime && iirEngine; gainWrap.style.display = showGain ? 'flex' : 'none'; if (showGain) { let val = Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB); if (!REALTIME_GAIN_CHOICES.includes(val)) val = 0; gainSel.value = String(val); } } if (resetPeakBtn) { const showBtn = isRealtime && (CONFIG.RTA_PEAK_HOLD_MODE === 'manual'); resetPeakBtn.style.display = showBtn ? 'inline-flex' : 'none'; } } function syncSpectrogramLegendVisibility(){ if (!spectroLegendWrap) return; const show = (style === 'spectrogram') && !isOptionsStyle(style); spectroLegendWrap.style.display = show ? 'flex' : 'none'; } function syncWaveWindowControl(){ if (!waveWindowWrap || !waveWindowSel) return; const show = (style === 'waveform') && !isOptionsStyle(style); waveWindowWrap.style.display = show ? 'flex' : 'none'; const val = getNearestWaveWindow(CONFIG.WAVEFORM_WINDOW_SEC ?? 1); waveWindowSel.value = String(val); } function syncWaveModeControl(){ if (!waveModeWrap || !waveModeSel) return; const show = (style === 'waveform') && !isOptionsStyle(style); waveModeWrap.style.display = show ? 'flex' : 'none'; waveModeSel.value = normalizeWaveMode(CONFIG.WAVEFORM_MODE); } function syncPeakHistScrollControl(){ if (!peakHistScrollWrap || !peakHistScrollSel) return; const show = (style === 'peak-history') && !isOptionsStyle(style); peakHistScrollWrap.style.display = show ? 'flex' : 'none'; if (show) { const val = normalizePeakHistScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE); peakHistScrollSel.value = String(val); } } function hasActivePhaseWheelPlot() { if (isOptionsStyle(style)) return false; return getActivePlotIds(getRenderableStyle()).includes('phase-wheel'); } function syncPhaseTrailToggle(){ if (!phaseTrailToggle || !phaseTrailChk) return; const show = hasActivePhaseWheelPlot(); phaseTrailToggle.style.display = show ? 'inline-flex' : 'none'; if (show) { phaseTrailChk.checked = !!CONFIG.PHASE_TRAIL_ENABLED; } } function syncRtaBpoControl(){ if (!rtaBpoWrap || !rtaBpoSel) return; const show = (style === 'realtime') && !isOptionsStyle(style); rtaBpoWrap.style.display = show ? 'flex' : 'none'; rtaBpoSel.value = normalizeRtaBpo(CONFIG.RTA_BPO_MODE); } function syncGoniAgcToggle(){ if (!goniAgcToggle || !goniAgcChk) return; const show = style === 'goniometer-rtw' && !isOptionsStyle(style); goniAgcToggle.style.display = show ? 'inline-flex' : 'none'; if (show) { goniAgcChk.checked = !!CONFIG.GONIO_AGC_ENABLED; } } function setGoniAgcEnabled(enabled){ const targetState = !!enabled; const wasEnabled = !!CONFIG.GONIO_AGC_ENABLED; const gainSliderEl = document.getElementById('opt_goniGain'); const gainLabel = document.getElementById('val_goniGain'); const optionsToggle = document.getElementById('opt_goniAgc'); const hudToggle = document.getElementById('goniAgcChk'); const sliderStored = gainSliderEl && gainSliderEl.dataset && typeof gainSliderEl.dataset.lastManualGain === 'string' ? sanitizeGoniGain(gainSliderEl.dataset.lastManualGain) : null; const sliderVal = (!wasEnabled && gainSliderEl) ? sanitizeGoniGain(gainSliderEl.value) : null; let manual = sanitizeGoniGain(CONFIG.GONIO_MANUAL_GAIN_DB ?? sliderStored ?? CONFIG.GONIO_DISPLAY_GAIN_DB); if (targetState) { if (sliderVal !== null) manual = sliderVal; else if (sliderStored !== null) manual = sliderStored; CONFIG.GONIO_MANUAL_GAIN_DB = manual; CONFIG.GONIO_DISPLAY_GAIN_DB = 0; } else { const restore = sanitizeGoniGain(CONFIG.GONIO_MANUAL_GAIN_DB ?? sliderStored ?? sliderVal ?? CONFIG.GONIO_DISPLAY_GAIN_DB ?? manual); CONFIG.GONIO_MANUAL_GAIN_DB = restore; CONFIG.GONIO_DISPLAY_GAIN_DB = restore; manual = restore; } CONFIG.GONIO_AGC_ENABLED = targetState; if (gainSliderEl) { gainSliderEl.disabled = targetState; gainSliderEl.value = targetState ? '0' : String(manual); gainSliderEl.dataset.lastManualGain = targetState ? String(manual) : String(manual); } if (gainLabel) { const labelVal = targetState ? 0 : manual; gainLabel.textContent = `${labelVal} dB`; } if (optionsToggle && optionsToggle.checked !== targetState) { optionsToggle.checked = targetState; } if (hudToggle && hudToggle.checked !== targetState) { hudToggle.checked = targetState; } } function applyStyleSelection(nextStyle, opts = {}) { const persistStorage = opts.persistStorage !== false; const persistConfig = opts.persistConfig !== false; const targetStyle = VALID_STYLES.has(nextStyle) ? nextStyle : DEFAULT_STYLE; if (targetStyle === 'clock' && style !== 'clock') { lastViewBeforeClock = getRenderableStyle(); } if (style === 'recorder' && targetStyle !== 'recorder' && recorderState.autoEnabled) { setRecorderAutoEnabled(false); } style = targetStyle; if (styleSel && styleSel.value !== targetStyle) { styleSel.value = targetStyle; } if (persistStorage) { setPersistentItem(STYLE_STORAGE_KEY, style); } adjustStyleDropdownWidth(); refreshSlotEditors(); toggleOptionsPanels(); if (!isOptionsStyle(style)) { lastNonOptionStyle = style; if (persistStorage) { try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, style); } catch (_) {} } setView(style); } else { const renderStyle = getRenderableStyle(); if (!currentView || currentViewId !== renderStyle) { setView(renderStyle); } } if (persistConfig) { persistStyleToConfig(style); } } styleSel.onchange = () => { applyStyleSelection(styleSel.value); }; import * as viewClock from './views/clock.js'; // --- Register Meters --- registerMeter(vu); registerMeter(ppmEbu); registerMeter(ppmDin); registerMeter(tp); registerMeter(hifiPeak); registerMeter(rms); registerMeter(lufs); registerMeter(stopwatch); const screensaver = createScreensaver({ config: CONFIG, body: document.body }); screensaver.setEnabled(CONFIG.SCREENSAVER_ENABLED !== false); // --- Options UI -------------------------------------------------------------- const opts = setupOptions({ audioReload: async () => { await bootAudio(true); }, resetSlotsToDefaults: () => resetAllSlotsToDefaults(), invalidateMeters: () => { if (typeof meterFacade.invalidateAll === 'function') { meterFacade.invalidateAll(); } }, notifyRtaConfig: () => { try { env.audio.updateRtaConfig?.(); } catch (_) {} }, notifyPhoenixGlobalConfig: () => { try { env.audio.updatePhoenixGlobalConfig?.(); } catch (_) {} }, notifyPpmConfig: () => { try { env.audio.updatePpmConfig?.(); } catch (_) {} }, syncRealtimeGain: () => { syncRealtimeGainControl(); }, updateInputOffset: () => { try { env.audio.updateInputOffset?.(); } catch (_) {} }, updateMonoInput: () => { try { env.audio.updateMonoMode?.(); } catch (_) {} }, syncWaveformWindow: () => { syncWaveWindowControl(); }, syncWaveformMode: () => { syncWaveModeControl(); }, syncRtaBpo: () => { syncRtaBpoControl(); }, syncSplitView: () => { syncSplitViewControls(); syncQuadViewControls(); }, buildPresetSnapshot: () => { const snapshot = JSON.parse(JSON.stringify(CONFIG)); delete snapshot.Y_TICKS; delete snapshot.INPUT_OFFSET_GAIN_L; delete snapshot.INPUT_OFFSET_GAIN_R; snapshot.__PHOENIX_UI_STATE = buildPresetUiState(); return snapshot; }, buildPresetUiState: () => { return buildPresetUiState(); }, applyPresetUiState: (uiState) => { applyPresetUiState(uiState); }, switchView: (id) => { const target = VALID_STYLES.has(id) ? id : DEFAULT_STYLE; if (styleSel) { styleSel.value = target; styleSel.dispatchEvent(new Event('change')); } else { style = target; setView(target); requestRender('preset-switch-view'); } }, requestRender: () => { requestRender('preset-refresh'); }, updateLrDelay: () => { try { env.audio.updateLrDelay?.(); } catch (_) {} }, screensaver, }); toggleOptionsPanels(); adjustStyleDropdownWidth(); maybeShowCalibrationNotice(); function buildPresetUiState() { const slots = {}; for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { slots[key] = normalizeSlots(key, slotsState?.[key]); } return { style: VALID_STYLES.has(style) ? style : DEFAULT_STYLE, lastNonOptionStyle: (VALID_STYLES.has(lastNonOptionStyle) && !isOptionsStyle(lastNonOptionStyle)) ? lastNonOptionStyle : DEFAULT_STYLE, slots, slotCustomized: loadSlotCustomizedState(), }; } function applyPresetUiState(uiState = null) { if (!uiState || typeof uiState !== 'object' || Array.isArray(uiState)) return; if (uiState.slots && typeof uiState.slots === 'object' && !Array.isArray(uiState.slots)) { const next = {}; for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) { next[key] = normalizeSlots(key, uiState.slots[key]); } slotsState = next; saveSlotState(slotsState); } syncConfigBackedSlots(); const customized = (uiState.slotCustomized && typeof uiState.slotCustomized === 'object' && !Array.isArray(uiState.slotCustomized)) ? uiState.slotCustomized : {}; saveSlotCustomizedState(customized); const savedLast = String(uiState.lastNonOptionStyle || '').trim(); if (savedLast && VALID_STYLES.has(savedLast) && !isOptionsStyle(savedLast)) { lastNonOptionStyle = savedLast; try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, savedLast); } catch (_) {} } const savedStyle = String(uiState.style || '').trim(); if (savedStyle && VALID_STYLES.has(savedStyle)) { try { setPersistentItem(STYLE_STORAGE_KEY, savedStyle); } catch (_) {} } refreshSlotEditors(); } function adjustStyleDropdownWidth(){ if (!styleSel) return; const opt = styleSel.options[styleSel.selectedIndex]; const rawText = (opt ? opt.textContent : '') || ''; const targetText = (rawText.trim() === 'Meters') ? rawText : 'Real Time Analyzer'; const computed = window.getComputedStyle(styleSel); const font = computed.font || '16px ui-monospace, monospace'; const paddingLeft = parseFloat(computed.paddingLeft) || 0; const paddingRight = parseFloat(computed.paddingRight) || 0; const arrowAllowance = 24; if (!adjustStyleDropdownWidth.ctx) { adjustStyleDropdownWidth.canvas = document.createElement('canvas'); adjustStyleDropdownWidth.ctx = adjustStyleDropdownWidth.canvas.getContext('2d'); } const ctx = adjustStyleDropdownWidth.ctx; ctx.font = font; const measured = ctx.measureText(targetText).width; const width = Math.ceil(measured + paddingLeft + paddingRight + arrowAllowance); styleSel.style.width = width + 'px'; } adjustStyleDropdownWidth.canvas = null; adjustStyleDropdownWidth.ctx = null; function isOptionsStyle(val) { return val === 'options-panel'; } function getRenderableStyle() { return isOptionsStyle(style) ? (lastNonOptionStyle || DEFAULT_STYLE) : style; } let persistStyleFollowupTimer = 0; function persistStyleToConfig(currentStyle) { CONFIG.UI_LAST_STYLE = currentStyle; if (!isOptionsStyle(currentStyle)) { CONFIG.UI_LAST_NON_OPTION_STYLE = currentStyle; } // Sofort speichern (damit ein schneller Neustart nicht 3–5s Wartezeit braucht). try { saveConfig(); } catch (_) {} // Best effort: einmal kurz danach nochmal speichern. if (persistStyleFollowupTimer) { try { clearTimeout(persistStyleFollowupTimer); } catch (_) {} } persistStyleFollowupTimer = setTimeout(() => { persistStyleFollowupTimer = 0; if (style !== currentStyle) return; CONFIG.UI_LAST_STYLE = currentStyle; if (!isOptionsStyle(currentStyle)) CONFIG.UI_LAST_NON_OPTION_STYLE = currentStyle; try { saveConfig(); } catch (_) {} }, 800); } function toggleOptionsPanels() { if (!optionsPanelNew) return; const showNewPanel = isOptionsStyle(style); optionsPanelNew.style.display = showNewPanel ? 'block' : 'none'; if (showNewPanel) collapseNewOptionsSections(); } function collapseNewOptionsSections() { if (!optionsPanelNew) return; const detailsNodes = optionsPanelNew.querySelectorAll('details'); detailsNodes.forEach((node) => { node.open = false; }); } // --- Views-Tabelle ----------------------------------------------------------- const VIEWS = { 'realtime': viewRealtime, 'split-view': viewSplit, 'quad-view': viewQuad, 'classic-needles': viewClassicNeedles, 'peak-history': viewPeakHistory, 'goniometer-rtw': viewGoni, 'phase-wheel': viewPhaseWheel, 'panel': viewPanel, 'spectrogram': viewSpectrogram, 'waveform': viewWaveform, 'recorder': viewRecorder, 'clock': viewClock, }; let currentView = null; let viewState = null; let currentViewId = null; let frameCounter = 0; let lastViewBeforeClock = null; function markCalibrationNoticeSeen() { calibrationNoticeSeen = true; try { localStorage.setItem(CALIBRATION_NOTICE_KEY, '1'); } catch (_) {} try { sessionStorage.setItem(CALIBRATION_NOTICE_KEY, '1'); } catch (_) {} } function hasSeenCalibrationNotice() { if (calibrationNoticeSeen) return true; let seen = false; try { seen = localStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {} if (!seen) { try { seen = sessionStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {} } calibrationNoticeSeen = seen; return seen; } function maybeShowCalibrationNotice() { if (!calibrationNotice || !calibrationNoticeBtn) return; if (hasSeenCalibrationNotice()) return; const close = () => { calibrationNotice.style.display = 'none'; markCalibrationNoticeSeen(); calibrationNoticeBtn.removeEventListener('click', close); calibrationNotice.removeEventListener('click', onBackdrop); }; const onBackdrop = (ev) => { if (ev.target === calibrationNotice) close(); }; calibrationNoticeBtn.addEventListener('click', close); calibrationNotice.addEventListener('click', onBackdrop); calibrationNotice.style.display = 'flex'; } function setView(id){ const fallbackId = DEFAULT_STYLE; const targetId = (id && VIEWS[id]) ? id : fallbackId; if (targetId !== 'split-view' && env?.splitPopup) { env.splitPopup.open = false; env.splitPopup.viewId = null; } if (targetId !== 'quad-view' && env?.quadPopup) { env.quadPopup.open = false; env.quadPopup.viewId = null; } if (targetId !== 'clock' && currentViewId === 'clock') { lastViewBeforeClock = null; } if (currentView && currentView.destroy){ try { currentView.destroy(viewState); } catch(e){ console.warn('View destroy error:', e); } } currentView = VIEWS[targetId] || viewGoni; currentViewId = targetId; viewState = currentView.init ? currentView.init(env) : {}; if (currentView.resize){ try { currentView.resize({ rect: env.rect }, viewState); } catch(e){ console.warn('View resize error:', e); } } maybeShowRecorderWarning(targetId); requestRender('set-view'); } function maybeShowRecorderWarning(targetId) { if (targetId !== 'recorder') return; if (!recWarning || !recWarningAck || !recWarningBtn) return; let seen = false; try { seen = localStorage.getItem(REC_WARNING_KEY) === '1'; } catch (_) {} if (seen) return; bindRecorderWarningHandlers(); recWarningAck.checked = false; recWarningBtn.disabled = true; recWarning.style.display = 'flex'; } function bindRecorderWarningHandlers() { if (!recWarningAck || !recWarningBtn || recWarningBtn.dataset.bound === '1') return; const syncBtn = () => { recWarningBtn.disabled = !recWarningAck.checked; }; recWarningAck.addEventListener('change', syncBtn); recWarningBtn.addEventListener('click', () => { if (!recWarningAck.checked) return; try { localStorage.setItem(REC_WARNING_KEY, '1'); } catch (_) {} recWarning.style.display = 'none'; }); syncBtn(); recWarningBtn.dataset.bound = '1'; } // --- Env --------------------------------------------------------------------- const audioState = { alive: false, backendMode: 'phoenix', backendLabel: 'Phoenix', baseUrl: null, nyq: 24000, sampleRate: 48000, getAnalyser: null, getFreqBuffer: null, getRtaData: () => audioState.rtaData, lastSampleTs: 0, lastSignalTs: (typeof performance !== 'undefined' ? performance.now() : Date.now()), xyL: null, xyR: null, xySeq: 0, rtaData: null, rmsDb: { L: -120, R: -120, mono: -120 }, updateRtaConfig: null, updatePpmConfig: null, updateInputOffset: null, updateMonoMode: null, updateLrDelay: null, updatePhoenixGlobalConfig: null, }; const env = { ctx: g, rect: { x:0, y:0, w:C.clientWidth, h:C.clientHeight }, config: CONFIG, utils: Object.assign({}, utils, { showErr, hideErr }), audio: audioState, screensaverWakeTs: 0, slots: (viewId) => getSlotsForStyle(viewId), getActiveMeterIds: () => getActiveMeterIds(getRenderableStyle()), getActivePlotIds: (viewId) => getActivePlotIds(viewId), getProcessingProfile: () => buildProcessingProfile(getRenderableStyle()), meters: null, invalidateMeters: () => { try { meterFacade.invalidateAll?.(); } catch (_) {} }, requestRender: () => {}, ui: { showErr, hideErr }, syncOptionsUI: () => { try { opts.syncUI?.(); } catch (_) {} }, recorder: recorderState, recorderDom: { hud: recorderHud, label: recLabel, start: recStartBtn, stop: recStopBtn, auto: recAutoBtn, list: recList, timer: recTimer }, screensaver, resetSlotsToDefaults: () => resetAllSlotsToDefaults(), syncConfigBackedSlots: () => { syncConfigBackedSlots(); refreshSlotEditors(); }, buildPresetUiState: () => buildPresetUiState(), splitPopup: popupStates.split, quadPopup: popupStates.quad, switchView: (id) => { const target = VALID_STYLES.has(id) ? id : DEFAULT_STYLE; if (styleSel) { styleSel.value = target; styleSel.dispatchEvent(new Event('change')); } else { style = target; setView(target); requestRender('switch-view'); } }, }; // Meter pointer routing (für interaktive Slot-Meter wie "Stoppuhr") const meterHitRects = []; const meters = { update: (...args) => meterFacade.update(...args), draw: async (ctx, rect, id, config) => { if (rect && id) meterHitRects.push({ rect: { ...rect }, id }); return meterFacade.draw(ctx, rect, id, config); }, pointer: (...args) => meterFacade.pointer?.(...args), getState: (...args) => meterFacade.getState?.(...args), }; env.meters = meters; C.addEventListener('pointerdown', async (e) => { if (!meterHitRects.length) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; for (let i = meterHitRects.length - 1; i >= 0; i--) { const hit = meterHitRects[i]; const rect = hit.rect; if (!rect) continue; if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) continue; const handled = await meterFacade.pointer?.( { type: 'pointerdown', x, y, button: e.button }, rect, hit.id, CONFIG, ); if (handled) { try { e.preventDefault(); } catch (_) {} try { C.setPointerCapture?.(e.pointerId); } catch (_) {} C.__meterPointerCapture = { id: hit.id, rect, pointerId: e.pointerId }; } return; } }, { passive: false }); C.addEventListener('pointermove', async (e) => { const cap = C.__meterPointerCapture; if (!cap) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; const handled = await meterFacade.pointer?.( { type: 'pointermove', x, y, pointerId: e.pointerId }, cap.rect, cap.id, CONFIG, ); if (handled) { try { e.preventDefault(); } catch (_) {} } }, { passive: false }); const clearMeterPointerCapture = (e) => { const cap = C.__meterPointerCapture; if (!cap) return; const r = C.getBoundingClientRect(); const x = e && Number.isFinite(e.clientX) ? (e.clientX - r.left) : NaN; const y = e && Number.isFinite(e.clientY) ? (e.clientY - r.top) : NaN; meterFacade.pointer?.( { type: e?.type === 'pointercancel' ? 'pointercancel' : 'pointerup', x, y, pointerId: e?.pointerId }, cap.rect, cap.id, CONFIG, ); try { C.releasePointerCapture?.(cap.pointerId); } catch (_) {} C.__meterPointerCapture = null; }; C.addEventListener('pointerup', clearMeterPointerCapture, { passive: true }); C.addEventListener('pointercancel', clearMeterPointerCapture, { passive: true }); // Quad-View: Tap/Click in Quadrant -> in Single-View wechseln let quadTap = null; function pointInRect(x, y, r) { if (!r) return false; return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; } function isInAnyMeterHitRect(x, y) { for (const hit of meterHitRects) { const r = hit?.rect; if (r && pointInRect(x, y, r)) return true; } return false; } // Split-View: Tap/Click in Linker/Rechter Plot -> in Single-View wechseln / Popup let splitTap = null; function handleSplitTapUp(x, y) { if (document?.body?.classList?.contains('screensaver-active')) return; if (isOptionsStyle(style) || currentViewId !== 'split-view') return; const hit = viewState?._splitHit; if (!hit) return; if (isInAnyMeterHitRect(x, y)) return; for (const mr of hit.meters || []) { if (pointInRect(x, y, mr)) return; } const plots = hit.plots; const left = plots?.left; const right = plots?.right; const target = (left && pointInRect(x, y, left)) ? left.viewId : ((right && pointInRect(x, y, right)) ? right.viewId : null); if (!target || target === 'none') return; const action = (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; if (action === 'popup') { if (env?.splitPopup?.open) { env.splitPopup.open = false; env.splitPopup.viewId = null; return; } env.splitPopup.open = true; env.splitPopup.viewId = target; return; } env.switchView?.(target); } function handleQuadTapUp(x, y) { if (document?.body?.classList?.contains('screensaver-active')) return; if (isOptionsStyle(style) || currentViewId !== 'quad-view') return; const hit = viewState?._quadHit; if (!hit) return; if (y < (hit.contentY ?? 0)) return; const action = (CONFIG.QUAD_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch'; if (action === 'popup') { if (env?.quadPopup?.open && hit.popupBox) { // Popup ist offen: Tap innerhalb/außerhalb schließt env.quadPopup.open = false; env.quadPopup.viewId = null; return; } if (isInAnyMeterHitRect(x, y)) return; for (const mr of hit.meters || []) { if (pointInRect(x, y, mr)) return; } const q = (hit.quadrants || []).find((it) => pointInRect(x, y, it?.rect)); const target = q?.viewId; if (!target || target === 'none') return; if (env?.quadPopup) { env.quadPopup.open = true; env.quadPopup.viewId = target; } return; } // Default: direkt wechseln if (isInAnyMeterHitRect(x, y)) return; for (const mr of hit.meters || []) { if (pointInRect(x, y, mr)) return; } const q = (hit.quadrants || []).find((it) => pointInRect(x, y, it?.rect)); const target = q?.viewId; if (!target || target === 'none') return; env.switchView?.(target); } C.addEventListener('pointerdown', (e) => { // Popup-Modus: Klick schließt sofort (Capture, damit Meter/Views nicht reagieren) if (document?.body?.classList?.contains('screensaver-active')) return; if (isOptionsStyle(style) || currentViewId !== 'split-view') return; if (CONFIG.SPLIT_VIEW_TAP_ACTION !== 'popup') return; if (!env?.splitPopup?.open) return; env.splitPopup.open = false; env.splitPopup.viewId = null; try { e.preventDefault?.(); } catch (_) {} try { e.stopPropagation?.(); } catch (_) {} }, { capture: true, passive: false }); C.addEventListener('pointerdown', (e) => { if (isOptionsStyle(style) || currentViewId !== 'split-view') return; if (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup' && env?.splitPopup?.open) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (isInAnyMeterHitRect(x, y)) return; splitTap = { pointerId: e.pointerId, x0: x, y0: y, maxD2: 0, t0: performance.now() }; }, { passive: true }); C.addEventListener('pointermove', (e) => { if (!splitTap || splitTap.pointerId !== e.pointerId) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; const dx = x - splitTap.x0; const dy = y - splitTap.y0; splitTap.maxD2 = Math.max(splitTap.maxD2, dx * dx + dy * dy); }, { passive: true }); C.addEventListener('pointerup', (e) => { if (!splitTap || splitTap.pointerId !== e.pointerId) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; const dt = performance.now() - splitTap.t0; const moved = splitTap.maxD2 > (10 * 10); splitTap = null; if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (moved || dt > 650) return; handleSplitTapUp(x, y); }, { passive: true }); C.addEventListener('pointerdown', (e) => { // Popup-Modus: Klick außerhalb schließt sofort (Capture, damit Meter/Quad nicht reagieren) if (document?.body?.classList?.contains('screensaver-active')) return; if (isOptionsStyle(style) || currentViewId !== 'quad-view') return; if (CONFIG.QUAD_VIEW_TAP_ACTION !== 'popup') return; if (!env?.quadPopup?.open) return; const hit = viewState?._quadHit; const box = hit?.popupBox; if (!box) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (y < (hit.contentY ?? 0)) return; if (pointInRect(x, y, box)) return; env.quadPopup.open = false; env.quadPopup.viewId = null; try { e.preventDefault?.(); } catch (_) {} try { e.stopPropagation?.(); } catch (_) {} }, { capture: true, passive: false }); C.addEventListener('pointerdown', (e) => { if (isOptionsStyle(style) || currentViewId !== 'quad-view') return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (isInAnyMeterHitRect(x, y)) return; quadTap = { pointerId: e.pointerId, x0: x, y0: y, maxD2: 0, t0: performance.now() }; }, { passive: true }); C.addEventListener('pointermove', (e) => { if (!quadTap || quadTap.pointerId !== e.pointerId) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; const dx = x - quadTap.x0; const dy = y - quadTap.y0; quadTap.maxD2 = Math.max(quadTap.maxD2, dx * dx + dy * dy); }, { passive: true }); C.addEventListener('pointerup', (e) => { if (!quadTap || quadTap.pointerId !== e.pointerId) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; const dt = performance.now() - quadTap.t0; const moved = quadTap.maxD2 > (10 * 10); quadTap = null; if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (moved || dt > 650) return; handleQuadTapUp(x, y); }, { passive: true }); C.addEventListener('wheel', async (e) => { if (!meterHitRects.length) return; const r = C.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; if (!Number.isFinite(x) || !Number.isFinite(y)) return; for (let i = meterHitRects.length - 1; i >= 0; i--) { const hit = meterHitRects[i]; const rect = hit.rect; if (!rect) continue; if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) continue; const handled = await meterFacade.pointer?.( { type: 'wheel', x, y, deltaY: e.deltaY, deltaX: e.deltaX }, rect, hit.id, CONFIG, ); if (handled) { try { e.preventDefault(); } catch (_) {} } return; } }, { passive: false }); ['mousemove','keydown','click','touchstart'].forEach((ev) => { addEventListener(ev, () => { screensaver?.markActivity?.(); void maybeRecoverAudio(`activity:${ev}`); }, { passive: true }); }); if (gainSel) { gainSel.addEventListener('change', () => { const raw = Number(gainSel.value); const val = Number.isFinite(raw) ? raw : 0; handleRealtimeGainSelection(val); }); } if (peakHistScrollSel) { peakHistScrollSel.addEventListener('change', () => { handlePeakHistScrollSelection(peakHistScrollSel.value); }); } if (resetPeakBtn) { resetPeakBtn.addEventListener('click', () => { try { if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') { window.resetRealTimeAnalyzerPeakHold(); } } catch (_) {} }); } if (waveWindowSel) { waveWindowSel.addEventListener('change', () => { handleWaveWindowSelection(Number(waveWindowSel.value)); }); } if (waveModeSel) { waveModeSel.addEventListener('change', () => { handleWaveModeSelection(waveModeSel.value); }); } if (rtaBpoSel) { rtaBpoSel.addEventListener('change', () => { handleRtaBpoSelection(rtaBpoSel.value); }); } if (recTargetSel) { recTargetSel.addEventListener('change', () => { recorderState.target = CONFIG.RECORD_TARGET || ((recTargetSel.value === 'analyzer' || recTargetSel.value === 'volumio') ? recTargetSel.value : 'download'); CONFIG.RECORD_TARGET = recorderState.target; if (recorderState.target === 'analyzer' || recorderState.target === 'volumio') { CONFIG.RECORD_PI_TARGET = recorderState.target; } try { saveConfig(); } catch (_) {} updateRecorderHud(); }); } if (recStartBtn) { recStartBtn.addEventListener('click', () => { void startRecording(); }); } if (recStopBtn) { recStopBtn.addEventListener('click', () => { stopRecording(); }); } if (recAutoBtn) { recAutoBtn.addEventListener('click', () => { setRecorderAutoEnabled(!recorderState.autoEnabled); }); } updateRecorderHud(); function handleRealtimeGainSelection(value){ let val = Number(value); if (!Number.isFinite(val)) val = 0; CONFIG.RTA_DISPLAY_GAIN_IIR_DB = val; saveConfig(); if (gainSel) { const setVal = REALTIME_GAIN_CHOICES.includes(val) ? val : 0; gainSel.value = String(setVal); } const slider = document.getElementById('opt_rtaDisplayGainIir'); if (slider) slider.value = val; const label = document.getElementById('val_rtaDisplayGainIir'); if (label) label.textContent = `${val} dB`; try { env.audio.updateRtaConfig?.(); } catch (_) {} } function handleWaveWindowSelection(value){ const nearest = getNearestWaveWindow(value); CONFIG.WAVEFORM_WINDOW_SEC = nearest; saveConfig(); if (waveWindowSel) { waveWindowSel.value = String(nearest); } const input = document.getElementById('opt_waveWindow'); if (input) { input.value = nearest; } } function handleWaveModeSelection(value){ const mode = normalizeWaveMode(value); CONFIG.WAVEFORM_MODE = mode; saveConfig(); if (waveModeSel) { waveModeSel.value = mode; } const select = document.getElementById('opt_waveMode'); if (select) { select.value = mode; } } function handlePeakHistScrollSelection(value){ const val = normalizePeakHistScroll(value); CONFIG.PEAK_HISTORY_SCROLL_MODE = val; saveConfig(); if (peakHistScrollSel) { peakHistScrollSel.value = String(val); } const optSel = document.getElementById('opt_peakHistScroll'); if (optSel) optSel.value = String(val); } function handleRtaBpoSelection(value){ const mode = normalizeRtaBpo(value); applyRtaBpoSelection(mode); saveConfig(); if (rtaBpoSel) { rtaBpoSel.value = mode; } const select = document.getElementById('opt_rtaBpo'); if (select) { select.value = mode; } try { env.audio.updateRtaConfig?.(); } catch (_) {} try { env.audio.updatePhoenixGlobalConfig?.(); } catch (_) {} } function getNearestWaveWindow(value){ const num = Number(value); const target = Math.max(0.05, Math.min(15, Number.isFinite(num) ? num : (CONFIG.WAVEFORM_WINDOW_SEC || 1))); return WAVE_WINDOW_CHOICES.reduce((prev, curr) => { return Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev; }, WAVE_WINDOW_CHOICES[0]); } function normalizeWaveMode(value){ if (value === 'overlay') return 'overlay'; if (value === 'diff') return 'diff'; return 'stacked'; } function normalizeRtaBpo(value){ const val = typeof value === 'string' ? value : ''; return RTA_BPO_CHOICES.some(([key]) => key === val) ? val : '1_6'; } function normalizePeakHistScroll(value){ const num = Number(value); const found = PEAK_HISTORY_SCROLL_CHOICES.find((v) => Math.abs(v - num) < 1e-9); return found ?? 1; } function formatTimerFull(ms){ if (!Number.isFinite(ms)) return '00:00:00:00'; const totalHund = Math.max(0, Math.floor(ms / 10)); const hund = totalHund % 100; const totalSec = Math.floor(totalHund / 100); const sec = totalSec % 60; const totalMin = Math.floor(totalSec / 60); const min = totalMin % 60; const hrs = Math.floor(totalMin / 60); const two = (n) => String(n).padStart(2, '0'); return `${two(hrs)}:${two(min)}:${two(sec)}:${two(hund)}`; } function stopRecorderTimer() { if (recorderState.timerRaf) { cancelAnimationFrame(recorderState.timerRaf); recorderState.timerRaf = null; } } function stopRecorderAutoMonitor() { if (recorderState.autoMonitorRaf) { cancelAnimationFrame(recorderState.autoMonitorRaf); recorderState.autoMonitorRaf = null; } } function tickRecorderAuto() { if (!recorderState.autoEnabled) { stopRecorderAutoMonitor(); return; } const now = performance.now(); const level = Number.isFinite(env?.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120; const hasSignal = level > getRecorderAutoThresholdDbfs(); const busy = recorderState.status === 'starting'; const recording = recorderState.status === 'recording' && !!recorderState.activeSession; if (recording) { if (hasSignal) { recorderState.autoSilenceSince = 0; } else if (!recorderState.autoPendingStop) { if (!recorderState.autoSilenceSince) recorderState.autoSilenceSince = now; if ((now - recorderState.autoSilenceSince) >= getRecorderAutoSplitGapMs()) { recorderState.autoPendingStop = true; stopRecording({ keepAuto: true }); } } } else if (!busy) { recorderState.autoSilenceSince = 0; if (hasSignal && !recorderState.autoPendingStart) { if (canStartRecorderWithoutInputStream()) { recorderState.autoPendingStart = true; void startRecording({ keepAuto: true }); } } } recorderState.autoMonitorRaf = requestAnimationFrame(tickRecorderAuto); } function setRecorderAutoEnabled(enabled) { const next = !!enabled; recorderState.autoEnabled = next; recorderState.autoSilenceSince = 0; recorderState.autoPendingStart = false; recorderState.autoPendingStop = false; if (next) { if (!recorderState.autoMonitorRaf) { recorderState.autoMonitorRaf = requestAnimationFrame(tickRecorderAuto); } } else { stopRecorderAutoMonitor(); } updateRecorderHud(); } function tickRecorderTimer() { if (!recorderState || recorderState.status !== 'recording') return; const now = performance.now(); const durationMs = now - (recorderState.startTs || now); if (recTimer) recTimer.innerHTML = formatTimerFull(durationMs); recorderState.timerRaf = requestAnimationFrame(tickRecorderTimer); } function getRecordingExtension(mimeType = '') { const type = String(mimeType || '').toLowerCase(); if (type.includes('ogg')) return 'ogg'; if (type.includes('mpeg')) return 'mp3'; if (type.includes('wav')) return 'wav'; return 'webm'; } function canUseNativeWavRecorder() { return typeof env?.audio?.startWavCapture === 'function' && typeof env?.audio?.stopWavCapture === 'function'; } function canStartRecorderWithoutInputStream() { return canUseNativeWavRecorder(); } async function finalizeRecordingSession(session, resultPromise) { const fallbackType = session?.mimeType || 'audio/webm'; let finalBlob = null; let downloadName = ''; let sessionError = ''; try { const result = await resultPromise; finalBlob = result?.blob || null; sessionError = result?.error || ''; downloadName = result?.downloadName || (finalBlob ? buildRecordingFilename(getRecordingExtension(result?.mimeType || finalBlob.type || fallbackType)) : ''); if (!sessionError && !finalBlob) sessionError = 'Aufnahme konnte nicht erstellt werden'; if (!sessionError && finalBlob) { addRecordingToHistory(finalBlob, downloadName); } } catch (e) { sessionError = e?.message || 'Aufnahme konnte nicht erstellt werden'; } recorderState.lastDurationMs = Math.max(0, performance.now() - session.startTs); recorderState.processingRecorderSlots = recorderState.processingRecorderSlots.filter((slot) => slot !== session.slot); recorderState.processingJobs = Math.max(0, recorderState.processingJobs - 1); recorderState.error = sessionError || recorderState.error; if (recorderState.activeSession?.id !== session.id) { recorderState.status = recorderState.error ? 'error' : (recorderState.activeSession ? 'recording' : (recorderState.processingJobs > 0 ? 'processing' : 'stopped')); updateRecorderHud(); } if (!recorderState.activeSession) { stopRecorderTimer(); } recorderState.autoPendingStart = false; recorderState.autoPendingStop = false; if (!recorderState.activeSession) { recorderState.status = recorderState.error ? 'error' : (recorderState.processingJobs > 0 ? 'processing' : 'stopped'); } updateRecorderHud(); } function updateRecorderHud(){ const desiredTarget = (CONFIG.RECORD_TARGET === 'analyzer' || CONFIG.RECORD_TARGET === 'volumio') ? CONFIG.RECORD_TARGET : 'download'; recorderState.target = desiredTarget; if (recTargetSel && recTargetSel.value !== recorderState.target) { recTargetSel.value = recorderState.target; } const recording = recorderState.status === 'recording' && !!recorderState.activeSession; const processing = !recording && recorderState.processingJobs > 0; const busy = recorderState.status === 'starting'; if (recLabel) { recLabel.classList.toggle('blink', recording); } if (recStartBtn) recStartBtn.disabled = recording || busy || recorderState.autoEnabled || (!recorderState.autoEnabled && processing); if (recStopBtn) recStopBtn.disabled = !recording && !recorderState.autoEnabled; if (recAutoBtn) { recAutoBtn.disabled = busy; recAutoBtn.classList.toggle('btn-active', recorderState.autoEnabled && !recording); recAutoBtn.classList.toggle('rec-auto-armed', recorderState.autoEnabled && !recording); recAutoBtn.classList.toggle('rec-auto-recording', recorderState.autoEnabled && recording); recAutoBtn.textContent = recorderState.autoEnabled ? 'Auto AN' : 'Auto'; } if (recProcessing) recProcessing.style.display = (processing && !recorderState.autoEnabled) ? 'flex' : 'none'; const durationMs = recording ? (performance.now() - (recorderState.startTs || performance.now())) : recorderState.lastDurationMs || 0; if (recTimer) { recTimer.innerHTML = formatTimerFull(durationMs); } renderRecordingList(); } function buildRecordingFilename(ext = 'webm') { const ts = new Date(); const stamp = ts.toISOString().replace(/[:.]/g, '-'); return `recording-${stamp}.${ext}`; } async function startRecording(opts = {}){ const allowParallelFinalize = !!opts.keepAuto; if (recorderState.activeSession || recorderState.status === 'starting') return; if (!allowParallelFinalize && recorderState.processingJobs > 0) return; if (!opts.keepAuto && recorderState.autoEnabled) { setRecorderAutoEnabled(false); } stopRecorderTimer(); recorderState.status = 'starting'; recorderState.error = ''; const rawOutFormat = String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav').toLowerCase(); const outFormat = rawOutFormat === 'webm' ? 'webm' : (rawOutFormat === 'mp3' ? 'mp3' : 'wav'); if (!canStartRecorderWithoutInputStream()) { recorderState.error = 'Kein Audio-Stream verfügbar'; recorderState.status = 'idle'; recorderState.autoPendingStart = false; updateRecorderHud(); showErr('Recorder: Kein Audio-Stream verfügbar (Audio starten?)'); return; } const session = { id: recorderState.nextSessionId++, kind: outFormat, recorder: null, mimeType: outFormat === 'mp3' ? 'audio/mpeg' : (outFormat === 'wav' ? 'audio/wav' : 'audio/webm'), chunks: [], startTs: performance.now(), slot: allowParallelFinalize ? recorderState.nextRecorderSlot : 'A', }; if (allowParallelFinalize) { recorderState.nextRecorderSlot = (session.slot === 'A') ? 'B' : 'A'; } else { recorderState.nextRecorderSlot = 'B'; } recorderState.activeSession = session; recorderState.activeRecorderSlot = session.slot; recorderState.lastDurationMs = 0; try { await env.audio.startWavCapture(session.id); } catch (e) { if (recorderState.activeSession?.id === session.id) { recorderState.activeSession = null; recorderState.activeRecorderSlot = null; } recorderState.error = e?.message || 'Phoenix-Recorder nicht verfügbar'; recorderState.status = 'error'; recorderState.autoPendingStart = false; updateRecorderHud(); return; } recorderState.startTs = session.startTs; recorderState.status = 'recording'; recorderState.autoPendingStart = false; recorderState.autoSilenceSince = 0; updateRecorderHud(); tickRecorderTimer(); } function stopRecording(opts = {}){ if (!opts.keepAuto && recorderState.autoEnabled) { setRecorderAutoEnabled(false); } const session = recorderState.activeSession; if ((session?.kind === 'wav' || session?.kind === 'mp3' || session?.kind === 'webm') && recorderState.status === 'recording') { recorderState.processingJobs += 1; if (!recorderState.processingRecorderSlots.includes(session.slot)) { recorderState.processingRecorderSlots = [...recorderState.processingRecorderSlots, session.slot]; } recorderState.activeSession = null; recorderState.activeRecorderSlot = null; recorderState.status = recorderState.processingJobs > 0 ? 'processing' : 'stopped'; updateRecorderHud(); const finalizePromise = env.audio.stopWavCapture(session.id, session.kind, { mp3BitrateKbps: Number(CONFIG.RECORD_MP3_BITRATE_KBPS) || 192, }).then((capture) => ({ blob: capture?.blob || null, mimeType: capture?.mimeType || 'audio/wav', downloadName: buildRecordingFilename(getRecordingExtension(capture?.mimeType || capture?.blob?.type || session.mimeType || 'audio/wav')), })); void finalizeRecordingSession(session, finalizePromise); } recorderState.autoPendingStart = false; stopRecorderTimer(); } function addRecordingToHistory(blob, name) { if (!blob) return; const url = URL.createObjectURL(blob); recorderState.history = recorderState.history || []; const entry = { name, url, downloadRequested: false, released: false, saved: false, saving: false, savedPath: '' }; recorderState.history.unshift(entry); while (recorderState.history.length > RECORDER_HISTORY_LIMIT) { const old = recorderState.history.pop(); try { if (old?.url) URL.revokeObjectURL(old.url); } catch (_) {} } if (CONFIG.RECORD_AUTO_DOWNLOAD === true) { void saveRecordingEntry(entry, { rerender: false }).finally(() => { renderRecordingList(); }); } renderRecordingList(); } function removeRecordingFromHistory(entry) { if (!recorderState.history) return; recorderState.history = recorderState.history.filter((e) => e !== entry); try { if (entry?.url) URL.revokeObjectURL(entry.url); } catch (_) {} if (recorderState.history.length === 0) { recorderState.lastDurationMs = 0; stopRecorderTimer(); if (recTimer) recTimer.innerHTML = formatTimerFull(0); } renderRecordingList(); } function sanitizeGoniGain(val){ let num = Number(val); if (!Number.isFinite(num)) num = 0; if (num > GONIO_GAIN_MAX_DB) num = GONIO_GAIN_MAX_DB; if (num < GONIO_GAIN_MIN_DB) num = GONIO_GAIN_MIN_DB; return Math.round(num / 5) * 5; } function formatWaveWindowLabel(seconds){ if (seconds >= 1) { return `${seconds} s`; } return `${Math.round(seconds * 1000)} ms`; } function updateRect(){ const w = C.clientWidth; const h = C.clientHeight; if (w === prevCanvasWidth && h === prevCanvasHeight) return; prevCanvasWidth = w; prevCanvasHeight = h; env.rect.w = w; env.rect.h = h; if (currentView && currentView.resize){ try { currentView.resize({ rect: env.rect }, viewState); } catch(e){ console.warn('View resize error:', e); } } } // Jetzt, wo env existiert: auf Window-Resize auch die View aktualisieren addEventListener('resize', () => { try { updateRect(); } catch (e) { console.warn('Deferred resize update error:', e); } }); // --- Audio ------------------------------------------------------------------- function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function bootAudio(force = false){ const retryPlan = force ? [0, 1500, 3500, 7000] : [0, 1500, 3500, 7000]; let ok = false; for (let i = 0; i < retryPlan.length; i++) { const waitMs = retryPlan[i]; if (waitMs > 0) await delay(waitMs); ok = force ? await reloadAudio(env) : await initAudio(env); if (ok) break; console.warn(`Audio init attempt ${i + 1}/${retryPlan.length} failed`); } if (!ok) showErr('Audio init fehlgeschlagen.'); else hideErr(); return ok; } // --- Hintergrund / Lost Overlay --------------------------------------------- function drawBG(){ const bg = getCachedBackgroundLayer(); if (bg) { g.drawImage(bg, 0, 0, C.clientWidth, C.clientHeight); return; } g.fillStyle = 'rgba(5,5,11,0.75)'; g.fillRect(0,0,C.clientWidth,C.clientHeight); } let backgroundLayerCanvas = null; let backgroundLayerKey = ''; function getCachedBackgroundLayer() { const w = C.clientWidth | 0; const h = C.clientHeight | 0; if (w <= 0 || h <= 0) return null; const key = `${w}x${h}`; if (backgroundLayerCanvas && backgroundLayerKey === key) { return backgroundLayerCanvas; } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) return null; ctx.fillStyle = 'rgba(5,5,11,0.75)'; ctx.fillRect(0, 0, w, h); backgroundLayerCanvas = canvas; backgroundLayerKey = key; return backgroundLayerCanvas; } function drawAudioLostOverlay(){ if (!audioLost(env)) return; const backendLabel = String(env.audio?.backendLabel || 'Audio'); g.save(); g.fillStyle = 'rgba(0,0,0,0.55)'; g.fillRect(0,0,C.clientWidth,C.clientHeight); g.fillStyle = '#ffd6d6'; g.textAlign = 'center'; g.font = 'bold 18px ui-monospace, monospace'; g.fillText(`${backendLabel} lost – Quelle prüfen`, C.clientWidth/2, C.clientHeight/2); g.textAlign = 'start'; g.restore(); } function drawOptionsPanelBackdrop(){ if (style !== 'options-panel') return; g.save(); g.fillStyle = 'rgba(8, 10, 18, 0.5)'; g.fillRect(0,0,C.clientWidth,C.clientHeight); g.restore(); } // --- Loop (FPS-Kontrolle) ---------------------------------------------------- function desiredFpsForStyle(renderStyle) { return 60; } let lastFrameTime = 0; let prevCanvasWidth = 0; let prevCanvasHeight = 0; let prevAudioLost = null; let prevSaverActive = false; let audioRecoverInFlight = false; let lastAudioRecoverAt = 0; let renderDirty = true; let lastRenderedAudioSeq = 0; function requestRender(reason = 'ui') { renderDirty = true; env.__lastRenderReason = reason; } env.requestRender = requestRender; async function maybeRecoverAudio(reason) { if (audioRecoverInFlight) return false; if (document.hidden) return false; if (!audioLost(env)) return false; const now = performance.now(); if (now - lastAudioRecoverAt < 5000) return false; lastAudioRecoverAt = now; audioRecoverInFlight = true; try { await new Promise((r) => setTimeout(r, 800)); if (audioLost(env)) { console.warn(`Audio lost after ${reason}; reloading audio…`); await bootAudio(true); } return true; } catch (e) { console.warn('Audio recover failed:', e); return false; } finally { audioRecoverInFlight = false; } } async function loop(now){ const elapsed = now - lastFrameTime; const audioOk = env.audio.alive && !audioLost(env); const renderStyle = getRenderableStyle(); try { env.audio?.updateProcessingConfig?.(buildProcessingProfile(renderStyle)); } catch (_) {} const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20); const target = targetMs; const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0; const audioDirty = currentAudioSeq > lastRenderedAudioSeq; const shouldRenderFrame = renderDirty || audioDirty || !audioOk || style === 'options-panel' || style === 'recorder' || renderStyle === 'clock'; if (elapsed >= target){ lastFrameTime = now - (elapsed % targetMs); try{ updateRect(); meterHitRects.length = 0; const lostNow = audioLost(env); if (lostNow) { // Don't wait for an edge transition; after long idle the app may already be in a lost state. void maybeRecoverAudio('lost'); } prevAudioLost = lostNow; const saverActive = screensaver.updateAndRender(g, env.rect, now, elapsed); if (prevSaverActive && !saverActive) { env.screensaverWakeTs = now; requestRender('screensaver-wake'); void maybeRecoverAudio('screensaver-wake'); } prevSaverActive = saverActive; if (!saverActive && shouldRenderFrame) { meterFacade.setFrameStamp?.(++frameCounter); drawBG(); if (!currentView || currentViewId !== renderStyle) setView(renderStyle); if (currentView.render) await currentView.render(env, viewState); drawOptionsPanelBackdrop(); drawAudioLostOverlay(); renderDirty = false; lastRenderedAudioSeq = currentAudioSeq; } } catch(e){ showErr('Render error: ' + (e?.message || String(e))); console.error('Render loop error:', e); } } requestAnimationFrame(loop); } // --- Start ------------------------------------------------------------------- (async function boot(){ try{ const start = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)); const layoutParam = (() => { try { return new URLSearchParams(globalThis?.location?.search || '').get('layout') || ''; } catch (_) { return ''; } })(); if (layoutParam) { try { const result = await loadLayoutPreset(layoutParam); if (result) { applyPresetUiState(result?.uiState || result?.snapshot?.__PHOENIX_UI_STATE || null); const rawStyle = String(result?.uiState?.style || CONFIG.UI_LAST_STYLE || ''); const fallbackStyle = String(result?.uiState?.lastNonOptionStyle || CONFIG.UI_LAST_NON_OPTION_STYLE || DEFAULT_STYLE); const targetStyle = (rawStyle === 'options-panel' || rawStyle === 'options') ? fallbackStyle : (rawStyle || fallbackStyle); if (VALID_STYLES.has(targetStyle)) { applyStyleSelection(targetStyle, { persistStorage: true, persistConfig: true }); } } } catch (err) { console.warn('Layout preset load error:', err); } } await bootAudio(); applyStyleSelection(style, { persistStorage: false, persistConfig: false }); requestRender('boot'); requestAnimationFrame(loop); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { await start(); } saveSlotState(slotsState); } catch(e){ showErr('Boot error: ' + (e?.message || String(e))); console.error('Boot error:', e); } })();