Recover analyzer DSP and realtime pipeline corrections

This commit is contained in:
Mikei386
2026-07-21 19:57:52 +02:00
parent e499bac928
commit 91aeccb938
27 changed files with 3410 additions and 924 deletions
+277 -32
View File
@@ -2,14 +2,26 @@
// 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 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;
@@ -50,30 +62,65 @@ function normalizePhoenixBaseUrl(rawValue) {
}
}
function buildPhoenixWsUrl(baseUrl) {
function buildPhoenixWsUrl(baseUrl, pathname = '/api/v1/metrics/ws') {
try {
const url = new URL(baseUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = '/api/v1/metrics/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:') + '/api/v1/metrics/ws';
return fallback.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + pathname;
}
}
function closePhoenixSocket() {
if (!phoenixSocket) return;
try {
phoenixSocket.onopen = null;
phoenixSocket.onmessage = null;
phoenixSocket.onerror = null;
phoenixSocket.onclose = null;
phoenixSocket.close();
} catch (_) {}
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;
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) {
@@ -164,14 +211,20 @@ function applyRmsActivity(env, rmsL, rmsR, sampleTs) {
if (hit) env.audio.lastSignalTs = sampleTs;
}
function copyPhoenixSpectroBins(audioState, spectro) {
if (!audioState) return;
const src = Array.isArray(spectro?.bins) ? spectro.bins : null;
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;
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);
@@ -190,7 +243,118 @@ function copyPhoenixSpectroBins(audioState, spectro) {
fftSize,
frequencyBinCount: target.length,
};
audioState.phoenixSpectroSeq = (audioState.phoenixSpectroSeq || 0) + 1;
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 (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 (!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 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;
}
if (wave) updateWaveformEnvelopeStore(env.audio, wave);
env.audio.phoenixVisualsSeq = seq;
env.requestRender?.('visuals');
}
async function updateActiveMeters(env, packet, CONFIG) {
@@ -230,21 +394,33 @@ async function syncPhoenixGlobalConfig(env, 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);
}
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);
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;
@@ -294,6 +470,8 @@ function buildPhoenixMeterPacket(frame) {
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;
@@ -302,7 +480,11 @@ function buildPhoenixMeterPacket(frame) {
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,
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,
@@ -344,10 +526,14 @@ function buildPhoenixMeterPacket(frame) {
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,
@@ -363,6 +549,57 @@ function buildPhoenixMeterPacket(frame) {
};
}
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;
@@ -625,33 +862,39 @@ function updateWaveformEnvelopeStore(audioState, payload) {
appendWaveformEnvelope(audioState.waveEnvStore, payload);
}
function buildRtaRuntimeConfig(CONFIG = {}) {
const bpoMode = CONFIG.RTA_BPO_MODE || '1_6';
export function buildRtaRuntimeConfig(CONFIG = {}) {
const bpoMode = CONFIG.RTA_BPO_MODE || '1_3';
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
const runtimeBpoMode = layout === 'rtw' ? '1_12' : bpoMode;
const rtwProfile = layout === 'rtw';
const runtimeBpoMode = rtwProfile ? '1_3' : bpoMode;
return {
engine: CONFIG.RTA_ENGINE || 'fft',
engine: rtwProfile ? 'iir' : (CONFIG.RTA_ENGINE || 'iir'),
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',
freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'),
weighting: CONFIG.RTA_WEIGHTING || 'z',
order: CONFIG.RTA_IIR_ORDER || 4,
order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6),
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),
ppmDinAttackMs: 10,
ppmDinDecayDbPerS: 20 / 1.5,
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),
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,
};
}
@@ -700,6 +943,9 @@ async function initPhoenixAudio(env) {
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 = {
@@ -852,16 +1098,15 @@ async function initPhoenixAudio(env) {
};
socket.onopen = () => {
openPhoenixSpectroSocket(baseUrl, env);
openPhoenixVisualsSocket(baseUrl, env);
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);
});
enqueuePhoenixFrame(env, CONFIG, frame);
} catch (err) {
console.warn('Phoenix metrics parse error:', err);
}
+68
View File
@@ -0,0 +1,68 @@
// Phoenix binary WebSocket packet decoders. Keep this module dependency-free so
// protocol compatibility can be tested without a browser.
export function decodePhoenixSpectroBuffer(buffer) {
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 20) return null;
const view = new DataView(buffer);
if (view.getUint32(0, true) !== 0x50585350) return null;
const seq = view.getUint32(4, true);
const sampleRate = view.getUint32(8, true);
const fftSize = view.getUint32(12, true);
const count = view.getUint32(16, true);
if (count < 1 || buffer.byteLength !== 20 + count * 4) return null;
return {
seq,
sampleRate,
fftSize,
bins: new Float32Array(buffer, 20, count),
};
}
export function decodePhoenixVisualsBuffer(buffer) {
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 32) return null;
const view = new DataView(buffer);
if (view.getUint32(0, true) !== 0x50585653 || view.getUint16(4, true) !== 1) return null;
const flags = view.getUint16(6, true);
const seq = view.getUint32(8, true);
const xyCount = view.getUint32(12, true);
const waveColumns = view.getUint32(16, true);
const waveChannels = view.getUint16(20, true);
const waveColumnSamples = view.getUint32(24, true);
const waveSampleRate = view.getUint32(28, true);
if (waveChannels > 2) return null;
const xyBytes = xyCount * 8;
const waveValues = waveColumns * waveChannels * 2;
const expectedBytes = 32 + xyBytes + waveValues * 4;
if (!Number.isSafeInteger(expectedBytes) || buffer.byteLength !== expectedBytes) return null;
let offset = 32;
let left = null;
let right = null;
if ((flags & 1) && xyCount > 0) {
left = new Float32Array(xyCount);
right = new Float32Array(xyCount);
for (let index = 0; index < xyCount; index++) {
left[index] = view.getFloat32(offset, true);
right[index] = view.getFloat32(offset + 4, true);
offset += 8;
}
} else {
offset += xyBytes;
}
let wave = null;
if ((flags & 2) && waveValues > 0 && (waveChannels === 1 || waveChannels === 2)) {
const data = new Float32Array(waveValues);
for (let index = 0; index < waveValues; index++) {
data[index] = view.getFloat32(offset + index * 4, true);
}
wave = {
data,
columns: waveColumns,
channels: waveChannels,
columnSamples: waveColumnSamples,
sampleRate: waveSampleRate,
};
}
return { seq, left, right, wave };
}
+60 -25
View File
@@ -51,20 +51,20 @@ const CONFIG = {
REALTIME_BAR_DECAY_DB_PER_S: 20,
RTA_ENGINE: 'iir', // 'fft' | 'iir'
RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf'
RTA_BPO_MODE: '1_6',
RTA_BPO_MODE: '1_3',
RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c'
RTA_DISPLAY_GAIN_FFT_DB: 20,
RTA_DISPLAY_GAIN_IIR_DB: 20,
RTA_BAR_LAYOUT: 'rtw', // 'iec' | 'rtw'
RTA_BAR_BASE_COLOR: '#ffe066',
HEADER_TEXT_COLOR: '#ffe066',
RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'slow' | 'peak'
RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'medium' | 'slow' | 'average' | 'peak'
RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both'
RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual'
RTA_PEAK_HOLD_SEC: 2,
RTA_PEAK_HOLD_SEC: 2.5,
RTA_PEAK_DECAY_DB_PER_S: 20,
RTA_DISPLAY_HOLD_SEC: 0,
RTA_IIR_ORDER: 4,
RTA_IIR_ORDER: 6,
RTA_IIR_TAU_FAST: 0.12,
RTA_IIR_TAU_SLOW: 1.0,
SPECTRO_GAMMA: 0.4,
@@ -156,20 +156,19 @@ const CONFIG = {
PPM_DIN_TOP: +5,
PPM_DIN_BOTTOM: -50,
PPM_DIN_RED_START: 0,
// DIN PPM attack and decay times: 5 ms integration and 20 dB in 1.7 s
// (≈11.8 dB/s) return, per IEC 6026810 Type I.
PPM_DIN_ATTACK_MS: 5,
// Fixed RTW/DIN profile: 10 ms integration and 20 dB return in 1.5 s.
PPM_DIN_ATTACK_MS: 10,
// Non-normative: when enabled, PPM DIN attack becomes instant (no integration).
// Useful to compensate perceived meter lag caused by device / pipeline latency.
PPM_DIN_FAST_ATTACK: false,
PPM_DIN_DECAY_DB_PER_S: 11.8,
PPM_DIN_DECAY_DB_PER_S: 20 / 1.5,
// Hold time for DIN PPM: verlängert auf 1000 ms (1 s), damit der PeakHold
// auch bei kurzen Transienten deutlich sichtbar bleibt. Gemäß
// Rundfunkpraxis werden PeakHoldStriche häufig 12 Sekunden gehalten.
PPM_DIN_HOLD_MS: 1000,
// Decay rate for the DIN PPM hold. When the held peak decays, it uses
// the same slope as the main meter (≈11.8 dB/s).
PPM_DIN_HOLD_DECAY_DB_PER_S: 11.8,
// the same slope as the main meter (≈13.33 dB/s).
PPM_DIN_HOLD_DECAY_DB_PER_S: 20 / 1.5,
// Offsets für die PPM-Meter (DIN/EBU-spezifisch). Die EBU/DIN Offsets
// ersetzen den früheren globalen PPM_OFFSET.
@@ -178,16 +177,18 @@ const CONFIG = {
PPM_REF_DBFS_PEAK_FOR_0_DBU: -15.0,
// Ballistic parameters for the EBU PPM (Type IIb). Attack ~10 ms and
// decay 24 dB in 2.8 s (≈8.6 dB/s), per IEC 6026810 Type II. A short
// decay 24 dB in 2.8 s (≈8.57 dB/s), per EBU Tech 3205-E Type IIb. A short
// peak-hold (~750 ms) helps visualise transients. Hold decay matches
// the main decay rate.
PPM_EBU_ATTACK_MS: 10,
PPM_EBU_DECAY_DB_PER_S: 8.6,
PPM_EBU_DECAY_DB_PER_S: 24 / 2.8,
PPM_EBU_HOLD_MS: 750,
PPM_EBU_HOLD_DECAY_DB_PER_S: 8.6,
PPM_EBU_HOLD_DECAY_DB_PER_S: 24 / 2.8,
CORR_SMOOTH: 0.85,
CORR_ZERO_ON_SILENCE: false,
CORR_SILENCE_THRESHOLD_RMS_DBFS: -75,
CORR_RESPONSE_S: 1.0,
CORR_RESET_TOKEN: 0,
XY_POINTS: 1024,
XY_STYLE: 'lines',
XY_SILENCE_GATE_ENABLED: true,
@@ -198,6 +199,7 @@ const CONFIG = {
GONIO_MANUAL_GAIN_DB: -5,
GONIO_AGC_ENABLED: false,
GONIO_LINE_FADE_MS: 50,
GONIO_PERSISTENCE_MODE: 'fast',
PHASE_DISPLAY_GAIN_DB: 0,
PHASE_AGC_ENABLED: false,
PHASE_TRAIL_ENABLED: true,
@@ -599,6 +601,8 @@ const PHOENIX_GLOBAL_OPTION_KEYS = Object.freeze([
'CLOCK_LED_COLOR',
'HEADER_TEXT_COLOR',
'RTA_BAR_BASE_COLOR',
'SPECTRO_GAMMA',
'SPECTRO_SCROLL_MODE',
'PEAK_HISTORY_SCROLL_MODE',
'PEAK_HISTORY_FILL_ENABLED',
'PEAK_HISTORY_FILL_INVERT',
@@ -774,6 +778,14 @@ function loadConfig(opts = {}) {
if (typeof saved.PPM_DIN_DECAY_DB_PER_S === 'number') CONFIG.PPM_DIN_DECAY_DB_PER_S = saved.PPM_DIN_DECAY_DB_PER_S;
if (typeof saved.PPM_DIN_HOLD_MS === 'number') CONFIG.PPM_DIN_HOLD_MS = saved.PPM_DIN_HOLD_MS;
if (CONFIG.RTA_BAR_LAYOUT === 'classic') CONFIG.RTA_BAR_LAYOUT = 'rtw';
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_BPO_MODE = '1_3';
CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5;
}
if (!Number.isFinite(CONFIG.DBFS_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP;
if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM;
// Clamp to sensible bounds while keeping the user-selected display range.
@@ -827,6 +839,14 @@ function loadConfig(opts = {}) {
CONFIG.LR_FRACTIONAL_DELAY_SAMPLES = Math.max(-1.5, Math.min(1.5, Number(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES)));
CONFIG.PHOENIX_BASE_URL = normalizePhoenixBaseUrlForCurrentClient(CONFIG.PHOENIX_BASE_URL || CONFIG_DEFAULTS.PHOENIX_BASE_URL || DEFAULT_PHOENIX_BASE_URL);
CONFIG.PPM_DIN_FAST_ATTACK = !!CONFIG.PPM_DIN_FAST_ATTACK;
// Migrate stale profiles: detector ballistics are standards profiles,
// not user-adjustable display preferences.
CONFIG.PPM_DIN_ATTACK_MS = 10;
CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5;
CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S = 20 / 1.5;
CONFIG.PPM_EBU_ATTACK_MS = 10;
CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8;
CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S = 24 / 2.8;
CONFIG.HIFI_PEAK_ALIGNMENT = (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero' || CONFIG.HIFI_PEAK_ALIGNMENT === 'dbfs_zero')
? 'ppm_din_zero'
: 'ppm_din_minus5';
@@ -969,6 +989,10 @@ function buildPhoenixGlobalConfigPayload() {
if (raw === 'stopwatch') return 'stopwatch';
return fallback;
};
const normalizeSpectroScroll = (value) => {
const number = Number(value);
return [0.5, 1, 2, 4, 6].includes(number) ? number : 1;
};
const normalizePeakHistoryScroll = (value) => {
const allowed = new Set([0.5, 1, 2, 4, 6]);
const num = Number(value);
@@ -990,11 +1014,11 @@ function buildPhoenixGlobalConfigPayload() {
monoInput: !!CONFIG.MONO_INPUT,
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : (CONFIG_DEFAULTS.LR_FRACTIONAL_DELAY_SAMPLES ?? 0.996),
ppmDinAttackMs: Number.isFinite(CONFIG.PPM_DIN_ATTACK_MS) ? CONFIG.PPM_DIN_ATTACK_MS : (CONFIG_DEFAULTS.PPM_DIN_ATTACK_MS ?? 5),
ppmDinDecayDbPerS: Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_DIN_DECAY_DB_PER_S ?? 11.8),
ppmDinAttackMs: 10,
ppmDinDecayDbPerS: 20 / 1.5,
ppmDinFastAttack: !!CONFIG.PPM_DIN_FAST_ATTACK,
ppmEbuAttackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : (CONFIG_DEFAULTS.PPM_EBU_ATTACK_MS ?? 10),
ppmEbuDecayDbPerS: Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : (CONFIG_DEFAULTS.PPM_EBU_DECAY_DB_PER_S ?? 8.6),
ppmEbuAttackMs: 10,
ppmEbuDecayDbPerS: 24 / 2.8,
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : (CONFIG_DEFAULTS.LUFS_I_WINDOW_MIN ?? 4),
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
ppmDinLoudnessBoxes: !!CONFIG.PPM_DIN_LOUDNESS_BOXES,
@@ -1016,6 +1040,8 @@ function buildPhoenixGlobalConfigPayload() {
clockLedColor: normalizeColor(CONFIG.CLOCK_LED_COLOR, CONFIG_DEFAULTS.CLOCK_LED_COLOR || '#ff0000'),
headerTextColor: normalizeColor(CONFIG.HEADER_TEXT_COLOR, CONFIG_DEFAULTS.HEADER_TEXT_COLOR || '#ffe066'),
rtaBarBaseColor: normalizeColor(CONFIG.RTA_BAR_BASE_COLOR, CONFIG_DEFAULTS.RTA_BAR_BASE_COLOR || '#ffe066'),
spectroGamma: Number.isFinite(CONFIG.SPECTRO_GAMMA) ? Math.max(0.3, Math.min(1.2, CONFIG.SPECTRO_GAMMA)) : 0.9,
spectroScrollMode: normalizeSpectroScroll(CONFIG.SPECTRO_SCROLL_MODE),
peakHistoryScrollMode: normalizePeakHistoryScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE),
peakHistoryFillEnabled: !!CONFIG.PEAK_HISTORY_FILL_ENABLED,
peakHistoryFillInvert: !!CONFIG.PEAK_HISTORY_FILL_INVERT,
@@ -1063,6 +1089,10 @@ function applyPhoenixGlobalConfig(payload = {}) {
if (raw === 'stopwatch') return 'stopwatch';
return fallback;
};
const normalizeSpectroScroll = (value) => {
const number = Number(value);
return [0.5, 1, 2, 4, 6].includes(number) ? number : 1;
};
const fft = Number(payload.fftSize);
if (Number.isFinite(fft)) {
CONFIG.FFT_SIZE = (fft === 2048 || fft === 4096 || fft === 8192 || fft === 16384)
@@ -1073,15 +1103,20 @@ function applyPhoenixGlobalConfig(payload = {}) {
const mode = String(payload.rtaBpoMode || '').trim();
CONFIG.RTA_BPO_MODE = (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6';
}
const dinAttack = Number(payload.ppmDinAttackMs);
if (Number.isFinite(dinAttack)) CONFIG.PPM_DIN_ATTACK_MS = Math.max(0.1, Math.min(100, dinAttack));
const dinDecay = Number(payload.ppmDinDecayDbPerS);
if (Number.isFinite(dinDecay)) CONFIG.PPM_DIN_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, dinDecay));
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_BPO_MODE = '1_3';
CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
}
const spectroGamma = Number(payload.spectroGamma);
if (Number.isFinite(spectroGamma)) CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, spectroGamma));
if ('spectroScrollMode' in payload) CONFIG.SPECTRO_SCROLL_MODE = normalizeSpectroScroll(payload.spectroScrollMode);
CONFIG.PPM_DIN_ATTACK_MS = 10;
CONFIG.PPM_DIN_DECAY_DB_PER_S = 20 / 1.5;
if ('ppmDinFastAttack' in payload) CONFIG.PPM_DIN_FAST_ATTACK = !!payload.ppmDinFastAttack;
const ebuAttack = Number(payload.ppmEbuAttackMs);
if (Number.isFinite(ebuAttack)) CONFIG.PPM_EBU_ATTACK_MS = Math.max(0.1, Math.min(100, ebuAttack));
const ebuDecay = Number(payload.ppmEbuDecayDbPerS);
if (Number.isFinite(ebuDecay)) CONFIG.PPM_EBU_DECAY_DB_PER_S = Math.max(0.1, Math.min(120, ebuDecay));
CONFIG.PPM_EBU_ATTACK_MS = 10;
CONFIG.PPM_EBU_DECAY_DB_PER_S = 24 / 2.8;
const lufsWin = Number(payload.lufsIWindowMin);
if (Number.isFinite(lufsWin)) CONFIG.LUFS_I_WINDOW_MIN = Math.max(1, Math.min(10, Math.round(lufsWin)));
if ('lufsINormEnabled' in payload) CONFIG.LUFS_I_NORM_ENABLED = !!payload.lufsINormEnabled;