Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,920 @@
|
||||
// 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);
|
||||
}
|
||||
+1252
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
// core/registry.js — Lazy Loader + Safe Runner für Meter
|
||||
// Lädt Meter-Module on-demand (oder via registerMeter) und kapselt Fehler.
|
||||
|
||||
const cache = {
|
||||
meters: new Map(), // id -> module
|
||||
};
|
||||
|
||||
const meterRenderCache = new Map(); // key -> surface
|
||||
const METER_CACHE_MARGIN = 32;
|
||||
let activeFrameStamp = 0;
|
||||
|
||||
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
|
||||
|
||||
function createCacheSurface(width, height) {
|
||||
const w = Math.max(1, Math.ceil(width));
|
||||
const h = Math.max(1, Math.ceil(height));
|
||||
if (CAN_USE_OFFSCREEN) {
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 };
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h, stamp: -1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cacheKeyForMeter(id, rect) {
|
||||
const x = Math.round(Number(rect?.x) || 0);
|
||||
const y = Math.round(Number(rect?.y) || 0);
|
||||
const w = Math.max(1, Math.round(Number(rect?.w) || 0));
|
||||
const h = Math.max(1, Math.round(Number(rect?.h) || 0));
|
||||
return `${id}::${x},${y},${w}x${h}`;
|
||||
}
|
||||
|
||||
function ensureCacheSurface(id, rect) {
|
||||
const key = cacheKeyForMeter(id, rect);
|
||||
let surface = meterRenderCache.get(key);
|
||||
if (!surface) {
|
||||
surface = createCacheSurface(rect.w + METER_CACHE_MARGIN * 2, rect.h + METER_CACHE_MARGIN * 2);
|
||||
if (!surface) return null;
|
||||
meterRenderCache.set(key, surface);
|
||||
}
|
||||
if (!surface.canvas || !surface.ctx) return null;
|
||||
const w = Math.max(1, Math.ceil(rect.w + METER_CACHE_MARGIN * 2));
|
||||
const h = Math.max(1, Math.ceil(rect.h + METER_CACHE_MARGIN * 2));
|
||||
if (surface.canvas.width !== w || surface.canvas.height !== h) {
|
||||
surface.canvas.width = w;
|
||||
surface.canvas.height = h;
|
||||
surface.stamp = -1;
|
||||
}
|
||||
surface.width = w;
|
||||
surface.height = h;
|
||||
surface.margin = METER_CACHE_MARGIN;
|
||||
return surface;
|
||||
}
|
||||
|
||||
export async function loadMeter(id) {
|
||||
if (!id) throw new Error('loadMeter: leere Meter-ID');
|
||||
if (cache.meters.has(id)) return cache.meters.get(id);
|
||||
const mod = await import(`../meters/${id}.js`);
|
||||
cache.meters.set(id, mod);
|
||||
return mod;
|
||||
}
|
||||
|
||||
// --- Meter-Facade -----------------------------------------------------------
|
||||
// Views rufen nur diese Fassade, kennen also die Module/States nicht direkt.
|
||||
const meterStates = new Map(); // instanceKey -> { id, shared }
|
||||
const DEFAULT_INSTANCE_SUFFIX = '::default';
|
||||
|
||||
function makeMeterInstanceKey(id, rect) {
|
||||
if (!rect) return `${id}::default`;
|
||||
const x = Math.round(Number(rect.x) || 0);
|
||||
const y = Math.round(Number(rect.y) || 0);
|
||||
const w = Math.round(Number(rect.w) || 0);
|
||||
const h = Math.round(Number(rect.h) || 0);
|
||||
return `${id}::${x},${y},${w},${h}`;
|
||||
}
|
||||
|
||||
function makeDefaultMeterInstanceKey(id) {
|
||||
return `${id}${DEFAULT_INSTANCE_SUFFIX}`;
|
||||
}
|
||||
|
||||
function getMeterSharedById(id) {
|
||||
const matches = [];
|
||||
for (const entry of meterStates.values()) {
|
||||
if (entry?.id === id && entry.shared) matches.push(entry.shared);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function getPreferredMeterShared(id) {
|
||||
const defaultEntry = meterStates.get(makeDefaultMeterInstanceKey(id));
|
||||
if (defaultEntry?.shared) return defaultEntry.shared;
|
||||
for (const entry of meterStates.values()) {
|
||||
if (entry?.id === id && entry.shared) return entry.shared;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureMeter(id, config, rect) {
|
||||
const mod = await loadMeter(id);
|
||||
const instanceKey = makeMeterInstanceKey(id, rect);
|
||||
if (!meterStates.has(instanceKey)) {
|
||||
const shared = mod.initShared?.(config) || {};
|
||||
meterStates.set(instanceKey, { id, shared });
|
||||
}
|
||||
return { mod, instanceKey, shared: meterStates.get(instanceKey)?.shared || null };
|
||||
}
|
||||
|
||||
async function ensureDefaultMeterShared(id, config) {
|
||||
const mod = await loadMeter(id);
|
||||
const instanceKey = makeDefaultMeterInstanceKey(id);
|
||||
if (!meterStates.has(instanceKey)) {
|
||||
const shared = mod.initShared?.(config) || {};
|
||||
meterStates.set(instanceKey, { id, shared });
|
||||
}
|
||||
return { mod, instanceKey, shared: meterStates.get(instanceKey)?.shared || null };
|
||||
}
|
||||
|
||||
export const meterFacade = {
|
||||
async update(packet, config, activeIds) {
|
||||
const seen = new Set();
|
||||
for (const rawId of activeIds || []) {
|
||||
if (!rawId || seen.has(rawId)) continue;
|
||||
seen.add(rawId);
|
||||
const id = rawId;
|
||||
try {
|
||||
const { mod } = await ensureDefaultMeterShared(id, config);
|
||||
const sharedList = getMeterSharedById(id);
|
||||
for (const shared of sharedList) {
|
||||
mod.update?.(packet, shared);
|
||||
}
|
||||
} catch (e) {
|
||||
// ein defektes Meter wird übersprungen, andere laufen weiter
|
||||
console.warn(`Meter ${id} update error:`, e);
|
||||
}
|
||||
}
|
||||
},
|
||||
async pointer(evt, rect, id, config) {
|
||||
if (!id) return false;
|
||||
try {
|
||||
const { mod, shared } = await ensureMeter(id, config, rect);
|
||||
if (typeof mod.pointer === 'function') {
|
||||
const handled = await mod.pointer(evt, rect, config, shared);
|
||||
return !!handled;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Meter ${id} pointer error:`, e);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async draw(ctx, rect, id, config) {
|
||||
try {
|
||||
const { mod, shared } = await ensureMeter(id, config, rect);
|
||||
const allowCache = !(mod && mod.disableCache);
|
||||
if (!allowCache) {
|
||||
await mod.draw?.(ctx, rect, config, shared);
|
||||
return;
|
||||
}
|
||||
const cacheSurface = ensureCacheSurface(id, rect);
|
||||
const stamp = activeFrameStamp;
|
||||
const margin = cacheSurface ? cacheSurface.margin || METER_CACHE_MARGIN : 0;
|
||||
const drawX = rect.x - margin;
|
||||
const drawY = rect.y - margin;
|
||||
if (cacheSurface && cacheSurface.stamp === stamp) {
|
||||
ctx.drawImage(cacheSurface.canvas, drawX, drawY);
|
||||
return;
|
||||
}
|
||||
if (cacheSurface && cacheSurface.ctx) {
|
||||
cacheSurface.ctx.save();
|
||||
try {
|
||||
cacheSurface.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
cacheSurface.ctx.clearRect(0, 0, cacheSurface.canvas.width, cacheSurface.canvas.height);
|
||||
cacheSurface.ctx.font = ctx.font;
|
||||
cacheSurface.ctx.textAlign = ctx.textAlign;
|
||||
cacheSurface.ctx.textBaseline = ctx.textBaseline;
|
||||
cacheSurface.ctx.translate(-drawX, -drawY);
|
||||
await mod.draw?.(cacheSurface.ctx, rect, config, shared);
|
||||
} finally {
|
||||
cacheSurface.ctx.restore();
|
||||
}
|
||||
cacheSurface.stamp = stamp;
|
||||
ctx.drawImage(cacheSurface.canvas, drawX, drawY);
|
||||
return;
|
||||
}
|
||||
await mod.draw?.(ctx, rect, config, shared);
|
||||
} catch (e) {
|
||||
// Slot neutral darstellen
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(200,80,80,.8)';
|
||||
ctx.setLineDash([6,4]);
|
||||
ctx.strokeRect(rect.x+0.5, rect.y+0.5, rect.w-1, rect.h-1);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#ffdddd';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`Meter "${id}" defekt`, rect.x + rect.w/2, rect.y + rect.h/2);
|
||||
ctx.textAlign = 'start';
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
setFrameStamp(stamp) {
|
||||
activeFrameStamp = stamp || 0;
|
||||
},
|
||||
getState(id) {
|
||||
return getPreferredMeterShared(id);
|
||||
},
|
||||
invalidateAll() {
|
||||
meterStates.clear();
|
||||
meterRenderCache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__AN_REGISTRY__ = window.__AN_REGISTRY__ || {};
|
||||
window.__AN_REGISTRY__.invalidateAll = () => meterFacade.invalidateAll();
|
||||
}
|
||||
|
||||
// --- Meter Registration System ---
|
||||
export function registerMeter(meterModule) {
|
||||
if (meterModule && meterModule.id) {
|
||||
cache.meters.set(meterModule.id, Promise.resolve(meterModule));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export const RTW_CENTER_MAP = {
|
||||
'1_3': [
|
||||
20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
|
||||
200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
|
||||
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
|
||||
],
|
||||
'1_6': [
|
||||
20, 22.4, 25, 28, 31.5, 35.5, 40, 45, 50, 56,
|
||||
63, 70.8, 80, 90, 100, 112, 125, 141, 160, 180,
|
||||
200, 224, 250, 282, 315, 355, 400, 450, 500, 560,
|
||||
630, 708, 800, 900, 1000, 1120, 1250, 1410, 1600, 1800,
|
||||
2000, 2240, 2500, 2820, 3150, 3550, 4000, 4500, 5000, 5600,
|
||||
6300, 7080, 8000, 9000, 10000, 11200, 12500, 14100, 16000, 18000, 20000,
|
||||
],
|
||||
'1_12': [
|
||||
20, 21.2, 22.4, 23.8, 25, 26.5, 28, 29.8, 31.5, 33.4,
|
||||
35.5, 37.6, 40, 42.4, 45, 47.5, 50, 53, 56, 59.5,
|
||||
63, 67, 71, 75, 80, 85, 90, 95, 100, 106,
|
||||
112, 118, 125, 132, 140, 150, 160, 170, 180, 190,
|
||||
200, 212, 224, 238, 250, 265, 280, 298, 315, 334,
|
||||
355, 376, 400, 424, 450, 475, 500, 530, 560, 595,
|
||||
630, 670, 710, 750, 800, 850, 900, 950, 1000, 1060,
|
||||
1120, 1180, 1250, 1320, 1400, 1500, 1600, 1700, 1800, 1900,
|
||||
2000, 2120, 2240, 2380, 2500, 2650, 2800, 2980, 3150, 3340,
|
||||
3550, 3760, 4000, 4240, 4500, 4750, 5000, 5300, 5600, 5950,
|
||||
6300, 6700, 7100, 7500, 8000, 8500, 9000, 9500, 10000, 10600,
|
||||
11200, 11800, 12500, 13200, 14000, 15000, 16000, 17000, 18000, 19000, 20000,
|
||||
],
|
||||
};
|
||||
|
||||
export function getRtwCenters(mode = '1_3') {
|
||||
const key = String(mode || '1_3').replace('/', '_');
|
||||
if (RTW_CENTER_MAP[key]) return RTW_CENTER_MAP[key].slice();
|
||||
return RTW_CENTER_MAP['1_3'].slice();
|
||||
}
|
||||
|
||||
export function resolveRtwBpoValue(mode) {
|
||||
if (typeof mode === 'number' && Number.isFinite(mode)) return mode;
|
||||
const key = String(mode || '').replace('/', '_');
|
||||
if (key === '1_12') return 12;
|
||||
if (key === '1_6') return 6;
|
||||
return 3;
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// core/screensaver.js — handles idle-activated screensaver overlay
|
||||
|
||||
export function createScreensaver({ config, body = (typeof document !== 'undefined' ? document.body : null) } = {}) {
|
||||
const state = {
|
||||
active: false,
|
||||
force: false,
|
||||
lastSignalTs: now(),
|
||||
ball: { x: 120, y: 120, vx: 180, vy: 140, w: 140, h: 80, color: '#ff0080', tint: '#ff0080', hue: 0 },
|
||||
logo: null,
|
||||
logoReady: false,
|
||||
enabled: true,
|
||||
mode: (config?.SCREENSAVER_MODE === 'starfield' || config?.SCREENSAVER_MODE === 'clock' || config?.SCREENSAVER_MODE === 'black') ? config.SCREENSAVER_MODE : 'clock',
|
||||
stars: [],
|
||||
};
|
||||
|
||||
function now() {
|
||||
return (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
}
|
||||
|
||||
function getThreshold() {
|
||||
const t = config?.SCREENSAVER_ACTIVITY_DB;
|
||||
return Number.isFinite(t) ? t : -50;
|
||||
}
|
||||
|
||||
function markActivity(ts) {
|
||||
const t = ts || now();
|
||||
state.lastSignalTs = t;
|
||||
state.force = false;
|
||||
if (state.active) setActive(false);
|
||||
}
|
||||
|
||||
function markAudioActivity(levelDb, ts) {
|
||||
if (!Number.isFinite(levelDb)) return false;
|
||||
if (levelDb > getThreshold()) {
|
||||
// Pegel-Trigger nur, wenn kein forcierter Testlauf aktiv ist
|
||||
if (!state.force) {
|
||||
markActivity(ts);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ensureLogo() {
|
||||
if (state.logo !== null) return;
|
||||
const img = new Image();
|
||||
img.src = 'assets/dvdlogo.svg';
|
||||
img.onload = () => { state.logoReady = true; };
|
||||
img.onerror = () => { state.logoReady = false; };
|
||||
state.logo = img;
|
||||
}
|
||||
|
||||
function setActive(next) {
|
||||
state.active = !!next;
|
||||
if (body) {
|
||||
body.classList.toggle('screensaver-active', state.active);
|
||||
}
|
||||
}
|
||||
|
||||
function nextLogoColor(prev = '#ff0080') {
|
||||
const palette = ['#ff0080', '#00d8ff', '#ffd400', '#00ff88', '#ff6600', '#9b59ff', '#ff2d55'];
|
||||
let c = prev;
|
||||
for (let i = 0; i < 4 && c === prev; i++) {
|
||||
c = palette[Math.floor(Math.random() * palette.length)];
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function randomizeBall(rect) {
|
||||
const { ball } = state;
|
||||
if (!rect) return;
|
||||
const base = Math.min(rect.w, rect.h) * 0.22;
|
||||
if (state.logo && state.logoReady && state.logo.width && state.logo.height) {
|
||||
const aspect = state.logo.width / state.logo.height;
|
||||
ball.w = base;
|
||||
ball.h = base / aspect;
|
||||
} else {
|
||||
ball.w = base;
|
||||
ball.h = base * 0.55;
|
||||
}
|
||||
const halfW = ball.w / 2;
|
||||
const halfH = ball.h / 2;
|
||||
ball.x = rect.x + halfW + Math.random() * Math.max(1, rect.w - 2 * halfW);
|
||||
ball.y = rect.y + halfH + Math.random() * Math.max(1, rect.h - 2 * halfH);
|
||||
const speed = 160 + Math.random() * 80;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
ball.vx = Math.cos(angle) * speed;
|
||||
ball.vy = Math.sin(angle) * speed;
|
||||
ball.color = nextLogoColor();
|
||||
ball.tint = ball.color;
|
||||
}
|
||||
|
||||
function updateBall(rect, dtMs) {
|
||||
const { ball } = state;
|
||||
const dt = Math.max(0.001, dtMs / 1000);
|
||||
ball.x += ball.vx * dt;
|
||||
ball.y += ball.vy * dt;
|
||||
const halfW = ball.w / 2;
|
||||
const halfH = ball.h / 2;
|
||||
const minX = rect.x + halfW;
|
||||
const maxX = rect.x + rect.w - halfW;
|
||||
const minY = rect.y + halfH;
|
||||
const maxY = rect.y + rect.h - halfH;
|
||||
let bounced = false;
|
||||
if (ball.x < minX) { ball.x = minX; ball.vx *= -1; bounced = true; }
|
||||
if (ball.x > maxX) { ball.x = maxX; ball.vx *= -1; bounced = true; }
|
||||
if (ball.y < minY) { ball.y = minY; ball.vy *= -1; bounced = true; }
|
||||
if (ball.y > maxY) { ball.y = maxY; ball.vy *= -1; bounced = true; }
|
||||
if (bounced) {
|
||||
const nxt = nextLogoColor(ball.color);
|
||||
ball.color = nxt;
|
||||
ball.tint = nxt;
|
||||
ball.hue = Math.floor(Math.random() * 360);
|
||||
}
|
||||
}
|
||||
|
||||
function draw(ctx, rect) {
|
||||
if (state.mode === 'starfield') {
|
||||
drawStarfield(ctx, rect);
|
||||
return;
|
||||
}
|
||||
const { ball } = state;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(5,5,10,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
const radius = Math.min(ball.w, ball.h) * 0.18;
|
||||
const x = ball.x - ball.w / 2;
|
||||
const y = ball.y - ball.h / 2;
|
||||
const col = ball.color || '#ff0080';
|
||||
// Logo body
|
||||
ctx.fillStyle = col;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + ball.w - radius, y);
|
||||
ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius);
|
||||
ctx.lineTo(x + ball.w, y + ball.h - radius);
|
||||
ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h);
|
||||
ctx.lineTo(x + radius, y + ball.h);
|
||||
ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// DVD text
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('DVD', ball.x, ball.y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function updateAndRender(ctx, rect, nowTs, dtMs) {
|
||||
const enabled = state.enabled !== false && config?.SCREENSAVER_ENABLED !== false;
|
||||
if (!enabled && !state.force) {
|
||||
if (state.active) setActive(false);
|
||||
return false;
|
||||
}
|
||||
const idleMin = Math.max(0, Number(config?.SCREENSAVER_IDLE_MIN ?? 0));
|
||||
const idleMs = idleMin * 60 * 1000;
|
||||
const shouldActivate = state.force || (idleMs > 0 && (nowTs - state.lastSignalTs) >= idleMs);
|
||||
if (shouldActivate && !state.active) {
|
||||
randomizeBall(rect);
|
||||
setActive(true);
|
||||
} else if (!shouldActivate && state.active) {
|
||||
setActive(false);
|
||||
}
|
||||
if (!state.active) return false;
|
||||
|
||||
if (state.mode === 'starfield') {
|
||||
updateStars(rect, dtMs);
|
||||
drawStarfield(ctx, rect);
|
||||
return true;
|
||||
}
|
||||
if (state.mode === 'black') {
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
if (state.mode === 'clock') {
|
||||
drawClock(ctx, rect, nowTs);
|
||||
return true;
|
||||
}
|
||||
updateBall(rect, dtMs);
|
||||
drawDvd(ctx, rect);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureStars(rect, count = 160) {
|
||||
if (!rect) return;
|
||||
if (!state.stars || state.stars.length !== count) {
|
||||
state.stars = new Array(count).fill(0).map(() => makeStar(rect));
|
||||
}
|
||||
}
|
||||
|
||||
function makeStar(rect) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 0.15 + Math.random() * 0.25;
|
||||
const hue = 180 + Math.random() * 120;
|
||||
return {
|
||||
x: (Math.random() * 2 - 1) * rect.w * 0.35,
|
||||
y: (Math.random() * 2 - 1) * rect.h * 0.35,
|
||||
z: Math.random() * 1 + 0.3,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
hue,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStars(rect, dtMs) {
|
||||
ensureStars(rect);
|
||||
const dt = Math.max(0.001, dtMs / 1000);
|
||||
for (let i = 0; i < state.stars.length; i++) {
|
||||
const s = state.stars[i];
|
||||
s.x += s.vx * rect.w * dt;
|
||||
s.y += s.vy * rect.h * dt;
|
||||
s.z -= dt * 0.35;
|
||||
if (s.z <= 0.05 || Math.abs(s.x) > rect.w || Math.abs(s.y) > rect.h) {
|
||||
state.stars[i] = makeStar(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function drawStarfield(ctx, rect) {
|
||||
ensureStars(rect);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(3,4,8,0.92)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
const cx = rect.x + rect.w / 2;
|
||||
const cy = rect.y + rect.h / 2;
|
||||
for (const s of state.stars) {
|
||||
const scale = 280 / (s.z * rect.w);
|
||||
const x = cx + s.x * scale;
|
||||
const y = cy + s.y * scale;
|
||||
const len = Math.max(4, 18 * (1 - s.z));
|
||||
const dx = (s.x * scale) * 0.02;
|
||||
const dy = (s.y * scale) * 0.02;
|
||||
ctx.strokeStyle = `hsl(${s.hue}, 90%, 65%)`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - dx, y - dy);
|
||||
ctx.lineTo(x + dx, y + dy);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = `hsl(${s.hue}, 90%, 70%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(1, len * 0.08), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawDvd(ctx, rect) {
|
||||
ensureLogo();
|
||||
const { ball } = state;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(5,5,10,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
|
||||
if (state.logo && state.logoReady) {
|
||||
const x = ball.x - ball.w / 2;
|
||||
const y = ball.y - ball.h / 2;
|
||||
ctx.filter = `hue-rotate(${ball.hue || 0}deg)`;
|
||||
ctx.drawImage(state.logo, x, y, ball.w, ball.h);
|
||||
ctx.filter = 'none';
|
||||
} else {
|
||||
// Fallback: simples Logo
|
||||
const radius = Math.min(ball.w, ball.h) * 0.18;
|
||||
const x = ball.x - ball.w / 2;
|
||||
const y = ball.y - ball.h / 2;
|
||||
const col = ball.color || '#ff0080';
|
||||
ctx.fillStyle = col;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + ball.w - radius, y);
|
||||
ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius);
|
||||
ctx.lineTo(x + ball.w, y + ball.h - radius);
|
||||
ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h);
|
||||
ctx.lineTo(x + radius, y + ball.h);
|
||||
ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('DVD', ball.x, ball.y);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawClock(ctx, rect, nowTs) {
|
||||
const d = new Date();
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
const radius = Math.min(rect.w, rect.h) * 0.48;
|
||||
const baseDot = Math.max(1.5, Math.round(radius * 0.03));
|
||||
const ringDot = baseDot * 0.7;
|
||||
const glyphDot = baseDot * 1.3;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8,8,12,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.translate(centerX, centerY);
|
||||
|
||||
// seconds ring + 5s markers
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const ang = (Math.PI * 2 * i) / 60 - Math.PI / 2;
|
||||
const r = radius * 0.88;
|
||||
const filled = i <= secs;
|
||||
const cx = Math.cos(ang) * r;
|
||||
const cy = Math.sin(ang) * r;
|
||||
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
||||
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
||||
if (filled) drawLed(ctx, cx, cy, ringDot, glow, color);
|
||||
else {
|
||||
ctx.fillStyle = 'rgba(120,120,120,0.25)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, ringDot, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
if (i % 5 === 0) {
|
||||
const outerR = r + ringDot * 3.2;
|
||||
const ocx = Math.cos(ang) * outerR;
|
||||
const ocy = Math.sin(ang) * outerR;
|
||||
drawLed(ctx, ocx, ocy, ringDot, glow, color);
|
||||
}
|
||||
}
|
||||
|
||||
// center time (HH:MM) as dot-matrix
|
||||
const timeStr = `${pad2(hours)}:${pad2(mins)}`;
|
||||
const glyphSize = radius * 0.32;
|
||||
const timeSpacing = 0.9;
|
||||
const timeY = -(glyphSize * 1.2) / 2; // vertikal zentriert auf der Mittelachse
|
||||
const blinkOn = isSecondPulseActive(d);
|
||||
const extraGaps = [0, 3]; // zusätzliche Spalte zwischen den Ziffern in jedem Block
|
||||
const metrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
||||
const timeX = -metrics.colonCenter; // Doppelpunkt auf Mittelpunkt legen
|
||||
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
||||
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
||||
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot * 1.1, timeSpacing, blinkOn ? (glyphDot * 1.1) : 0, extraGaps, glow, color);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawClockDigital(ctx, rect) {
|
||||
const d = new Date();
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const day = d.getDate();
|
||||
const month = d.getMonth() + 1;
|
||||
const year = d.getFullYear();
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
const radius = Math.min(rect.w, rect.h) * 0.45;
|
||||
const baseDot = Math.max(1.5, Math.round(radius * 0.028));
|
||||
const glyphDot = baseDot * 1.3;
|
||||
const glyphSize = radius * 0.32;
|
||||
const timeSpacing = 0.9;
|
||||
const blinkOn = isSecondPulseActive(d);
|
||||
const extraGaps = [0, 3];
|
||||
const color = normalizeColor(config?.SCREENSAVER_LED_COLOR);
|
||||
const glow = config?.SCREENSAVER_LED_GLOW !== false;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8,8,12,0.9)';
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.translate(centerX, centerY);
|
||||
|
||||
const timeStr = `${pad2(hours)}:${pad2(mins)}:${pad2(Math.floor(secs))}`;
|
||||
const dateStr = `${pad2(day)}.${pad2(month)}.${year}`;
|
||||
const dateSize = glyphSize * 0.8;
|
||||
const dateGaps = [1, 4]; // nach DD und MM
|
||||
const timeMetrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
|
||||
const dateMetrics = measureTextLayout(dateStr, dateSize, timeSpacing, dateGaps);
|
||||
const totalH = glyphSize * 1.2 + dateSize * 1.2 + glyphSize * 0.25;
|
||||
const startY = -totalH / 2;
|
||||
const timeX = -timeMetrics.colonCenter;
|
||||
const timeY = startY;
|
||||
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot, timeSpacing, blinkOn ? glyphDot : 0, extraGaps, glow, color);
|
||||
|
||||
const dateX = -dateMetrics.width / 2;
|
||||
const dateY = startY + glyphSize * 1.2 + glyphSize * 0.25;
|
||||
drawDotText(ctx, dateStr, dateX, dateY, dateSize, glyphDot * 0.9, timeSpacing, 0, dateGaps, glow, color);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
return {
|
||||
markActivity,
|
||||
markAudioActivity,
|
||||
updateAndRender,
|
||||
triggerTest() { state.force = true; state.lastSignalTs = now(); },
|
||||
setIdleMinutes(min) { if (Number.isFinite(min)) config.SCREENSAVER_IDLE_MIN = min; },
|
||||
setActivityThreshold(db) { if (Number.isFinite(db)) config.SCREENSAVER_ACTIVITY_DB = db; },
|
||||
setEnabled(val) {
|
||||
state.enabled = !!val;
|
||||
config.SCREENSAVER_ENABLED = state.enabled;
|
||||
if (!state.enabled && state.active) setActive(false);
|
||||
},
|
||||
setColor(val) {
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
config.SCREENSAVER_LED_COLOR = val;
|
||||
}
|
||||
},
|
||||
setMode(mode) {
|
||||
const m = mode === 'starfield'
|
||||
? 'starfield'
|
||||
: (mode === 'clock'
|
||||
? 'clock'
|
||||
: (mode === 'black'
|
||||
? 'black'
|
||||
: (mode === 'lines' ? 'lines' : 'dvd')));
|
||||
state.mode = m;
|
||||
config.SCREENSAVER_MODE = m;
|
||||
if (m === 'starfield') {
|
||||
state.stars = [];
|
||||
} else if (m === 'clock') {
|
||||
// no special init needed
|
||||
} else if (m === 'lines') {
|
||||
state.lines = [];
|
||||
} else if (m === 'dvd') {
|
||||
randomizeBall({ x: 0, y: 0, w: 1, h: 1 });
|
||||
}
|
||||
},
|
||||
isEnabled: () => state.enabled !== false && config?.SCREENSAVER_ENABLED !== false,
|
||||
isActive: () => state.active,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Dot glyph rendering for clock -----------------------------------------
|
||||
const DOT_FONT = {
|
||||
'0': [
|
||||
'01110',
|
||||
'10001',
|
||||
'10011',
|
||||
'10101',
|
||||
'11001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'1': [
|
||||
'00100',
|
||||
'01100',
|
||||
'00100',
|
||||
'00100',
|
||||
'00100',
|
||||
'00100',
|
||||
'01110',
|
||||
],
|
||||
'2': [
|
||||
'01110',
|
||||
'10001',
|
||||
'00001',
|
||||
'00110',
|
||||
'01000',
|
||||
'10000',
|
||||
'11111',
|
||||
],
|
||||
'3': [
|
||||
'11110',
|
||||
'00001',
|
||||
'00001',
|
||||
'01110',
|
||||
'00001',
|
||||
'00001',
|
||||
'11110',
|
||||
],
|
||||
'4': [
|
||||
'00010',
|
||||
'00110',
|
||||
'01010',
|
||||
'10010',
|
||||
'11111',
|
||||
'00010',
|
||||
'00010',
|
||||
],
|
||||
'5': [
|
||||
'11111',
|
||||
'10000',
|
||||
'11110',
|
||||
'00001',
|
||||
'00001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'6': [
|
||||
'00110',
|
||||
'01000',
|
||||
'10000',
|
||||
'11110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'7': [
|
||||
'11111',
|
||||
'00001',
|
||||
'00010',
|
||||
'00100',
|
||||
'01000',
|
||||
'01000',
|
||||
'01000',
|
||||
],
|
||||
'8': [
|
||||
'01110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01110',
|
||||
],
|
||||
'9': [
|
||||
'01110',
|
||||
'10001',
|
||||
'10001',
|
||||
'01111',
|
||||
'00001',
|
||||
'00010',
|
||||
'01100',
|
||||
],
|
||||
':': [
|
||||
'0',
|
||||
'1',
|
||||
'0',
|
||||
'0',
|
||||
'1',
|
||||
'0',
|
||||
'0',
|
||||
],
|
||||
};
|
||||
const DOT_COLS = DOT_FONT['0'][0].length;
|
||||
|
||||
function drawDotText(ctx, text, x, y, glyphSize, dotRadius, spacingFactor = 0.9, colonDotRadiusOverride = null, extraGapIndices = [], glow = true, color = 'rgb(255,0,0)') {
|
||||
let cursorX = x;
|
||||
const glyphW = glyphSize;
|
||||
const glyphH = glyphSize * 1.2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const useDot = (ch === ':' && colonDotRadiusOverride !== null) ? colonDotRadiusOverride : dotRadius;
|
||||
drawDotChar(ctx, ch, cursorX, y, glyphW, glyphH, useDot, glow, color);
|
||||
cursorX += glyphW * spacingFactor;
|
||||
if (extraGapIndices.includes(i)) {
|
||||
cursorX += glyphW / DOT_COLS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function measureTextLayout(text, glyphSize, spacingFactor, extraGapIndices = []) {
|
||||
const glyphW = glyphSize;
|
||||
const colonIndex = text.indexOf(':');
|
||||
let cursor = 0;
|
||||
let colonCenter = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (i === colonIndex) {
|
||||
colonCenter = cursor + glyphW * 0.5;
|
||||
}
|
||||
cursor += glyphW * spacingFactor;
|
||||
if (extraGapIndices.includes(i)) cursor += glyphW / DOT_COLS;
|
||||
}
|
||||
return { width: cursor, colonCenter };
|
||||
}
|
||||
|
||||
function drawDotChar(ctx, ch, x, y, w, h, dotRadius, glow = true, color = 'rgb(255,0,0)') {
|
||||
const rows = DOT_FONT[ch] || DOT_FONT['0'];
|
||||
const cols = rows[0].length;
|
||||
const r = Math.min(dotRadius, Math.max(1, Math.min(w, h) * 0.04));
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows.length;
|
||||
for (let row = 0; row < rows.length; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
if (rows[row][col] === '1') {
|
||||
const cx = x + col * cellW + cellW / 2;
|
||||
const cy = y + row * cellH + cellH / 2;
|
||||
drawLed(ctx, cx, cy, r, glow, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawLed(ctx, cx, cy, r, glow = true, color = 'rgb(255,0,0)') {
|
||||
if (r <= 0) return;
|
||||
if (glow) {
|
||||
const haloR = r * 2.2;
|
||||
const midR = r * 1.4;
|
||||
ctx.fillStyle = colorWithAlpha(color, 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = colorWithAlpha(color, 0.35);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, midR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
function isSecondPulseActive(date, pulseMs = 500) {
|
||||
return date.getMilliseconds() < pulseMs;
|
||||
}
|
||||
|
||||
function normalizeColor(val) {
|
||||
if (typeof val !== 'string' || !val.trim()) return '#ff0000';
|
||||
return val.trim();
|
||||
}
|
||||
|
||||
function colorWithAlpha(color, alpha) {
|
||||
const c = normalizeColor(color);
|
||||
if (/^#([0-9a-fA-F]{6})$/.test(c)) {
|
||||
const r = parseInt(c.slice(1, 3), 16);
|
||||
const g = parseInt(c.slice(3, 5), 16);
|
||||
const b = parseInt(c.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
if (/^rgb\(/i.test(c)) {
|
||||
const nums = c.match(/(\d+\.?\d*)/g)?.slice(0, 3) || [255, 0, 0];
|
||||
return `rgba(${nums[0]},${nums[1]},${nums[2]},${alpha})`;
|
||||
}
|
||||
return `rgba(255,0,0,${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const FRAME_COLOR = '#00e7ff';
|
||||
export const FRAME_COLOR_DIM = 'rgba(0,231,255,0.4)';
|
||||
export const PANEL_BG = 'rgba(5,5,11,0.75)';
|
||||
export const HEADER_BG = 'rgba(5,5,11,0.9)';
|
||||
export const GRID_MAJOR_COLOR = '#1e2a35';
|
||||
export const GRID_MINOR_COLOR = '#131b24';
|
||||
export const LABEL_COLOR = '#8fd3d4';
|
||||
export const MID_COLOR = '#ffe066';
|
||||
export const WARN_COLOR = '#ff3b3b';
|
||||
export const OK_COLOR = '#34d399';
|
||||
export const DEFAULT_TOP_INSET = 70;
|
||||
@@ -0,0 +1,318 @@
|
||||
// core/utils.js — generische Helfer (ohne Seiteneffekte)
|
||||
// Hinweis: Viele Funktionen nehmen CONFIG/Nyquist als Parameter, damit utils keine
|
||||
// harte Abhängigkeit auf config.js haben. So bleiben die Module austauschbar.
|
||||
|
||||
// --- Math & Guards -----------------------------------------------------------
|
||||
export const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
||||
|
||||
export function clampPow2(v, min = 2048, max = 16384) {
|
||||
const allowed = [2048, 4096, 8192, 16384].filter(n => n >= min && n <= max);
|
||||
return allowed.includes(v) ? v : Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
const nowTime = () => {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
export function createPeakHoldState(initial = -160, now = nowTime(), holdMs = 0) {
|
||||
const startValue = Number.isFinite(initial) ? initial : -160;
|
||||
const tNow = Number.isFinite(now) ? now : nowTime();
|
||||
const span = Math.max(0, Number.isFinite(holdMs) ? holdMs : 0);
|
||||
return {
|
||||
value: startValue,
|
||||
holdUntil: tNow + span,
|
||||
lastTs: tNow,
|
||||
};
|
||||
}
|
||||
|
||||
export function stepPeakHold(current, state, now = nowTime(), opts = {}) {
|
||||
const holdMs = Math.max(0, Number.isFinite(opts.holdMs) ? opts.holdMs : 0);
|
||||
const decayDbPerS = Math.max(0, Number.isFinite(opts.decayDbPerS) ? opts.decayDbPerS : 0);
|
||||
const floor = Number.isFinite(opts.floor) ? opts.floor : -160;
|
||||
const riseThreshold = Number.isFinite(opts.riseThreshold) ? opts.riseThreshold : 0.2;
|
||||
const tNow = Number.isFinite(now) ? now : nowTime();
|
||||
const sample = Number.isFinite(current) ? current : floor;
|
||||
|
||||
if (!state || typeof state !== 'object') {
|
||||
state = createPeakHoldState(floor, tNow, holdMs);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(state.value)) state.value = floor;
|
||||
if (!Number.isFinite(state.lastTs)) state.lastTs = tNow;
|
||||
if (!Number.isFinite(state.holdUntil)) state.holdUntil = tNow + holdMs;
|
||||
|
||||
const holdEnd = tNow + holdMs;
|
||||
if (state.holdUntil > holdEnd) state.holdUntil = holdEnd;
|
||||
|
||||
if (sample >= state.value + riseThreshold) {
|
||||
state.value = sample;
|
||||
state.holdUntil = tNow + holdMs;
|
||||
state.lastTs = tNow;
|
||||
} else if (tNow <= state.holdUntil) {
|
||||
state.lastTs = tNow;
|
||||
} else {
|
||||
const dt = Math.max(0, (tNow - state.lastTs) / 1000);
|
||||
if (dt > 0 && decayDbPerS > 0) {
|
||||
state.value = Math.max(floor, state.value - decayDbPerS * dt);
|
||||
}
|
||||
state.lastTs = tNow;
|
||||
}
|
||||
|
||||
return state.value;
|
||||
}
|
||||
|
||||
// --- dBFS Mapping ------------------------------------------------------------
|
||||
export function yFromDbfs(db, yTop, yBot, CONFIG) {
|
||||
const top = CONFIG.DBFS_TOP, bot = CONFIG.DBFS_BOTTOM;
|
||||
const d = Math.max(bot, Math.min(top, db));
|
||||
const t = (d - bot) / (top - bot);
|
||||
return yBot - t * (yBot - yTop);
|
||||
}
|
||||
|
||||
export function mapFRect(f, x0, w, nyq) {
|
||||
const f0 = 20, f1 = nyq, fx = Math.max(f, f0);
|
||||
const lx = (Math.log10(fx) - Math.log10(f0)) / (Math.log10(f1) - Math.log10(f0));
|
||||
return x0 + lx * w;
|
||||
}
|
||||
|
||||
// --- Bänder & Aggregation ----------------------------------------------------
|
||||
export function makeIECThirdOctCenters(nyq) {
|
||||
const base = [20,25,31.5,40,50,63,80,100,125,160,200,250,315,400,500,630,800,1000,1250,1600,2000,2500,3150,4000,5000,6300,8000,10000,12500,16000,20000,21000];
|
||||
return base.filter(f => f < nyq * 0.999);
|
||||
}
|
||||
|
||||
export function avgBandDB(fLo, fHi, data, nyq) {
|
||||
const n = data.length;
|
||||
const binLo = Math.max(0, Math.min(n - 1, Math.round((fLo / nyq) * n)));
|
||||
const binHi = Math.max(0, Math.min(n - 1, Math.round((fHi / nyq) * n)));
|
||||
let sum = 0, count = 0;
|
||||
for (let i = binLo; i <= binHi; i++) {
|
||||
sum += Math.pow(10, data[i] / 10);
|
||||
count++;
|
||||
}
|
||||
return count > 0 ? 10 * Math.log10(sum / count) : -160;
|
||||
}
|
||||
|
||||
// ----- Fractional octave helpers (IEC 61260-1) ------------------------------
|
||||
const BPO_VALUE_MAP = { '1_3': 3, '1_6': 6, '1_12': 12 };
|
||||
|
||||
export function resolveBpoValue(mode) {
|
||||
if (typeof mode === 'number' && Number.isFinite(mode)) return mode;
|
||||
return BPO_VALUE_MAP[String(mode)] || 6;
|
||||
}
|
||||
|
||||
export function makeFractionalOctaveBands(nyq = 24000, mode = '1_6', opts = {}) {
|
||||
const bpo = resolveBpoValue(mode);
|
||||
const fMin = Math.max(5, opts.fMin ?? 20);
|
||||
const fMax = Math.max(fMin * 1.001, Math.min(opts.fMax ?? nyq, nyq));
|
||||
const centerRef = opts.reference ?? 1000;
|
||||
const kMin = Math.ceil(bpo * Math.log2(fMin / centerRef));
|
||||
const kMax = Math.floor(bpo * Math.log2(fMax / centerRef));
|
||||
const bands = [];
|
||||
const factor = Math.pow(2, 1 / (2 * bpo));
|
||||
for (let k = kMin; k <= kMax; k++) {
|
||||
const center = centerRef * Math.pow(2, k / bpo);
|
||||
const lo = center / factor;
|
||||
const hi = center * factor;
|
||||
if (hi < fMin) continue;
|
||||
if (lo > fMax) break;
|
||||
bands.push({
|
||||
center,
|
||||
fLo: Math.max(lo, fMin),
|
||||
fHi: Math.min(hi, fMax),
|
||||
});
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
export function buildBandBinMapping(bands, nyq, binCount) {
|
||||
const result = [];
|
||||
if (!Array.isArray(bands) || !Number.isFinite(nyq) || !Number.isFinite(binCount) || binCount <= 0) {
|
||||
return result;
|
||||
}
|
||||
const binWidth = nyq / binCount;
|
||||
for (const band of bands) {
|
||||
const bins = [];
|
||||
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
|
||||
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
|
||||
let weightSum = 0;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const binStart = i * binWidth;
|
||||
const binEnd = binStart + binWidth;
|
||||
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
|
||||
if (overlap > 0) {
|
||||
bins.push({ index: i, weight: overlap });
|
||||
weightSum += overlap;
|
||||
}
|
||||
}
|
||||
if (!bins.length) {
|
||||
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
|
||||
bins.push({ index: idx, weight: 1 });
|
||||
weightSum = 1;
|
||||
}
|
||||
bins.forEach((b) => { b.weight /= weightSum || 1; });
|
||||
result.push({
|
||||
center: band.center,
|
||||
fLo: band.fLo,
|
||||
fHi: band.fHi,
|
||||
bins,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function computeBandLevels(mappedBands, freqData, opts = {}) {
|
||||
if (!Array.isArray(mappedBands) || !freqData) return [];
|
||||
const floor = Number.isFinite(opts.floor) ? opts.floor : -160;
|
||||
const weightingFn = typeof opts.weightingFn === 'function' ? opts.weightingFn : null;
|
||||
const out = new Array(mappedBands.length);
|
||||
for (let i = 0; i < mappedBands.length; i++) {
|
||||
const band = mappedBands[i];
|
||||
let energy = 0;
|
||||
for (const seg of band.bins) {
|
||||
const val = freqData[seg.index];
|
||||
if (!Number.isFinite(val)) continue;
|
||||
energy += Math.pow(10, val / 10) * seg.weight;
|
||||
}
|
||||
const weightedDb = weightingFn ? weightingFn(band.center) : 0;
|
||||
const lin = Math.max(1e-12, energy * Math.pow(10, weightedDb / 10));
|
||||
out[i] = 10 * Math.log10(lin);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function weightingDb(freq, mode = 'z') {
|
||||
if (!Number.isFinite(freq) || freq <= 0) return 0;
|
||||
const f2 = freq * freq;
|
||||
if (mode === 'a') {
|
||||
const raNum = (12194 ** 2) * f2 * f2;
|
||||
const raDen = (f2 + 20.6 ** 2) * Math.sqrt((f2 + 107.7 ** 2) * (f2 + 737.9 ** 2)) * (f2 + 12194 ** 2);
|
||||
const ra = raNum / raDen;
|
||||
return 20 * Math.log10(ra) + 2.0;
|
||||
}
|
||||
if (mode === 'c') {
|
||||
const rcNum = (12194 ** 2) * f2;
|
||||
const rcDen = (f2 + 20.6 ** 2) * (f2 + 12194 ** 2);
|
||||
const rc = rcNum / rcDen;
|
||||
return 20 * Math.log10(rc) + 0.06;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Korrelation -------------------------------------------------------------
|
||||
export function correlation(xyL, xyR, smoothPrev = 0, smooth = 0.85) {
|
||||
const n = Math.min(xyL?.length || 0, xyR?.length || 0);
|
||||
if (!n) return 0;
|
||||
let sLL = 0, sRR = 0, sLR = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = xyL[i], b = xyR[i];
|
||||
sLL += a * a; sRR += b * b; sLR += a * b;
|
||||
}
|
||||
const denom = Math.sqrt((sLL + 1e-12) * (sRR + 1e-12));
|
||||
const r = denom > 0 ? sLR / denom : 0;
|
||||
return smooth * smoothPrev + (1 - smooth) * Math.max(-1, Math.min(1, r));
|
||||
}
|
||||
|
||||
// --- Labels & Ticks ----------------------------------------------------------
|
||||
export function freqTickLabels(nyq, fMin = 20) {
|
||||
const ticks = [
|
||||
5, 10, 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
|
||||
200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
|
||||
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
|
||||
];
|
||||
const min = Math.max(5, fMin);
|
||||
return ticks.filter((f) => f >= min && f < nyq);
|
||||
}
|
||||
|
||||
export function fmtFreq(f) {
|
||||
if (f >= 1000) return (f / 1000) + 'k';
|
||||
return (f % 1 ? f : Math.round(f)).toString();
|
||||
}
|
||||
|
||||
// --- Rechteck-Helper ---------------------------------------------------------
|
||||
// --- Zeichnen: Rahmen (ruhig hier, damit Views sich nicht wiederholen) -------
|
||||
export function strokeRect(ctx, rect, color = '#00e7ff', lw = 2) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = lw;
|
||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function fillRect(ctx, rect, color) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = color; ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// dBFS Raster als Utility, damit Bars/Line denselben Look teilen
|
||||
export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
||||
const {
|
||||
colorMajor = '#1e2a35',
|
||||
colorMinor = 'rgba(30,42,53,.55)',
|
||||
labelColor = '#8fd3d4',
|
||||
freqTicks = null,
|
||||
freqMin = 20,
|
||||
refDb = null,
|
||||
refStyle = 'rgba(0,231,255,0.35)',
|
||||
refDash = [4, 3],
|
||||
} = opts;
|
||||
const yTicks = (CONFIG.Y_TICKS && CONFIG.Y_TICKS.length)
|
||||
? CONFIG.Y_TICKS
|
||||
: [9, 0, -9, -18, -27, -36, -45, -54, -63];
|
||||
|
||||
const _g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT);
|
||||
const gutterL = Number.isFinite(_g) ? Math.max(8, _g) : 14;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2; ctx.strokeRect(x0 - gutterL, y0, w + gutterL, h);
|
||||
ctx.font = 'bold 14px ui-monospace, monospace'; ctx.fillStyle = labelColor;
|
||||
|
||||
// Grid + Labels at fixed ticks
|
||||
for (const v of yTicks) {
|
||||
if (v > CONFIG.DBFS_TOP || v < CONFIG.DBFS_BOTTOM) continue;
|
||||
const y = yFromDbfs(v, y0, y0 + h, CONFIG);
|
||||
const labelY = Math.min(y0 + h - 4, Math.max(y0 + 12, y + 4));
|
||||
ctx.strokeStyle = colorMajor; ctx.lineWidth = 1.2;
|
||||
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y); ctx.stroke();
|
||||
ctx.textAlign = 'right'; ctx.fillText(String(v), (x0 - gutterL) - 6, labelY);
|
||||
}
|
||||
|
||||
// Reference line (e.g. alignment level)
|
||||
if (Number.isFinite(refDb) && refDb <= CONFIG.DBFS_TOP && refDb >= CONFIG.DBFS_BOTTOM) {
|
||||
const yRef = yFromDbfs(refDb, y0, y0 + h, CONFIG);
|
||||
ctx.strokeStyle = refStyle;
|
||||
ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, yRef);
|
||||
ctx.lineTo(x0 + w, yRef);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Frequenz-Ticks (x-Achse)
|
||||
ctx.textAlign = 'center'; ctx.fillStyle = labelColor;
|
||||
const ticks = Array.isArray(freqTicks) && freqTicks.length
|
||||
? freqTicks.filter((f) => Number.isFinite(f) && f > 0 && f < nyq)
|
||||
: freqTickLabels(nyq, freqMin);
|
||||
const minFreq = Math.max(5, freqMin);
|
||||
const logMin = Math.log10(minFreq);
|
||||
const logSpan = Math.max(1e-6, Math.log10(nyq) - logMin);
|
||||
for (const f of ticks) {
|
||||
const x = x0 + ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan) * w;
|
||||
// Linken Rand nicht doppeln: keine Vertikal-Linie auf dem linken Plot-Rand
|
||||
if (x > x0 + 0.75) {
|
||||
ctx.strokeStyle = '#131b24';
|
||||
ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke();
|
||||
}
|
||||
ctx.fillText(fmtFreq(f), x, y0 + h + 18);
|
||||
}
|
||||
ctx.textAlign = 'start';
|
||||
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user