// core/audio.js — Phoenix Audio-Init, Metrics-WS, Waveform/Spectrogram buffers // Wird von main.js mit `initAudio(env)` gestartet. Nutzt env.config (CONFIG) und env.meters. import { applyPhoenixGlobalConfig, buildPhoenixGlobalConfigPayload, saveConfig } from './config.js'; import { getRtwCenters } from './rtw_centers.js'; // interner Zustand (pro App-Instanz) let phoenixSocket = null; let envRef = null; let lifecycleHandlersBound = false; let recoverTimer = null; let lastHardRecoverAt = 0; const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 }; const WAVEFORM_RING_SECONDS = 20; const WAVEFORM_FALLBACK_SECONDS = 18; const WAVE_ENV_COLUMNS_PER_SEC = 9600; const PHOENIX_CONNECT_TIMEOUT_MS = 3000; function isLoopbackHost(host) { const h = String(host || '').trim().toLowerCase(); return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]'; } function defaultPhoenixBaseUrl() { try { const host = String(globalThis?.location?.hostname || '').trim(); if (host) return `http://${host}:8789`; } catch (_) {} return 'http://127.0.0.1:8789'; } function normalizePhoenixBaseUrl(rawValue) { const fallback = defaultPhoenixBaseUrl(); let value = String(rawValue || '').trim(); if (!value) value = fallback; if (!/^[a-z]+:\/\//i.test(value)) value = `http://${value}`; try { const url = new URL(value); const currentHost = String(globalThis?.location?.hostname || '').trim(); if (currentHost && !isLoopbackHost(currentHost) && isLoopbackHost(url.hostname)) { url.hostname = currentHost; } url.pathname = ''; url.search = ''; url.hash = ''; return url.toString().replace(/\/$/, ''); } catch (_) { return fallback; } } function buildPhoenixWsUrl(baseUrl) { try { const url = new URL(baseUrl); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; url.pathname = '/api/v1/metrics/ws'; url.search = ''; url.hash = ''; return url.toString(); } catch (_) { const fallback = defaultPhoenixBaseUrl(); return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/api/v1/metrics/ws'; } } function closePhoenixSocket() { if (!phoenixSocket) return; try { phoenixSocket.onopen = null; phoenixSocket.onmessage = null; phoenixSocket.onerror = null; phoenixSocket.onclose = null; phoenixSocket.close(); } catch (_) {} phoenixSocket = null; } async function requestPhoenixRtaConfig(baseUrl, config) { const normalizedBase = normalizePhoenixBaseUrl(baseUrl); const url = `${normalizedBase}/api/v1/rta-config`; try { await fetch(url, { method: 'POST', cache: 'no-store', headers: { 'content-type': 'application/json' }, body: JSON.stringify(config || {}), }); } catch (err) { console.warn('Phoenix RTA config update failed:', err); } } async function requestPhoenixGlobalConfig(baseUrl) { const normalizedBase = normalizePhoenixBaseUrl(baseUrl); const url = `${normalizedBase}/api/v1/global-config`; const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`Phoenix global config failed: ${res.status}`); return await res.json(); } async function requestPhoenixGlobalConfigUpdate(baseUrl, config) { const normalizedBase = normalizePhoenixBaseUrl(baseUrl); const url = `${normalizedBase}/api/v1/global-config`; const res = await fetch(url, { method: 'POST', cache: 'no-store', headers: { 'content-type': 'application/json' }, body: JSON.stringify(config || {}), }); if (!res.ok) throw new Error(`Phoenix global config update failed: ${res.status}`); return await res.json(); } async function requestPhoenixWavCaptureStart(baseUrl, sessionId) { const normalizedBase = normalizePhoenixBaseUrl(baseUrl); const url = `${normalizedBase}/api/v1/recordings/wav/start/${encodeURIComponent(Number(sessionId) || 0)}`; const res = await fetch(url, { method: 'POST', cache: 'no-store' }); if (!res.ok) throw new Error(`Phoenix WAV start failed: ${res.status}`); } async function requestPhoenixWavCaptureStop(baseUrl, sessionId, format = 'wav', options = {}) { const normalizedBase = normalizePhoenixBaseUrl(baseUrl); const rawFormat = String(format || '').toLowerCase(); const normalizedFormat = rawFormat === 'mp3' ? 'mp3' : (rawFormat === 'webm' ? 'webm' : 'wav'); const url = new URL(`${normalizedBase}/api/v1/recordings/stop/${encodeURIComponent(Number(sessionId) || 0)}/${encodeURIComponent(normalizedFormat)}`); if (normalizedFormat === 'mp3') { const bitrate = Number(options?.mp3BitrateKbps); if (Number.isFinite(bitrate) && bitrate > 0) { url.searchParams.set('bitrate_kbps', String(Math.round(bitrate))); } } const res = await fetch(url.toString(), { method: 'POST', cache: 'no-store' }); if (!res.ok) { let detail = ''; try { const payload = await res.json(); detail = String(payload?.error || '').trim(); } catch (_) {} throw new Error(detail || `Phoenix capture stop failed: ${res.status}`); } const blob = await res.blob(); return { blob, mimeType: blob.type || 'audio/wav', }; } function applyRmsActivity(env, rmsL, rmsR, sampleTs) { if (!env?.audio) return; const level = Math.max( Number.isFinite(rmsL) ? rmsL : -120, Number.isFinite(rmsR) ? rmsR : -120, ); env.audio.rmsDb = { L: Number.isFinite(rmsL) ? rmsL : -120, R: Number.isFinite(rmsR) ? rmsR : -120, }; const linL = Math.pow(10, (env.audio.rmsDb.L || -120) / 20); const linR = Math.pow(10, (env.audio.rmsDb.R || -120) / 20); const monoLin = Math.sqrt((linL * linL + linR * linR) / 2); env.audio.rmsDb.mono = 20 * Math.log10(Math.max(monoLin, 1e-12)); const hit = env.screensaver?.markAudioActivity?.(level, sampleTs); if (hit) env.audio.lastSignalTs = sampleTs; } function copyPhoenixSpectroBins(audioState, spectro) { if (!audioState) return; const src = Array.isArray(spectro?.bins) ? spectro.bins : null; if (!src || !src.length) { audioState.phoenixSpectroBuffer = null; audioState.phoenixSpectroMeta = null; return; } let target = audioState.phoenixSpectroBuffer; if (!(target instanceof Float32Array) || target.length !== src.length) { target = new Float32Array(src.length); audioState.phoenixSpectroBuffer = target; } for (let i = 0; i < src.length; i++) { const v = Number(src[i]); target[i] = Number.isFinite(v) ? v : -160; } const sampleRate = Number(spectro?.sampleRate) || audioState.sampleRate || 48000; audioState.sampleRate = sampleRate; audioState.nyq = sampleRate / 2; const fftSize = Number(spectro?.fftSize) || (target.length * 2); audioState.phoenixSpectroMeta = { sampleRate, fftSize, frequencyBinCount: target.length, }; audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + 1; } async function updateActiveMeters(env, packet, CONFIG) { try { const activeRaw = typeof env.getActiveMeterIds === 'function' ? env.getActiveMeterIds() : env.slots?.(); const normalized = Array.from(new Set( (Array.isArray(activeRaw) ? activeRaw : ['vu', 'ppm-ebu', 'tp']) .filter((id) => id && id !== 'none'), )); const active = normalized.length ? normalized : ['vu', 'ppm-ebu', 'tp']; const maybePromise = env.meters.update(packet, CONFIG, active); if (maybePromise && typeof maybePromise.then === 'function') { await maybePromise; } } catch (e) { console.warn('Meter update error:', e); } } async function syncPhoenixGlobalConfig(env, revision) { if (!env?.audio || !Number.isFinite(revision) || revision <= 0) return; if ((env.audio.phoenixGlobalConfigRev || 0) >= revision) return; const baseUrl = normalizePhoenixBaseUrl(env.config?.PHOENIX_BASE_URL || env.audio.baseUrl); const payload = await requestPhoenixGlobalConfig(baseUrl); if (payload?.config) { applyPhoenixGlobalConfig(payload.config); saveConfig(); try { env.syncConfigBackedSlots?.(); } catch (_) {} try { env.syncOptionsUI?.(); } catch (_) {} try { env.invalidateMeters?.(); } catch (_) {} } env.audio.phoenixGlobalConfigRev = Number(payload?.revision) || revision; } async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performance.now()) { const d = packet || {}; if (!env?.audio) return; env.requestRender?.('audio'); env.audio.lastSampleTs = sampleTs; env.audio.alive = true; if (d.xyL && d.xyR) { env.audio.xyL = d.xyL; env.audio.xyR = d.xyR; env.audio.xySeq = Number.isFinite(d.seq) ? d.seq : (env.audio.xySeq || 0); } if (d.waveL && env.audio.pushWaveSamples) { const channelCount = d.waveChannels || (d.waveR ? 2 : 1); env.audio.sampleRate = d.sampleRate || env.audio.sampleRate || 48000; env.audio.pushWaveSamples(d.waveL, d.waveR || null, channelCount, d.sampleRate); } if (d.rta) env.audio.rtaData = d.rta; if (d.spectro) copyPhoenixSpectroBins(env.audio, d.spectro); if (typeof d.ppmDinL === 'number') env.audio.ppmDinL = d.ppmDinL; if (typeof d.ppmDinR === 'number') env.audio.ppmDinR = d.ppmDinR; if (typeof d.ppmEbuL === 'number') env.audio.ppmEbuL = d.ppmEbuL; if (typeof d.ppmEbuR === 'number') env.audio.ppmEbuR = d.ppmEbuR; if (typeof d.ppmL === 'number') env.audio.ppmL = d.ppmL; if (typeof d.ppmR === 'number') env.audio.ppmR = d.ppmR; if (typeof d.rmsL === 'number' || typeof d.rmsR === 'number') { applyRmsActivity(env, d.rmsL, d.rmsR, sampleTs); } if (d.waveEnv) { updateWaveformEnvelopeStore(env.audio, d.waveEnv); } if (Number.isFinite(d.globalConfigRev) && d.globalConfigRev > (env.audio.phoenixGlobalConfigRev || 0)) { try { await syncPhoenixGlobalConfig(env, d.globalConfigRev); } catch (err) { console.warn('Phoenix global config sync failed:', err); } } if (d.rmsL !== undefined && d.rmsR !== undefined) { RMS_RING.L[RMS_RING.i] = d.rmsL; RMS_RING.R[RMS_RING.i] = d.rmsR; RMS_RING.i = (RMS_RING.i + 1) % RMS_RING.L.length; RMS_RING.n = Math.min(RMS_RING.L.length, RMS_RING.n + 1); } await updateActiveMeters(env, d, CONFIG); } function buildPhoenixMeterPacket(frame) { const rmsL = Number(frame?.rms_l); const rmsR = Number(frame?.rms_r); const vuL = Number(frame?.vu_l); const vuR = Number(frame?.vu_r); const tpL = Number(frame?.tp_l); const tpR = Number(frame?.tp_r); const ppmDinL = Number(frame?.ppm_din_l); const ppmDinR = Number(frame?.ppm_din_r); const ppmEbuL = Number(frame?.ppm_ebu_l); const ppmEbuR = Number(frame?.ppm_ebu_r); const lufsM = Number(frame?.lufs_m); const lufsS = Number(frame?.lufs_s); const lufsI = Number(frame?.lufs_i); const lra = Number(frame?.lra); const lufsML = Number(frame?.lufs_ml); const lufsMR = Number(frame?.lufs_mr); const lufsSL = Number(frame?.lufs_sl); const lufsSR = Number(frame?.lufs_sr); const ppmBoxL = Number(frame?.ppm_box_l); const ppmBoxR = Number(frame?.ppm_box_r); const xyL = Array.isArray(frame?.xy_l) ? frame.xy_l : null; const xyR = Array.isArray(frame?.xy_r) ? frame.xy_r : null; const waveL = Array.isArray(frame?.wave_l) && frame.wave_l.length ? frame.wave_l : null; const waveR = Array.isArray(frame?.wave_r) && frame.wave_r.length ? frame.wave_r : null; const rta = frame?.rta && typeof frame.rta === 'object' ? frame.rta : null; const spectro = frame?.spectro && typeof frame.spectro === 'object' ? frame.spectro : null; const waveEnv = frame?.wave_env && typeof frame.wave_env === 'object' ? frame.wave_env : null; return { sampleRate: 48000, rmsL: Number.isFinite(rmsL) ? rmsL : -120, rmsR: Number.isFinite(rmsR) ? rmsR : -120, tpL: Number.isFinite(tpL) ? tpL : -120, tpR: Number.isFinite(tpR) ? tpR : -120, ppmDinL: Number.isFinite(ppmDinL) ? ppmDinL : -120, ppmDinR: Number.isFinite(ppmDinR) ? ppmDinR : -120, ppmEbuL: Number.isFinite(ppmEbuL) ? ppmEbuL : (Number.isFinite(ppmDinL) ? ppmDinL : -120), ppmEbuR: Number.isFinite(ppmEbuR) ? ppmEbuR : (Number.isFinite(ppmDinR) ? ppmDinR : -120), ppmL: Number.isFinite(ppmEbuL) ? ppmEbuL : (Number.isFinite(ppmDinL) ? ppmDinL : -120), ppmR: Number.isFinite(ppmEbuR) ? ppmEbuR : (Number.isFinite(ppmDinR) ? ppmDinR : -120), vuL: Number.isFinite(vuL) ? vuL : (Number.isFinite(rmsL) ? rmsL : -120), vuR: Number.isFinite(vuR) ? vuR : (Number.isFinite(rmsR) ? rmsR : -120), lufsM: Number.isFinite(lufsM) ? lufsM : undefined, lufsS: Number.isFinite(lufsS) ? lufsS : undefined, lufsI: Number.isFinite(lufsI) ? lufsI : undefined, lra: Number.isFinite(lra) ? lra : undefined, lufsML: Number.isFinite(lufsML) ? lufsML : undefined, lufsMR: Number.isFinite(lufsMR) ? lufsMR : undefined, lufsSL: Number.isFinite(lufsSL) ? lufsSL : undefined, lufsSR: Number.isFinite(lufsSR) ? lufsSR : undefined, ppmBoxL: Number.isFinite(ppmBoxL) ? ppmBoxL : undefined, ppmBoxR: Number.isFinite(ppmBoxR) ? ppmBoxR : undefined, waveL, waveR, waveChannels: Number.isFinite(Number(frame?.wave_channels)) ? Number(frame.wave_channels) : (waveR ? 2 : (waveL ? 1 : 0)), xyL, xyR, rta: rta ? { engine: String(rta?.engine || 'iir'), bands_avg: Array.isArray(rta?.bands_avg) ? rta.bands_avg : (Array.isArray(rta?.bands) ? rta.bands : []), bands_peak: Array.isArray(rta?.bands_peak) ? rta.bands_peak : [], bands: Array.isArray(rta?.bands) ? rta.bands : (Array.isArray(rta?.bands_avg) ? rta.bands_avg : []), centers: Array.isArray(rta?.centers) ? rta.centers : [], freqMin: Number.isFinite(Number(rta?.freq_min)) ? Number(rta.freq_min) : 20, freqMax: Number.isFinite(Number(rta?.freq_max)) ? Number(rta.freq_max) : 20000, bpo: String(rta?.bpo || '1_6'), weighting: String(rta?.weighting || 'z'), layout: String(rta?.layout || 'rtw'), sampleRate: Number.isFinite(Number(rta?.sample_rate)) ? Number(rta.sample_rate) : 48000, } : null, spectro: spectro ? { bins: Array.isArray(spectro?.bins) ? spectro.bins : [], sampleRate: Number.isFinite(Number(spectro?.sample_rate)) ? Number(spectro.sample_rate) : 48000, fftSize: Number.isFinite(Number(spectro?.fft_size)) ? Number(spectro.fft_size) : 4096, } : null, waveEnv: waveEnv ? { data: Array.isArray(waveEnv?.data) ? waveEnv.data : [], columns: Number.isFinite(Number(waveEnv?.columns)) ? Number(waveEnv.columns) : 0, channels: Number.isFinite(Number(waveEnv?.channels)) ? Number(waveEnv.channels) : 1, columnSamples: Number.isFinite(Number(waveEnv?.column_samples)) ? Number(waveEnv.column_samples) : 0, sampleRate: Number.isFinite(Number(waveEnv?.sample_rate)) ? Number(waveEnv.sample_rate) : 48000, } : null, source: frame?.source || 'phoenix', input: 'line', globalConfigRev: Number(frame?.global_config_rev) || 0, seq: Number(frame?.seq) || 0, timestampMs: Number(frame?.timestamp_ms) || 0, }; } function bindLifecycleHandlers() { if (lifecycleHandlersBound) return; const scheduleRecover = (reason) => { try { if (recoverTimer) clearTimeout(recoverTimer); } catch (_) {} recoverTimer = setTimeout(async () => { recoverTimer = null; if (document.hidden) return; const env = envRef; if (!env || !audioLost(env)) return; const now = performance.now(); if (now - lastHardRecoverAt < 5000) return; lastHardRecoverAt = now; console.warn(`Audio stalled after ${reason}; reloading audio…`); try { await reloadAudio(env); } catch (e) { console.warn('Audio reload failed:', e); } }, 150); }; document.addEventListener('visibilitychange', () => { if (!document.hidden) scheduleRecover('visibility'); }); window.addEventListener('focus', () => { scheduleRecover('focus'); }); lifecycleHandlersBound = true; } function createWaveformStateFallback(sampleRate, seconds = WAVEFORM_FALLBACK_SECONDS) { const maxSamples = Math.max(1, Math.round(sampleRate * seconds)); return { sampleRate, maxSamples, bufferL: new Float32Array(maxSamples), bufferR: new Float32Array(maxSamples), write: 0, length: 0, channels: 1, }; } function resetWaveformStateFallback(state) { if (!state) return; state.write = 0; state.length = 0; state.channels = 1; } function pushWaveformSamplesFallback(state, chunkL, chunkR, channelCount = 2) { if (!state || !chunkL || !chunkL.length) return; const len = chunkL.length; const cap = state.maxSamples; let write = state.write; const useRight = channelCount > 1 && chunkR && chunkR.length === len; for (let i = 0; i < len; i++) { state.bufferL[write] = chunkL[i]; state.bufferR[write] = useRight ? chunkR[i] : chunkL[i]; write++; if (write >= cap) write = 0; } state.write = write; state.length = Math.min(cap, state.length + len); state.channels = useRight ? 2 : 1; } function copyFromRing(buffer, capacity, writeIndex, count) { const out = new Float32Array(count); let start = writeIndex - count; if (start < 0) start += capacity; if (start + count <= capacity) { out.set(buffer.subarray(start, start + count), 0); } else { const first = capacity - start; out.set(buffer.subarray(start), 0); out.set(buffer.subarray(0, count - first), first); } return out; } function getWaveformSamplesFallback(state, requested) { if (!state || !state.length) return null; const count = Math.max(0, Math.min(state.length, Math.round(requested))); if (count <= 0) return null; return { sampleRate: state.sampleRate, L: copyFromRing(state.bufferL, state.maxSamples, state.write, count), R: state.channels > 1 ? copyFromRing(state.bufferR, state.maxSamples, state.write, count) : null, channels: state.channels, }; } function createWaveformEnvelopeStore(sampleRate, columnSamples) { const samplesPerColumn = Math.max(1, Math.round(columnSamples)); const sr = Math.max(8000, Math.round(sampleRate) || 48000); const columnsPerSecond = sr / samplesPerColumn; const maxColumns = Math.max( 64, Math.ceil(columnsPerSecond * WAVEFORM_RING_SECONDS) + 32, ); return { minL: new Float32Array(maxColumns), maxL: new Float32Array(maxColumns), minR: new Float32Array(maxColumns), maxR: new Float32Array(maxColumns), write: 0, length: 0, capacity: maxColumns, sampleRate: sr, columnSamples: samplesPerColumn, columnsPerSecond, channels: 1, }; } function ensureWaveformEnvelopeStore(store, sampleRate, columnSamples) { const samplesPerColumn = Math.max(1, Math.round(columnSamples)); const sr = Math.max(8000, Math.round(sampleRate) || 48000); const columnsPerSecond = sr / samplesPerColumn; const required = Math.max( 64, Math.ceil(columnsPerSecond * WAVEFORM_RING_SECONDS) + 32, ); if (!store || store.capacity < required) { return createWaveformEnvelopeStore(sr, samplesPerColumn); } store.sampleRate = sr; store.columnSamples = samplesPerColumn; store.columnsPerSecond = columnsPerSecond; if (store.length > store.capacity) { store.length = store.capacity; store.write = store.length % store.capacity; } return store; } function appendWaveformEnvelope(store, payload) { if (!store || !payload || !payload.data) return; const data = payload.data; const columns = Math.max(0, Math.min(payload.columns || 0, Math.floor(data.length / (payload.channels > 1 ? 4 : 2)))); if (!columns) return; store.channels = payload.channels > 1 ? 2 : 1; const stride = store.channels > 1 ? 4 : 2; const cap = store.capacity; for (let i = 0; i < columns; i++) { const base = i * stride; const writeIndex = store.write; store.minL[writeIndex] = data[base]; store.maxL[writeIndex] = data[base + 1]; if (store.channels > 1) { store.minR[writeIndex] = data[base + 2]; store.maxR[writeIndex] = data[base + 3]; } else { store.minR[writeIndex] = data[base]; store.maxR[writeIndex] = data[base + 1]; } store.write = (store.write + 1) % cap; store.length = Math.min(cap, store.length + 1); } } function createWaveformScratch(width) { const size = Math.max(1, width); return { widthCapacity: size, minMaxL: new Float32Array(size * 2), minMaxR: new Float32Array(size * 2), tmpMinL: new Float32Array(size), tmpMaxL: new Float32Array(size), tmpMinR: new Float32Array(size), tmpMaxR: new Float32Array(size), }; } function ensureWaveformScratch(scratch, width) { if (!scratch || scratch.widthCapacity < width) { return createWaveformScratch(width); } return scratch; } function sampleWaveformEnvelope(store, pixelWidth, windowSec, scratch) { if (!store || !store.length) return null; const width = Math.max(1, Math.floor(pixelWidth)); const seconds = Math.max(0.01, Number(windowSec) || 1); const neededColumns = Math.max(1, Math.round(seconds * store.columnsPerSecond)); const available = Math.min(store.length, neededColumns); if (available <= 0) return null; scratch = ensureWaveformScratch(scratch, width); const bucketMinL = scratch.tmpMinL; const bucketMaxL = scratch.tmpMaxL; const bucketMinR = scratch.tmpMinR; const bucketMaxR = scratch.tmpMaxR; for (let i = 0; i < width; i++) { bucketMinL[i] = Infinity; bucketMaxL[i] = -Infinity; bucketMinR[i] = Infinity; bucketMaxR[i] = -Infinity; } const cap = store.capacity; let idx = store.write - available; if (idx < 0) idx += cap; for (let i = 0; i < available; i++) { const bucket = Math.min(width - 1, Math.floor(i * width / available)); const next = (idx + i) % cap; const minL = store.minL[next]; const maxL = store.maxL[next]; bucketMinL[bucket] = Math.min(bucketMinL[bucket], minL); bucketMaxL[bucket] = Math.max(bucketMaxL[bucket], maxL); if (store.channels > 1) { const minR = store.minR[next]; const maxR = store.maxR[next]; bucketMinR[bucket] = Math.min(bucketMinR[bucket], minR); bucketMaxR[bucket] = Math.max(bucketMaxR[bucket], maxR); } } for (let i = 0; i < width; i++) { if (!Number.isFinite(bucketMinL[i])) { const fallback = i > 0 ? i - 1 : -1; if (fallback >= 0 && Number.isFinite(bucketMinL[fallback])) { bucketMinL[i] = bucketMinL[fallback]; bucketMaxL[i] = bucketMaxL[fallback]; bucketMinR[i] = bucketMinR[fallback]; bucketMaxR[i] = bucketMaxR[fallback]; } else { bucketMinL[i] = 0; bucketMaxL[i] = 0; bucketMinR[i] = 0; bucketMaxR[i] = 0; } } } const outL = scratch.minMaxL; const outR = scratch.minMaxR; for (let i = 0; i < width; i++) { const base = i * 2; outL[base] = bucketMinL[i]; outL[base + 1] = bucketMaxL[i]; if (store.channels > 1) { outR[base] = bucketMinR[i]; outR[base + 1] = bucketMaxR[i]; } } return { minMaxL: outL, minMaxR: store.channels > 1 ? outR : null, width, channels: store.channels, scratch, }; } function updateWaveformEnvelopeStore(audioState, payload) { if (!audioState || !payload || !payload.data) return; const sr = payload.sampleRate || audioState.sampleRate || 48000; const columnSamples = payload.columnSamples || Math.max(8, Math.round(sr / WAVE_ENV_COLUMNS_PER_SEC)); audioState.waveEnvStore = ensureWaveformEnvelopeStore(audioState.waveEnvStore, sr, columnSamples); appendWaveformEnvelope(audioState.waveEnvStore, payload); } function buildRtaRuntimeConfig(CONFIG = {}) { const bpoMode = CONFIG.RTA_BPO_MODE || '1_6'; const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec'; const runtimeBpoMode = layout === 'rtw' ? '1_12' : bpoMode; return { engine: CONFIG.RTA_ENGINE || 'fft', fftSize: CONFIG.FFT_SIZE || 4096, monoInput: !!CONFIG.MONO_INPUT, lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED, lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0, bpo: runtimeBpoMode, freqRange: CONFIG.RTA_FREQ_RANGE || 'norm', weighting: CONFIG.RTA_WEIGHTING || 'z', order: CONFIG.RTA_IIR_ORDER || 4, tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.12, tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0, integration: CONFIG.RTA_INTEGRATION || 'fast', layout, inputOffsetDbL: Number.isFinite(CONFIG.INPUT_OFFSET_DB_L) ? CONFIG.INPUT_OFFSET_DB_L : -5, inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5, ppmDinAttackMs: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : 5, ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (20 / 1.7), ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK, ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : 10, ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (24 / 2.8), lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4, lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED, rtwCenters: layout === 'rtw' ? getRtwCenters(runtimeBpoMode) : null, }; } async function initPhoenixAudio(env) { const CONFIG = env?.config; const baseUrl = normalizePhoenixBaseUrl(CONFIG?.PHOENIX_BASE_URL); const wsUrl = buildPhoenixWsUrl(baseUrl); try { const payload = await requestPhoenixGlobalConfig(baseUrl); if (payload?.config) { applyPhoenixGlobalConfig(payload.config); saveConfig(); try { env.syncConfigBackedSlots?.(); } catch (_) {} try { env.syncOptionsUI?.(); } catch (_) {} try { env.invalidateMeters?.(); } catch (_) {} } env.audio.phoenixGlobalConfigRev = Number(payload?.revision) || 0; } catch (err) { console.warn('Phoenix global config bootstrap failed:', err); env.audio.phoenixGlobalConfigRev = 0; } env.audio.backendMode = 'phoenix'; env.audio.backendLabel = 'Phoenix'; env.audio.baseUrl = baseUrl; env.audio.sampleRate = 48000; env.audio.nyq = 24000; env.audio.alive = false; env.audio.lastSampleTs = 0; env.audio.xyL = null; env.audio.xyR = null; env.audio.xySeq = 0; env.audio.rtaData = null; env.audio.ppmDinL = undefined; env.audio.ppmDinR = undefined; env.audio.ppmEbuL = undefined; env.audio.ppmEbuR = undefined; env.audio.ppmL = undefined; env.audio.ppmR = undefined; env.audio.waveEnvStore = null; env.audio.waveEnvScratch = null; env.audio.waveformFallback = createWaveformStateFallback(env.audio.sampleRate || 48000); env.audio.phoenixSpectroBuffer = null; env.audio.phoenixSpectroScratch = null; env.audio.phoenixSpectroMeta = null; env.audio.phoenixSpectroSeq = 0; env.audio.rmsDb = { L: -120, R: -120, mono: -120 }; const phoenixAnalyser = { frequencyBinCount: 0, smoothingTimeConstant: 0, context: { sampleRate: 48000 }, getFloatFrequencyData(target) { const src = env.audio?.phoenixSpectroBuffer; const len = Math.max(0, Math.min(target?.length || 0, src?.length || 0)); if (!target || !src || !len) { if (target?.fill) target.fill(-160); return; } if (target !== src) target.fill(-160); for (let i = 0; i < len; i++) target[i] = src[i]; for (let i = len; i < target.length; i++) target[i] = -160; }, }; env.audio.getAnalyser = () => { const meta = env.audio?.phoenixSpectroMeta; const buf = env.audio?.phoenixSpectroBuffer; if (!meta || !buf) return null; phoenixAnalyser.frequencyBinCount = meta.frequencyBinCount || buf.length || 0; phoenixAnalyser.context.sampleRate = meta.sampleRate || env.audio.sampleRate || 48000; return phoenixAnalyser; }; env.audio.getFreqBuffer = () => { const src = env.audio?.phoenixSpectroBuffer; if (!src) return null; let scratch = env.audio?.phoenixSpectroScratch; if (!(scratch instanceof Float32Array) || scratch.length !== src.length) { scratch = new Float32Array(src.length); env.audio.phoenixSpectroScratch = scratch; } return scratch; }; env.audio.getWaveformSamples = (count) => getWaveformSamplesFallback(env.audio.waveformFallback, count); env.audio.pushWaveSamples = (chunkL, chunkR, channelCount = 2, sr) => { if (!env.audio.waveformFallback) { env.audio.waveformFallback = createWaveformStateFallback(Number(sr) || env.audio.sampleRate || 48000); } if (sr && Number.isFinite(sr)) { env.audio.waveformFallback.sampleRate = sr; env.audio.sampleRate = sr; } pushWaveformSamplesFallback(env.audio.waveformFallback, chunkL, chunkR, channelCount); }; env.audio.getWaveformEnvelope = (pixelWidth, windowSec) => { const sample = sampleWaveformEnvelope( env.audio.waveEnvStore, pixelWidth, windowSec, env.audio.waveEnvScratch, ); if (!sample) return null; env.audio.waveEnvScratch = sample.scratch; return { minMaxL: sample.minMaxL, minMaxR: sample.minMaxR, pixelWidth: sample.width, width: sample.width, channels: sample.channels, }; }; env.audio.startWavCapture = async (sessionId) => { const id = Number(sessionId) || 0; if (!id) throw new Error('Ungültige WAV-Session'); await requestPhoenixWavCaptureStart(baseUrl, id); }; env.audio.stopWavCapture = async (sessionId, format = 'wav', options = {}) => { const id = Number(sessionId) || 0; if (!id) throw new Error('Ungültige WAV-Session'); return await requestPhoenixWavCaptureStop(baseUrl, id, format, options); }; env.audio.abortWavCaptures = () => {}; env.audio.updateInputOffset = () => { void pushPhoenixGlobalConfig(); }; env.audio.updateMonoMode = () => { void pushPhoenixGlobalConfig(); }; env.audio.updateLrDelay = () => { void pushPhoenixGlobalConfig(); }; env.audio.updateProcessingConfig = (cfg) => { const rawProfile = (cfg && typeof cfg === 'object') ? cfg : ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null); const profile = rawProfile || {}; if (!profile.needXy) { env.audio.xyL = null; env.audio.xyR = null; env.audio.xySeq = 0; } if (!profile.needRta) { env.audio.rtaData = null; } if (!profile.needWaveform) { env.audio.waveEnvStore = null; env.audio.waveEnvScratch = null; resetWaveformStateFallback(env.audio.waveformFallback); } }; const pushPhoenixGlobalConfig = async () => { const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload()); if (response?.config) { applyPhoenixGlobalConfig(response.config); saveConfig(); try { env.syncConfigBackedSlots?.(); } catch (_) {} try { env.syncOptionsUI?.(); } catch (_) {} } env.audio.phoenixGlobalConfigRev = Number(response?.revision) || env.audio.phoenixGlobalConfigRev || 0; if (env?.audio) env.audio.rtaData = null; await requestPhoenixRtaConfig(baseUrl, buildRtaRuntimeConfig(CONFIG)); }; const pushPhoenixRtaConfig = async () => { if (env?.audio) env.audio.rtaData = null; await requestPhoenixRtaConfig(baseUrl, buildRtaRuntimeConfig(CONFIG)); }; env.audio.updateRtaConfig = pushPhoenixRtaConfig; env.audio.updatePpmConfig = pushPhoenixRtaConfig; env.audio.updatePhoenixGlobalConfig = pushPhoenixGlobalConfig; await pushPhoenixRtaConfig(); return await new Promise((resolve) => { let settled = false; const socket = new WebSocket(wsUrl); phoenixSocket = socket; const timeout = setTimeout(() => { if (settled) return; settled = true; try { socket.close(); } catch (_) {} if (phoenixSocket === socket) phoenixSocket = null; env.audio.alive = false; env.utils?.showErr?.(`Phoenix connect timeout: ${wsUrl}`); resolve(false); }, PHOENIX_CONNECT_TIMEOUT_MS); const finish = (ok) => { if (settled) return; settled = true; clearTimeout(timeout); resolve(ok); }; socket.onopen = () => { finish(true); }; socket.onmessage = (event) => { try { const frame = JSON.parse(event.data); const packet = buildPhoenixMeterPacket(frame); applyIncomingAudioPacket(env, packet, CONFIG, performance.now()).catch((err) => { console.warn('Phoenix packet error:', err); }); } catch (err) { console.warn('Phoenix metrics parse error:', err); } }; socket.onerror = () => { env.audio.alive = false; if (!settled) { clearTimeout(timeout); if (phoenixSocket === socket) phoenixSocket = null; env.utils?.showErr?.(`Phoenix connect error: ${wsUrl}`); finish(false); } }; socket.onclose = () => { env.audio.alive = false; if (phoenixSocket === socket) phoenixSocket = null; if (!settled) { clearTimeout(timeout); env.utils?.showErr?.(`Phoenix socket closed: ${wsUrl}`); finish(false); } }; }); } export async function initAudio(env) { try { envRef = env; bindLifecycleHandlers(); closePhoenixSocket(); return await initPhoenixAudio(env); } catch (e) { try { env?.audio?.abortWavCaptures?.('Audio init error'); } catch (_) {} env.audio.alive = false; env.utils?.showErr?.('Audio init error: ' + (e?.message || String(e))); return false; } } export function audioLost(env) { const timeoutMs = 2500; const lost = (!env.audio.alive) || (performance.now() - (env.audio.lastSampleTs || 0)) > timeoutMs; return !!lost; } export async function reloadAudio(env) { try { try { env?.audio?.abortWavCaptures?.('Audio neu initialisiert'); } catch (_) {} closePhoenixSocket(); } catch (e) { console.warn('Audio cleanup error:', e); } return initAudio(env); }