Files
Phoenix/www/main.js
T

2742 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// 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 (LR)'],
];
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 35s 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)}:<span class="hund">${two(hund)}</span>`;
}
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);
}
})();