1210 lines
44 KiB
JavaScript
1210 lines
44 KiB
JavaScript
// 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 { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js';
|
|
import { getRtwCenters } from './rtw_centers.js';
|
|
|
|
// interner Zustand (pro App-Instanz)
|
|
let phoenixSocket = null;
|
|
let phoenixSpectroSocket = null;
|
|
let phoenixSpectroRetryTimer = null;
|
|
let phoenixSpectroDemanded = false;
|
|
let pendingSpectroBuffer = null;
|
|
let phoenixSpectroRaf = 0;
|
|
let phoenixVisualsSocket = null;
|
|
let phoenixVisualsRetryTimer = null;
|
|
let pendingVisualsBuffer = null;
|
|
let phoenixVisualsRaf = 0;
|
|
let envRef = null;
|
|
let lifecycleHandlersBound = false;
|
|
let recoverTimer = null;
|
|
let lastHardRecoverAt = 0;
|
|
let pendingPhoenixFrame = null;
|
|
let phoenixPacketRaf = 0;
|
|
let phoenixPacketBusy = false;
|
|
|
|
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, pathname = '/api/v1/metrics/ws') {
|
|
try {
|
|
const url = new URL(baseUrl);
|
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
url.pathname = pathname;
|
|
url.search = '';
|
|
url.hash = '';
|
|
return url.toString();
|
|
} catch (_) {
|
|
const fallback = defaultPhoenixBaseUrl();
|
|
return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + pathname;
|
|
}
|
|
}
|
|
|
|
function closePhoenixSocket() {
|
|
if (phoenixSocket) {
|
|
try {
|
|
phoenixSocket.onopen = null;
|
|
phoenixSocket.onmessage = null;
|
|
phoenixSocket.onerror = null;
|
|
phoenixSocket.onclose = null;
|
|
phoenixSocket.close();
|
|
} catch (_) {}
|
|
}
|
|
phoenixSocket = null;
|
|
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
|
|
phoenixSpectroRetryTimer = null;
|
|
if (phoenixSpectroSocket) {
|
|
try {
|
|
phoenixSpectroSocket.onopen = null;
|
|
phoenixSpectroSocket.onmessage = null;
|
|
phoenixSpectroSocket.onerror = null;
|
|
phoenixSpectroSocket.onclose = null;
|
|
phoenixSpectroSocket.close();
|
|
} catch (_) {}
|
|
}
|
|
phoenixSpectroSocket = null;
|
|
phoenixSpectroDemanded = false;
|
|
pendingSpectroBuffer = null;
|
|
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
|
|
phoenixSpectroRaf = 0;
|
|
if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer);
|
|
phoenixVisualsRetryTimer = null;
|
|
if (phoenixVisualsSocket) {
|
|
try {
|
|
phoenixVisualsSocket.onopen = null;
|
|
phoenixVisualsSocket.onmessage = null;
|
|
phoenixVisualsSocket.onerror = null;
|
|
phoenixVisualsSocket.onclose = null;
|
|
phoenixVisualsSocket.close();
|
|
} catch (_) {}
|
|
}
|
|
phoenixVisualsSocket = null;
|
|
pendingVisualsBuffer = null;
|
|
if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf);
|
|
phoenixVisualsRaf = 0;
|
|
pendingPhoenixFrame = null;
|
|
if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf);
|
|
phoenixPacketRaf = 0;
|
|
phoenixPacketBusy = false;
|
|
}
|
|
|
|
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, frameDelta = 1) {
|
|
if (!audioState) return false;
|
|
const src = Array.isArray(spectro?.bins) || ArrayBuffer.isView(spectro?.bins)
|
|
? spectro.bins
|
|
: null;
|
|
if (!src || !src.length) {
|
|
audioState.phoenixSpectroBuffer = null;
|
|
audioState.phoenixSpectroMeta = null;
|
|
return false;
|
|
}
|
|
const sourceSeq = Number(spectro?.seq);
|
|
const previousSourceSeq = Number(audioState.phoenixSpectroSourceSeq || 0);
|
|
if (Number.isFinite(sourceSeq) && sourceSeq > 0
|
|
&& previousSourceSeq > 0 && sourceSeq <= previousSourceSeq) return false;
|
|
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,
|
|
};
|
|
let delta = Number.isFinite(Number(frameDelta)) ? Math.max(1, Math.floor(Number(frameDelta))) : 1;
|
|
if (Number.isFinite(sourceSeq) && sourceSeq > 0) {
|
|
if (previousSourceSeq > 0) delta = Math.max(1, Math.floor(sourceSeq - previousSourceSeq));
|
|
audioState.phoenixSpectroSourceSeq = sourceSeq;
|
|
}
|
|
audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + delta;
|
|
return true;
|
|
}
|
|
|
|
function openPhoenixSpectroSocket(baseUrl, env) {
|
|
if (!phoenixSpectroDemanded) return;
|
|
if (phoenixSpectroSocket && (
|
|
phoenixSpectroSocket.readyState === WebSocket.OPEN
|
|
|| phoenixSpectroSocket.readyState === WebSocket.CONNECTING
|
|
)) return;
|
|
const socket = new WebSocket(buildPhoenixWsUrl(baseUrl, '/api/v1/spectro/ws'));
|
|
socket.binaryType = 'arraybuffer';
|
|
phoenixSpectroSocket = socket;
|
|
|
|
socket.onmessage = (event) => {
|
|
if (!(event.data instanceof ArrayBuffer)) return;
|
|
pendingSpectroBuffer = event.data;
|
|
scheduleSpectroBufferPump(env);
|
|
};
|
|
|
|
const retry = () => {
|
|
if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null;
|
|
if (!phoenixSpectroDemanded || !phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
|
|
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
|
|
phoenixSpectroRetryTimer = setTimeout(() => {
|
|
phoenixSpectroRetryTimer = null;
|
|
openPhoenixSpectroSocket(baseUrl, env);
|
|
}, 1000);
|
|
};
|
|
socket.onerror = retry;
|
|
socket.onclose = retry;
|
|
}
|
|
|
|
function setPhoenixSpectroDemand(baseUrl, env, demanded) {
|
|
const next = !!demanded;
|
|
phoenixSpectroDemanded = next;
|
|
if (next) {
|
|
if (phoenixSocket?.readyState === WebSocket.OPEN) openPhoenixSpectroSocket(baseUrl, env);
|
|
return;
|
|
}
|
|
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
|
|
phoenixSpectroRetryTimer = null;
|
|
if (phoenixSpectroSocket) {
|
|
const socket = phoenixSpectroSocket;
|
|
phoenixSpectroSocket = null;
|
|
try {
|
|
socket.onopen = null;
|
|
socket.onmessage = null;
|
|
socket.onerror = null;
|
|
socket.onclose = null;
|
|
socket.close();
|
|
} catch (_) {}
|
|
}
|
|
pendingSpectroBuffer = null;
|
|
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
|
|
phoenixSpectroRaf = 0;
|
|
}
|
|
|
|
function scheduleSpectroBufferPump(env) {
|
|
if (phoenixSpectroRaf || !pendingSpectroBuffer) return;
|
|
phoenixSpectroRaf = requestAnimationFrame(() => {
|
|
phoenixSpectroRaf = 0;
|
|
const buffer = pendingSpectroBuffer;
|
|
pendingSpectroBuffer = null;
|
|
try {
|
|
const decoded = decodePhoenixSpectroBuffer(buffer);
|
|
if (!decoded) return;
|
|
if (!copyPhoenixSpectroBins(env.audio, decoded)) return;
|
|
env.requestRender?.('spectro');
|
|
} catch (err) {
|
|
console.warn('Phoenix spectrogram packet error:', err);
|
|
} finally {
|
|
if (pendingSpectroBuffer) scheduleSpectroBufferPump(env);
|
|
}
|
|
});
|
|
}
|
|
|
|
function openPhoenixVisualsSocket(baseUrl, env) {
|
|
if (phoenixVisualsSocket && (
|
|
phoenixVisualsSocket.readyState === WebSocket.OPEN
|
|
|| phoenixVisualsSocket.readyState === WebSocket.CONNECTING
|
|
)) return;
|
|
const socket = new WebSocket(buildPhoenixWsUrl(baseUrl, '/api/v1/visuals/ws'));
|
|
socket.binaryType = 'arraybuffer';
|
|
phoenixVisualsSocket = socket;
|
|
socket.onmessage = (event) => {
|
|
if (!(event.data instanceof ArrayBuffer)) return;
|
|
pendingVisualsBuffer = event.data;
|
|
scheduleVisualsBufferPump(env);
|
|
};
|
|
const retry = () => {
|
|
if (phoenixVisualsSocket === socket) phoenixVisualsSocket = null;
|
|
if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
|
|
if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer);
|
|
phoenixVisualsRetryTimer = setTimeout(() => {
|
|
phoenixVisualsRetryTimer = null;
|
|
openPhoenixVisualsSocket(baseUrl, env);
|
|
}, 1000);
|
|
};
|
|
socket.onerror = retry;
|
|
socket.onclose = retry;
|
|
}
|
|
|
|
function scheduleVisualsBufferPump(env) {
|
|
if (phoenixVisualsRaf || !pendingVisualsBuffer) return;
|
|
phoenixVisualsRaf = requestAnimationFrame(() => {
|
|
phoenixVisualsRaf = 0;
|
|
const buffer = pendingVisualsBuffer;
|
|
pendingVisualsBuffer = null;
|
|
try {
|
|
applyVisualsBuffer(env, buffer);
|
|
} catch (err) {
|
|
console.warn('Phoenix visual packet error:', err);
|
|
} finally {
|
|
if (pendingVisualsBuffer) scheduleVisualsBufferPump(env);
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyVisualsBuffer(env, buffer) {
|
|
const decoded = decodePhoenixVisualsBuffer(buffer);
|
|
if (!decoded) return;
|
|
const { seq, left, right, wave } = decoded;
|
|
const previousSeq = Number(env.audio?.phoenixVisualsSeq || 0);
|
|
if (previousSeq > 0 && seq <= previousSeq) return;
|
|
if (left && right) {
|
|
env.audio.xyL = left;
|
|
env.audio.xyR = right;
|
|
env.audio.xySeq = seq;
|
|
env.audio.xyLastSampleTs = performance.now();
|
|
}
|
|
if (wave) updateWaveformEnvelopeStore(env.audio, wave);
|
|
env.audio.phoenixVisualsSeq = seq;
|
|
env.requestRender?.('visuals');
|
|
}
|
|
|
|
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;
|
|
const seq = Number(d.seq);
|
|
const previousSeq = Number(env.audio.phoenixMetricsSeq || 0);
|
|
if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return;
|
|
if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq;
|
|
env.requestRender?.('audio');
|
|
env.audio.lastSampleTs = sampleTs;
|
|
env.audio.alive = true;
|
|
if (Number.isFinite(Number(d.sampleRate)) && Number(d.sampleRate) > 0) {
|
|
env.audio.sampleRate = Number(d.sampleRate);
|
|
env.audio.nyq = Number(d.sampleRate) / 2;
|
|
}
|
|
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);
|
|
env.audio.xyLastSampleTs = sampleTs;
|
|
}
|
|
if (typeof d.correlation === 'number') env.audio.correlation = d.correlation;
|
|
if (typeof d.correlationNegativePeak === 'number') {
|
|
env.audio.correlationNegativePeak = d.correlationNegativePeak;
|
|
}
|
|
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, d.spectroFrameDelta);
|
|
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 correlation = Number(frame?.correlation);
|
|
const correlationNegativePeak = Number(frame?.correlation_negative_peak);
|
|
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: Number.isFinite(Number(frame?.sample_rate)) ? Number(frame.sample_rate) : 48000,
|
|
correlation: Number.isFinite(correlation) ? Math.max(-1, Math.min(1, correlation)) : 0,
|
|
correlationNegativePeak: Number.isFinite(correlationNegativePeak)
|
|
? Math.max(-1, Math.min(1, correlationNegativePeak))
|
|
: 0,
|
|
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'),
|
|
filterbank: String(rta?.filterbank || 'legacy'),
|
|
detector: String(rta?.detector || 'average'),
|
|
response: String(rta?.response || 'fast'),
|
|
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 ? {
|
|
seq: Number.isFinite(Number(spectro?.seq)) ? Number(spectro.seq) : 0,
|
|
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,
|
|
spectroFrameDelta: Number.isFinite(Number(frame?.__spectro_frame_delta))
|
|
? Math.max(1, Math.floor(Number(frame.__spectro_frame_delta)))
|
|
: 1,
|
|
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 mergePendingPhoenixFrame(previous, next) {
|
|
if (!next) return previous;
|
|
const previousSeq = Number(previous?.seq || 0);
|
|
const nextSeq = Number(next?.seq || 0);
|
|
if (previous && previousSeq > 0 && nextSeq > 0 && nextSeq <= previousSeq) return previous;
|
|
if (!previous) {
|
|
if (next?.spectro) next.__spectro_frame_delta = 1;
|
|
return next;
|
|
}
|
|
const merged = next;
|
|
if (!merged.wave_env && previous.wave_env) merged.wave_env = previous.wave_env;
|
|
if ((!merged.xy_l || !merged.xy_l.length) && previous.xy_l?.length) {
|
|
merged.xy_l = previous.xy_l;
|
|
merged.xy_r = previous.xy_r;
|
|
}
|
|
if (!merged.rta && previous.rta) merged.rta = previous.rta;
|
|
const previousSpectroCount = Math.max(0, Number(previous.__spectro_frame_delta) || 0);
|
|
if (merged.spectro) {
|
|
merged.__spectro_frame_delta = previousSpectroCount + 1;
|
|
} else if (previous.spectro) {
|
|
merged.spectro = previous.spectro;
|
|
merged.__spectro_frame_delta = Math.max(1, previousSpectroCount);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function schedulePhoenixPacketPump(env, CONFIG) {
|
|
if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return;
|
|
phoenixPacketRaf = requestAnimationFrame(async () => {
|
|
phoenixPacketRaf = 0;
|
|
const frame = pendingPhoenixFrame;
|
|
pendingPhoenixFrame = null;
|
|
if (!frame) return;
|
|
phoenixPacketBusy = true;
|
|
try {
|
|
const packet = buildPhoenixMeterPacket(frame);
|
|
await applyIncomingAudioPacket(env, packet, CONFIG, performance.now());
|
|
} catch (err) {
|
|
console.warn('Phoenix packet error:', err);
|
|
} finally {
|
|
phoenixPacketBusy = false;
|
|
if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG);
|
|
}
|
|
});
|
|
}
|
|
|
|
function enqueuePhoenixFrame(env, CONFIG, frame) {
|
|
pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame);
|
|
schedulePhoenixPacketPump(env, CONFIG);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export function buildRtaRuntimeConfig(CONFIG = {}) {
|
|
const bpoMode = CONFIG.RTA_BPO_MODE || '1_3';
|
|
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
|
const rtwProfile = layout === 'rtw';
|
|
const runtimeBpoMode = bpoMode;
|
|
return {
|
|
engine: rtwProfile ? 'iir' : (CONFIG.RTA_ENGINE || 'iir'),
|
|
filterbank: CONFIG.RTA_IIR_FILTERBANK === 'butterworth' ? 'butterworth' : 'legacy',
|
|
detector: CONFIG.RTA_DETECTOR === 'peak' ? 'peak' : 'average',
|
|
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: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'),
|
|
weighting: CONFIG.RTA_WEIGHTING || 'z',
|
|
order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6),
|
|
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.125,
|
|
tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0,
|
|
integration: CONFIG.RTA_INTEGRATION || 'fast',
|
|
layout,
|
|
rtaPeakHoldMode: ['off', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE)
|
|
? CONFIG.RTA_PEAK_HOLD_MODE
|
|
: 'auto',
|
|
rtaPeakHoldSeconds: Number.isFinite(CONFIG.RTA_PEAK_HOLD_SEC) ? CONFIG.RTA_PEAK_HOLD_SEC : 2.5,
|
|
rtaPeakResetToken: Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)),
|
|
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: 10,
|
|
ppmDinDecayDbPerS: 20 / 1.5,
|
|
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
|
|
ppmEbuAttackMs: 10,
|
|
ppmEbuDecayDbPerS: 24 / 2.8,
|
|
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4,
|
|
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
|
|
correlationResponseS: Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0,
|
|
correlationResetToken: Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)),
|
|
xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS))
|
|
? Number(CONFIG.XY_POINTS)
|
|
: 1024,
|
|
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.phoenixSpectroSourceSeq = 0;
|
|
env.audio.phoenixVisualsSeq = 0;
|
|
env.audio.phoenixMetricsSeq = 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 || {};
|
|
setPhoenixSpectroDemand(baseUrl, env, profile.needSpectro);
|
|
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);
|
|
}
|
|
};
|
|
env.audio.updateProcessingConfig(
|
|
(typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null,
|
|
);
|
|
|
|
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 = () => {
|
|
if (phoenixSpectroDemanded) openPhoenixSpectroSocket(baseUrl, env);
|
|
openPhoenixVisualsSocket(baseUrl, env);
|
|
finish(true);
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
try {
|
|
const frame = JSON.parse(event.data);
|
|
enqueuePhoenixFrame(env, CONFIG, frame);
|
|
} 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);
|
|
}
|