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;
+5 -3
View File
@@ -307,7 +307,7 @@
<label>Bänder pro Oktave</label>
<select id="opt_rtaBpo" style="width:150px">
<option value="1_3">1/3 Oktave</option>
<option value="1_6" selected>1/6 Oktave</option>
<option value="1_6">1/6 Oktave</option>
<option value="1_12">1/12 Oktave</option>
</select>
<small>Filterauflösung Anzahl Bänder</small>
@@ -355,7 +355,9 @@
<select id="opt_rtaIntegration" style="width:150px">
<option value="impulse">Impulse</option>
<option value="fast" selected>Fast</option>
<option value="medium">Medium</option>
<option value="slow">Slow</option>
<option value="average">Average</option>
<option value="peak">Peak (10 ms)</option>
</select>
<small>Reaktionsmodus</small>
@@ -1024,7 +1026,7 @@
<input id="opt_ppmDinFastAttack" type="checkbox">
PPM DIN: FastAttack (sofort, nicht normgerecht)
</label>
<small>Umgeht die 5msIntegration und lässt die Anzeige sofort ansteigen (kann gefühltes AnzeigeLag durch Latenz reduzieren).</small>
<small>Umgeht die feste 10-ms-Normintegration und zeigt Sample-Peaks sofort an (kann gefühltes Anzeige-Lag reduzieren).</small>
</div>
<div class="opt">
<label class="row" style="align-items:center;gap:8px">
@@ -1841,7 +1843,7 @@
<div id="ppmDinFastWarn" class="rec-warning" style="display:none">
<div class="rec-warning-box">
<h3>PPM DIN FastAttack</h3>
<p>Dieser Modus lässt das PPM DIN sofort ansteigen (ohne 5msIntegration).</p>
<p>Dieser Sample-Peak-Sondermodus lässt das PPM DIN sofort ansteigen (ohne 10-ms-Normintegration).</p>
<p>Dadurch kann die Anzeige „schneller“ wirken, ist aber <b>nicht mehr normgerecht</b> (DIN 45406 / IEC 6026810).</p>
<p>Hinweis: Das ändert nur die AnzeigeBallistik und kompensiert keine echte AudioLatenz.</p>
<button id="ppmDinFastWarnBtn" class="btn btn-lg" type="button">Verstanden</button>
+2 -2
View File
@@ -8,7 +8,7 @@ import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js'
export const id = 'ppm-din';
const DEFAULT_DECAY_DB_PER_S = 20 / 1.7; // 20 dB in ca. 1.7 s → ~11.76 dB/s
const DEFAULT_DECAY_DB_PER_S = 20 / 1.5; // RTW/DIN: 20 dB in 1.5 s
const DEFAULT_HOLD_MS = 1000;
const DIN_SCALE = [
@@ -79,7 +79,7 @@ export function initShared(CONFIG = {}) {
},
// Offset for DIN uses PPM_DIN_OFFSET so that 0 dB on the meter aligns
// properly with the Permitted Maximum Level (PML). According to the
// DIN Type I specification, 0 dBu (≈−15 dBFS peak) should read 9 dB.
// selected alignment profile maps 0 dBu to either -6 dB or legacy -9 dB.
offset: Number(CONFIG.PPM_DIN_OFFSET) || 0,
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
+8 -8
View File
@@ -4,7 +4,7 @@ import { drawCachedStaticLayer } from './static_layer.js';
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
// meters/ppm_ebu.js — PPM (EBU) mit linearer Skala
// - Messbereich: -14 … +14 dB (Clamping/Anzeigegrenzen)
// - Messbereich: -12 … +12 dB
// - Rote Zone ab +9 dB
// - Ticks: große Marken bei -12/-8/-4/0/+4/+8/+12, kleine u. a. bei -10/-6/…/+10
// - Labels: nur für die großen Ticks; bei 0 statt "0" → "TEST"
@@ -12,7 +12,7 @@ import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js'
export const id = 'ppm-ebu';
const DEFAULT_ATTACK_MS = 10; // Type IIb typisch ~10 ms
const DEFAULT_DECAY_DB_PER_S = 8.6; // ~24 dB in 2.8 s
const DEFAULT_DECAY_DB_PER_S = 24 / 2.8;
const DEFAULT_HOLD_MS = 750;
const EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
@@ -21,10 +21,10 @@ const EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
export function initShared(CONFIG = {}) {
const now = performance.now();
// Use EBU-specific configuration values. PPM_EBU_BOTTOM defines the
// bottom of the EBU scale (default -14 dB) and PPM_EBU_HOLD_MS defines
// bottom of the EBU scale (default -12 dB) and PPM_EBU_HOLD_MS defines
// the peak-hold duration. This ensures the EBU meter aligns with
// the Type IIb specification (0 dB at AL, +9 dB red start).
const bottom = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
const bottom = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -12;
const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS) ? CONFIG.PPM_EBU_HOLD_MS : DEFAULT_HOLD_MS;
const decayCfg = Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S)
? CONFIG.PPM_EBU_DECAY_DB_PER_S
@@ -40,7 +40,7 @@ export function initShared(CONFIG = {}) {
},
_ballistics: {
// The EBU (Type IIb) PPM uses a ~10 ms attack and a decay of 24 dB
// in 2.8 s (≈8.6 dB/s).
// in 2.8 s (≈8.57 dB/s).
attackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : DEFAULT_ATTACK_MS,
decayDbPerS: decayCfg,
},
@@ -83,8 +83,8 @@ export function draw(g, rect, CONFIG = {}, shared) {
const __effOff = Number(CONFIG.PPM_EBU_OFFSET) || 0;
const __offCorr = __effOff - (shared.offset || 0);
const PPM_TOP = Number.isFinite(CONFIG.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14;
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
const PPM_TOP = Number.isFinite(CONFIG.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +12;
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -12;
const RED_START = Number.isFinite(CONFIG.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
@@ -271,7 +271,7 @@ function drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, P
topPad,
CONFIG.METER_BAR_THIN || 0.55,
CONFIG.PPM_EBU_TOP ?? 14,
CONFIG.PPM_EBU_BOTTOM ?? -14,
CONFIG.PPM_EBU_BOTTOM ?? -12,
CONFIG.PPM_EBU_RED_START ?? 9,
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
METER_HEADER_FONT,
+46 -6
View File
@@ -290,7 +290,7 @@ function syncUI() {
['opt_rtaIntegration', CONFIG.RTA_INTEGRATION || 'fast'],
['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'],
['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'],
['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2],
['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2.5],
['opt_rtaDecay', CONFIG.RTA_PEAK_DECAY_DB_PER_S ?? 20],
['opt_rtaDisplayHold', CONFIG.RTA_DISPLAY_HOLD_SEC ?? 0],
['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)],
@@ -1054,13 +1054,23 @@ function wireHandlers(env) {
}, null, null, true);
// Hold-/Decay-Parameter
h('opt_rtaEngine', v => { CONFIG.RTA_ENGINE = (v === 'iir') ? 'iir' : 'fft'; });
h('opt_rtaEngine', v => {
CONFIG.RTA_ENGINE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'iir' : ((v === 'iir') ? 'iir' : 'fft');
return CONFIG.RTA_ENGINE;
});
h('opt_rtaBpo', v => {
const allowed = new Set(['1_3', '1_6', '1_12']);
CONFIG.RTA_BPO_MODE = allowed.has(v) ? v : '1_6';
CONFIG.RTA_BPO_MODE = CONFIG.RTA_BAR_LAYOUT === 'rtw'
? '1_3'
: (allowed.has(v) ? v : '1_6');
notifyPhoenixGlobalConfig();
return CONFIG.RTA_BPO_MODE;
});
h('opt_rtaOrder', v => {
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_IIR_ORDER = 6;
return 6;
}
let val = Math.round(+v);
if (!Number.isFinite(val)) val = 4;
val = Math.max(2, Math.min(8, val));
@@ -1068,12 +1078,33 @@ function wireHandlers(env) {
CONFIG.RTA_IIR_ORDER = val;
return val;
});
h('opt_rtaFreq', v => { CONFIG.RTA_FREQ_RANGE = (v === 'lf') ? 'lf' : 'norm'; });
h('opt_rtaFreq', v => {
CONFIG.RTA_FREQ_RANGE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'norm' : ((v === 'lf') ? 'lf' : 'norm');
return CONFIG.RTA_FREQ_RANGE;
});
h('opt_rtaDisplayGainFft', v => { CONFIG.RTA_DISPLAY_GAIN_FFT_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainFft', v=>`${v} dB`);
h('opt_rtaDisplayGainIir', v => { CONFIG.RTA_DISPLAY_GAIN_IIR_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainIir', v=>`${v} dB`);
h('opt_rtaLayout', v => {
const val = (v === 'rtw') ? 'rtw' : 'iec';
CONFIG.RTA_BAR_LAYOUT = val;
if (val === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_BPO_MODE = '1_3';
CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
const forced = {
opt_rtaEngine: 'iir',
opt_rtaBpo: '1_3',
opt_rtaOrder: '6',
opt_rtaFreq: 'norm',
};
for (const [id, value] of Object.entries(forced)) {
const control = document.getElementById(id);
if (control) control.value = value;
}
notifyPhoenixGlobalConfig();
}
return val;
});
h('opt_rtaBarBaseColor', v => {
CONFIG.RTA_BAR_BASE_COLOR = v || '#ffe066';
@@ -1085,8 +1116,9 @@ function wireHandlers(env) {
CONFIG.RTA_WEIGHTING = allowed.has(v) ? v : 'z';
});
h('opt_rtaIntegration', v => {
const allowed = new Set(['impulse','fast','slow','peak']);
const allowed = new Set(['impulse','fast','medium','slow','average','peak']);
CONFIG.RTA_INTEGRATION = allowed.has(v) ? v : 'fast';
return CONFIG.RTA_INTEGRATION;
});
h('opt_rtaBallistics', v => {
const val = (v === 'peak' || v === 'both' || v === 'average') ? v : 'average';
@@ -1097,12 +1129,19 @@ function wireHandlers(env) {
const allowed = new Set(['off','auto','manual']);
CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'auto';
});
h('opt_rtaHoldTime', v => { CONFIG.RTA_PEAK_HOLD_SEC = clamp(+v, 0, 30); });
h('opt_rtaHoldTime', v => {
const requested = clamp(+v, 0, 30);
CONFIG.RTA_PEAK_HOLD_SEC = CONFIG.RTA_BAR_LAYOUT === 'rtw'
? (requested < 3.25 ? 2.5 : 4.0)
: requested;
return CONFIG.RTA_PEAK_HOLD_SEC;
});
h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); });
h('opt_rtaDisplayHold', v => { CONFIG.RTA_DISPLAY_HOLD_SEC = clamp(+v, 0, 5); });
h('opt_spectroGamma', v => {
const num = clamp(parseFloat(v), 0.3, 1.2);
CONFIG.SPECTRO_GAMMA = num;
notifyPhoenixGlobalConfig();
return num;
}, 'val_spectroGamma', (v)=>Number(v).toFixed(2));
h('opt_spectroScroll', v => {
@@ -1110,6 +1149,7 @@ function wireHandlers(env) {
const num = Number(v);
const val = allowed.has(num) ? num : 1;
CONFIG.SPECTRO_SCROLL_MODE = val;
notifyPhoenixGlobalConfig();
return val;
});
h('opt_peakHistScroll', v => {
+21 -112
View File
@@ -16,8 +16,6 @@ const METER_SLOT_SHRINK = 24;
const METER_PAD_TOP = 1;
const METER_PAD_BOTTOM = -7;
const METER_EXTRA_BOTTOM_PAD = 6;
const CORR_ATTACK_S = 1.5;
const CORR_RELEASE_S = 2.5;
const GONIO_GAIN_MIN_DB = -35;
const GONIO_GAIN_MAX_DB = 35;
const BASE_TARGET = 1.0;
@@ -26,8 +24,7 @@ const BASE_ZOOM = 1.0;
const BASE_GONIO_SCALE = (BASE_TARGET / ALIGN_PEAK) * Math.pow(10, -BASE_HEADROOM_DB / 20) * BASE_ZOOM;
const AGC_TARGET_DB = linearToDb(ALIGN_PEAK);
const CORR_SILENCE_THRESHOLD_DEFAULT = -75;
const CORR_SILENCE_HOLD_MS = 30000;
const CORR_SILENCE_DRIFT_MS = 60000;
const MAX_TRAIL_FRAMES = 48;
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
@@ -39,11 +36,6 @@ function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 1
export function init() {
return {
corrDisplayed: 0,
corrMeter: 0,
corrLastTs: 0,
corrLastValid: 0,
corrSilenceSince: 0,
gateLevel: 1,
gateLastTs: 0,
agcEnv: 1e-3,
@@ -51,6 +43,7 @@ export function init() {
agcLastTs: 0,
traceBuffer: new Float32Array(0),
lineTrails: [],
trailPool: [],
staticLayerCanvas: null,
staticLayerCtx: null,
staticLayerKey: '',
@@ -66,13 +59,7 @@ export async function render(env, state) {
const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0;
if (wakeTs && state?._lastWakeTs !== wakeTs) {
// Nach Screensaver-Wake: Korrelation sauber zurücksetzen, damit kein "alter" Wert (z.B. +1) stehen bleibt.
state._lastWakeTs = wakeTs;
state.corrDisplayed = 0;
state.corrMeter = 0;
state.corrLastTs = 0;
state.corrLastValid = 0;
state.corrSilenceSince = frameNow;
}
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
@@ -101,62 +88,20 @@ export async function render(env, state) {
: null;
renderTrace(g, state, trace, layout.scope, style, xyReady && gate.active, settings);
const silenceGateEnabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
const corrThreshold = resolveCorrThreshold(CONFIG);
const rmsL = Number.isFinite(env.audio?.rmsDb?.L) ? env.audio.rmsDb.L : -120;
const rmsR = Number.isFinite(env.audio?.rmsDb?.R) ? env.audio.rmsDb.R : -120;
const activeL = audioFresh && (!silenceGateEnabled || (rmsL >= corrThreshold));
const activeR = audioFresh && (!silenceGateEnabled || (rmsR >= corrThreshold));
let corrTarget = 0;
const canCorr = xyReady && utils?.correlation;
const zeroOnSilence = !!CONFIG?.CORR_ZERO_ON_SILENCE;
const bothSilent = !activeL && !activeR;
if (activeL && activeR && canCorr) {
// Beide Kanäle aktiv → echte Korrelation aus L/R-Samples
const corrRaw = utils.correlation(
xyData.xyL,
xyData.xyR,
Number.isFinite(state.corrDisplayed) ? state.corrDisplayed : 0,
CONFIG.CORR_SMOOTH,
);
if (Number.isFinite(corrRaw)) {
state.corrDisplayed = corrRaw;
state.corrLastValid = corrRaw;
state.corrSilenceSince = 0;
corrTarget = corrRaw;
}
} else if (activeL !== activeR) {
// Nur ein Kanal aktiv → hart auf 0, keine Hold-/Decay-Logik
state.corrSilenceSince = 0;
corrTarget = 0;
} else {
if (zeroOnSilence && bothSilent) {
// Beide still → zügig Richtung 0 (ohne Hold/Drift)
state.corrLastValid = 0;
state.corrSilenceSince = frameNow;
corrTarget = 0;
} else {
// Beide still → Hold + Drift zur Mitte
if (!state.corrSilenceSince) state.corrSilenceSince = frameNow;
corrTarget = resolveCorrTarget(state, frameNow);
}
}
const fastZero = zeroOnSilence && bothSilent;
const corrVisual = updateCorrelationDisplay(state, corrTarget, frameNow, fastZero ? true : gate.active);
if (fastZero) {
// Damit die nächste echte Korrelation nicht noch an einem alten Glättungswert "hängt".
state.corrDisplayed = corrVisual;
}
// Correlation is measured continuously in the audio DSP. Do not add a
// second, frame-rate-dependent browser integration here.
const corrVisual = Number.isFinite(audio?.correlation) ? clamp1(audio.correlation) : 0;
const corrNegativePeak = Number.isFinite(audio?.correlationNegativePeak)
? clamp1(audio.correlationNegativePeak)
: 0;
drawCorrelationBar(
g,
layout.plot.x + layout.plot.w / 2,
layout.plot.y + layout.plot.h - 28,
Math.round(Math.min(layout.scope.w * 0.75, layout.plot.w - 50)),
18,
corrVisual
corrVisual,
corrNegativePeak,
);
if (slots.length) {
@@ -755,52 +700,6 @@ function getNow() {
return Date.now();
}
function updateCorrelationDisplay(state, target, nowTs, gateActive = true) {
const prev = Number.isFinite(state.corrMeter) ? state.corrMeter : target;
const lastTs = Number.isFinite(state.corrLastTs) ? state.corrLastTs : nowTs;
const dt = Math.max(0, (nowTs - lastTs) / 1000);
if (!dt) {
state.corrMeter = clamp1(target);
state.corrLastTs = nowTs;
return state.corrMeter;
}
const rising = Math.abs(target) > Math.abs(prev);
// Schneller einrasten, wenn Gate aktiv ist; etwas zäher auf Null auslaufen, wenn Stille.
const tauAttack = gateActive ? 0.08 : CORR_ATTACK_S;
const tauRelease = gateActive ? 0.35 : CORR_RELEASE_S;
const tau = rising ? tauAttack : tauRelease;
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
const next = clamp1(prev + (target - prev) * alpha);
state.corrMeter = next;
state.corrLastTs = nowTs;
return next;
}
function resolveCorrThreshold(CONFIG = {}) {
if (Number.isFinite(CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS)) {
return CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS;
}
if (Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)) {
return CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS;
}
return CORR_SILENCE_THRESHOLD_DEFAULT;
}
function resolveCorrTarget(state, nowTs) {
const lastValid = Number.isFinite(state.corrLastValid) ? state.corrLastValid : 0;
if (!state.corrSilenceSince) {
return lastValid;
}
const holdMs = CORR_SILENCE_HOLD_MS;
const driftMs = CORR_SILENCE_DRIFT_MS;
const elapsed = Math.max(0, nowTs - state.corrSilenceSince);
if (elapsed <= holdMs) {
return lastValid;
}
const t = Math.min(1, (elapsed - holdMs) / Math.max(1, driftMs));
return lastValid * (1 - t);
}
function computeAgcGain(state, xyData, nowTs) {
const attackTau = 0.001; // 1 ms
const releaseDbPerS = 10;
@@ -857,7 +756,7 @@ function dbToLinear(db) {
// -----------------------------------------------------------------------------
// Correlation bar helper
function drawCorrelationBar(g, centerX, y, w, h, val) {
function drawCorrelationBar(g, centerX, y, w, h, val, negativePeak) {
const x = Math.floor(centerX - w / 2);
g.save();
g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x, y, w, h);
@@ -886,6 +785,16 @@ function drawCorrelationBar(g, centerX, y, w, h, val) {
g.lineWidth = 1;
g.strokeRect(cubeX, cubeY, cubeSize, cubeSize);
g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke();
if (Number.isFinite(negativePeak) && negativePeak < 0.999) {
const markerX = clampCenter(mid + (clamp1(negativePeak) * 0.5) * w);
g.fillStyle = WARN_COLOR;
g.beginPath();
g.moveTo(markerX, y - 1);
g.lineTo(markerX - 5, y - 7);
g.lineTo(markerX + 5, y - 7);
g.closePath();
g.fill();
}
g.restore();
}
+10 -8
View File
@@ -71,14 +71,15 @@ export async function render(env, state) {
const nyq = audio.nyq || 24000;
const analyser = audio.getAnalyser?.();
const buf = audio.getFreqBuffer?.();
const useIirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir';
const rtaData = typeof audio.getRtaData === 'function'
? audio.getRtaData()
: null;
const useIirEngine = rtaData?.engine === 'iir'
|| ((CONFIG.RTA_ENGINE || 'fft') === 'iir');
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw')
? selectLocalRtwPacket(nativeRtaPacket, CONFIG.RTA_BPO_MODE || '1_6')
? selectLocalRtwPacket(nativeRtaPacket, nativeRtaPacket.bpo || '1_3')
: nativeRtaPacket;
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
@@ -177,9 +178,9 @@ export async function render(env, state) {
} else {
const integrated = baseLevels;
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
: displayBase;
// Native IIR values already contain the selected power-domain
// integration. A second browser attack/hold stage would falsify it.
const display = displayBase;
applyPeakHold(state, integrated, CONFIG, range);
state.displayLevels = display;
state.currentRange = range;
@@ -320,10 +321,11 @@ function resetState(state) {
function ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, rtaPacket) {
const freqRange = CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm';
const bpo = CONFIG.RTA_BPO_MODE || '1_6';
const engine = CONFIG.RTA_ENGINE || 'fft';
const layoutMode = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
const rtaCenters = (layoutMode === 'rtw') ? getRtwCenters(bpo) : null;
const bpo = layoutMode === 'rtw' ? (rtaPacket?.bpo || '1_3') : (CONFIG.RTA_BPO_MODE || '1_6');
const engine = rtaPacket?.engine || CONFIG.RTA_ENGINE || 'fft';
const packetCenters = isVectorLike(rtaPacket?.centers) ? Array.from(rtaPacket.centers) : null;
const rtaCenters = (layoutMode === 'rtw') ? (packetCenters || getRtwCenters(bpo)) : null;
const centerKey = rtaCenters
? `${rtaCenters.length}:${rtaCenters[0]}:${rtaCenters[rtaCenters.length - 1]}`
: 'static';
+124 -33
View File
@@ -15,6 +15,9 @@ const SMOOTHING_MAX = 0.2;
const TIME_GRID_SPACING = 100;
const SPECTROGRAM_ID = 'spectrogram';
const SPECTRO_RENDER_SCALE = 0.85;
const BASE_SCROLL_CSS_PX_PER_SECOND = 60;
const WORKER_WATCHDOG_MS = 750;
const MAX_SCROLL_COLUMNS_PER_DRAW = 64;
const SUPPORTS_WORKER = typeof window !== 'undefined'
&& typeof Worker !== 'undefined'
&& typeof HTMLCanvasElement !== 'undefined'
@@ -38,6 +41,13 @@ export function init() {
useWorker: false, // wird im Render je nach Config gesetzt
worker: null,
workerReady: false,
workerBusy: false,
workerMessageId: 0,
workerWatchdog: null,
pendingWorkerColumn: null,
pendingWorkerRepeat: 0,
pendingWorkerDropped: 0,
workerStats: { sent: 0, drawn: 0, dropped: 0, restarts: 0, lastDrawMs: 0 },
offscreenWidth: 0,
offscreenHeight: 0,
spectroCanvasEl: null,
@@ -93,6 +103,10 @@ export async function render(env, state) {
}
// Config-Flag schaltet Worker ein (default true), wenn Offscreen verfügbar
state.useWorker = SUPPORTS_WORKER && !!env.config?.SPECTRO_USE_WORKER;
if (!state.useWorker && state.worker) {
teardownWorker(state);
removeSpectroCanvasElement(state);
}
state._dbgNextLog = state._dbgNextLog || 0;
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
@@ -165,6 +179,7 @@ export async function render(env, state) {
}
const scrollRate = resolveScrollRate(CONFIG);
const outputPixelScale = state.useWorker ? (plotWpx / Math.max(1, plotW)) : 1;
let spectroDirty = true;
if (hasPhoenixSpectro) {
spectroDirty = phoenixSpectroSeq !== (state.lastPhoenixSpectroSeq || 0);
@@ -173,15 +188,19 @@ export async function render(env, state) {
let columnsToEmit = 0;
if (hasPhoenixSpectro) {
if (spectroDirty) {
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate;
while (state.scrollAccumulator >= 1) {
columnsToEmit += 1;
state.scrollAccumulator -= 1;
}
if (columnsToEmit < 1) {
columnsToEmit = 1;
state.scrollAccumulator = 0;
}
const previousSeq = Number(state.lastPhoenixSpectroSeq || 0);
const sequenceDelta = previousSeq > 0
? Math.max(1, phoenixSpectroSeq - previousSeq)
: 1;
const fftSize = Number(env.audio?.phoenixSpectroMeta?.fftSize) || freqBuf.length * 2;
const pixelsPerSourceFrame = spectrogramPixelsPerSourceFrame(scrollRate, fs, fftSize);
const scrollStep = stepScrollAccumulator(
state.scrollAccumulator,
pixelsPerSourceFrame * outputPixelScale,
sequenceDelta,
);
state.scrollAccumulator = scrollStep.accumulator;
columnsToEmit = scrollStep.columns;
}
} else {
const nowTs = performance.now();
@@ -206,15 +225,11 @@ export async function render(env, state) {
if (columnsToEmit > 0 && spectroDirty) {
const column = buildColumnData(state, freqBuf, range.bottom);
const columns = [];
for (let i = 0; i < columnsToEmit; i++) {
columns.push(i === 0 ? column : new Float32Array(column));
}
if (state.useWorker && state.workerReady) {
columns.forEach(col => sendColumnToWorker(state, col));
if (state.useWorker) {
queueColumnForWorker(state, column, columnsToEmit);
} else {
columns.forEach(col => writeColumnToHistory(state, col));
const repeat = Math.min(state.width, columnsToEmit);
for (let index = 0; index < repeat; index++) writeColumnToHistory(state, column);
}
}
@@ -249,7 +264,7 @@ function setupWorker(state, widthPx, heightPx, range, gamma, freqBounds) {
worker.onmessage = (event) => handleWorkerMessage(state, event);
worker.onerror = () => {
state.useWorker = false;
teardownWorker(state);
restartWorker(state);
};
const offscreen = canvasEl.transferControlToOffscreen();
state.spectroCanvasEl = canvasEl;
@@ -310,6 +325,7 @@ function setupWorker(state, widthPx, heightPx, range, gamma, freqBounds) {
}
function teardownWorker(state) {
clearWorkerWatchdog(state);
if (state.worker) {
try {
state.worker.postMessage({ type: 'dispose' });
@@ -320,39 +336,97 @@ function teardownWorker(state) {
}
state.worker = null;
state.workerReady = false;
state.spectroCanvasEl = null;
state.spectroCanvasTransferred = false;
state.workerBusy = false;
state.pendingWorkerColumn = null;
state.pendingWorkerRepeat = 0;
state.pendingWorkerDropped = 0;
}
function handleWorkerMessage(state, event) {
const data = event.data || {};
if (data.type === 'ready') {
state.workerReady = true;
state.workerBusy = false;
pumpWorkerColumn(state);
} else if (data.type === 'drawn') {
if (Number(data.id) !== Number(state.workerMessageId)) return;
clearWorkerWatchdog(state);
state.workerBusy = false;
state.workerStats.drawn += Math.max(0, Number(data.repeat) || 0);
state.workerStats.lastDrawMs = Math.max(0, Number(data.drawMs) || 0);
pumpWorkerColumn(state);
} else if (data.type === 'error') {
restartWorker(state);
} else if (data.type === 'dbg' && state._dbgEnabled) {
console.debug('[spectro worker]', data);
}
}
function sendColumnToWorker(state, column) {
if (!state.worker || !column) return;
// Prüfe ob Worker bereit ist und nicht überlastet
if (!state.workerReady) return;
try {
state.worker.postMessage({ type: 'column', data: column }, [column.buffer]);
} catch (e) {
state.useWorker = false;
function queueColumnForWorker(state, column, repeat = 1) {
if (!column) return;
const requested = Math.max(1, Math.floor(Number(repeat) || 1));
if (state.pendingWorkerColumn) {
state.pendingWorkerDropped += 1;
state.workerStats.dropped += 1;
}
advanceWriteIndex(state);
state.pendingWorkerColumn = column;
state.pendingWorkerRepeat = Math.min(
Math.max(1, state.offscreenWidth || state.width || 1),
Math.max(0, state.pendingWorkerRepeat) + requested,
);
pumpWorkerColumn(state);
}
function pumpWorkerColumn(state) {
if (!state.worker || !state.workerReady || state.workerBusy || !state.pendingWorkerColumn) return;
const column = state.pendingWorkerColumn;
const repeat = Math.min(MAX_SCROLL_COLUMNS_PER_DRAW, Math.max(1, state.pendingWorkerRepeat || 1));
const dropped = Math.max(0, state.pendingWorkerDropped || 0);
state.pendingWorkerColumn = null;
// A large value means the display was suspended or overloaded. Never catch
// up for seconds; jump by a bounded amount and continue with live data.
state.pendingWorkerRepeat = 0;
state.pendingWorkerDropped = 0;
state.workerBusy = true;
state.workerMessageId += 1;
const id = state.workerMessageId;
try {
state.worker.postMessage({ type: 'column', id, data: column, repeat, dropped }, [column.buffer]);
state.workerStats.sent += repeat;
} catch (e) {
restartWorker(state);
return;
}
clearWorkerWatchdog(state);
state.workerWatchdog = setTimeout(() => {
if (!state.workerBusy || id !== state.workerMessageId) return;
restartWorker(state);
}, WORKER_WATCHDOG_MS);
// Debug: alle ~1s senden wir ein Status-Log
if (state._dbgEnabled && performance.now() >= state._dbgNextLog) {
state._dbgNextLog = performance.now() + 1000;
console.debug('[spectro main] sent column', { writeIndex: state.writeIndex });
console.debug('[spectro]', { ...state.workerStats, busy: state.workerBusy, queuedRepeat: state.pendingWorkerRepeat });
}
}
function clearWorkerWatchdog(state) {
if (!state.workerWatchdog) return;
clearTimeout(state.workerWatchdog);
state.workerWatchdog = null;
}
function restartWorker(state) {
clearWorkerWatchdog(state);
if (state.worker) {
try { state.worker.terminate(); } catch (_) {}
}
state.worker = null;
state.workerReady = false;
state.workerBusy = false;
state.workerStats.restarts += 1;
removeSpectroCanvasElement(state);
}
function ensureFallbackBuffers(state, width, height, fillDb, g) {
const needsInit = !state.history ||
state.width !== width ||
@@ -527,6 +601,23 @@ function resolveScrollRate(CONFIG) {
return 1;
}
export function stepScrollAccumulator(accumulator, rate, frameDelta = 1) {
const safeAccumulator = Number.isFinite(Number(accumulator)) ? Math.max(0, Number(accumulator)) : 0;
const safeRate = Number.isFinite(Number(rate)) ? Math.max(0.01, Math.min(64, Number(rate))) : 1;
const safeDelta = Number.isFinite(Number(frameDelta)) ? Math.max(1, Math.floor(Number(frameDelta))) : 1;
const total = safeAccumulator + safeRate * safeDelta;
const columns = Math.floor(total + 1e-9);
return { columns, accumulator: Math.max(0, total - columns) };
}
export function spectrogramPixelsPerSourceFrame(rate, sampleRate, fftSize) {
const safeRate = [0.5, 1, 2, 4, 6].includes(Number(rate)) ? Number(rate) : 1;
const safeSampleRate = Math.max(8000, Number(sampleRate) || 48000);
const safeFftSize = Math.max(1024, Math.floor(Number(fftSize) || 4096));
const hopSamples = Math.max(128, Math.floor(safeFftSize / 8));
return BASE_SCROLL_CSS_PX_PER_SECOND * safeRate * hopSamples / safeSampleRate;
}
function advanceWriteIndex(state) {
const width = Math.max(1, state.width || 1);
state.writeIndex = (state.writeIndex + 1) % width;
+145 -236
View File
@@ -1,9 +1,7 @@
// workers/spectrogram.worker.js
// Handles Spectrogram rendering off the main thread using OffscreenCanvas.
// Incremental OffscreenCanvas renderer for the spectrogram.
// One message is processed at a time and acknowledged. The sender therefore
// never needs to build an unbounded postMessage queue.
const FRAME_INTERVAL_MS = 1000 / 24;
const MAX_QUEUE_AGE_MS = 2000; // wenn länger nichts geflossen ist, trotzdem zeichnen
const MAX_PENDING_COLUMNS = 64;
const LUT_SIZE = 256;
const COLOR_STOPS = [
{ t: 0.0, color: [0, 0, 0] },
@@ -13,9 +11,6 @@ const COLOR_STOPS = [
{ t: 1.0, color: [255, 255, 255] },
];
// Pre-calculated constants for performance
const LUT_SIZE_MINUS_ONE = LUT_SIZE - 1;
const state = {
canvas: null,
ctx: null,
@@ -24,33 +19,27 @@ const state = {
topDb: -9,
bottomDb: -90,
gamma: 0.9,
fMin: 20,
fMax: 20000,
history: null,
writeIndex: 0,
imgData: null,
pixels: null,
lut: null,
lastDraw: 0,
drawTimer: null,
pendingQueue: [],
lastColumnTs: 0,
stripe: null,
stripePixels: null,
drawn: 0,
dropped: 0,
};
self.onmessage = (event) => {
const data = event.data || {};
switch (data.type) {
const message = event.data || {};
switch (message.type) {
case 'init':
handleInit(data);
init(message);
break;
case 'resize':
handleResize(data);
resize(message);
break;
case 'config':
handleConfig(data);
configure(message);
break;
case 'column':
handleColumn(data);
drawColumn(message);
break;
case 'dispose':
dispose();
@@ -60,221 +49,155 @@ self.onmessage = (event) => {
}
};
function handleInit({ canvas, width, height, topDb, bottomDb, gamma, fMin, fMax }) {
function init({ canvas, width, height, topDb, bottomDb, gamma }) {
if (!canvas) return;
state.canvas = canvas;
state.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
if (!state.ctx) {
postMessage({ type: 'error', error: 'ctx' });
postMessage({ type: 'error', error: '2d-context' });
return;
}
state.ctx.imageSmoothingEnabled = false;
state.topDb = Number.isFinite(topDb) ? topDb : state.topDb;
state.bottomDb = Number.isFinite(bottomDb) ? bottomDb : state.bottomDb;
state.fMin = Number.isFinite(fMin) ? fMin : state.fMin;
state.fMax = Number.isFinite(fMax) ? fMax : state.fMax;
state.gamma = clampGamma(gamma);
allocateBuffers(width, height);
rebuildLut();
applyConfig(topDb, bottomDb, gamma);
allocate(width, height);
clearCanvas();
postMessage({ type: 'ready' });
}
function handleResize({ width, height, fMin, fMax }) {
if (!state.canvas || !state.ctx) return;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
allocateBuffers(width, height);
if (Number.isFinite(fMin)) state.fMin = fMin;
if (Number.isFinite(fMax)) state.fMax = fMax;
function resize({ width, height }) {
if (!state.ctx) return;
const nextWidth = positiveInt(width, state.width || 1);
const nextHeight = positiveInt(height, state.height || 1);
if (nextWidth === state.width && nextHeight === state.height) return;
allocate(nextWidth, nextHeight);
clearCanvas();
postMessage({ type: 'resized', width: state.width, height: state.height });
}
function handleConfig({ topDb, bottomDb, gamma }) {
if (Number.isFinite(topDb)) state.topDb = topDb;
if (Number.isFinite(bottomDb)) state.bottomDb = bottomDb;
if (Number.isFinite(gamma)) state.gamma = clampGamma(gamma);
rebuildLut();
}
function handleColumn({ data }) {
if (!data || !(data instanceof Float32Array)) return;
if (!state.history || state.height !== data.length) {
allocateBuffers(state.width, data.length);
}
// Ringpuffer: halte nur die letzten wenige Columns, neueste gewinnt
state.pendingQueue.push(data);
while (state.pendingQueue.length > MAX_PENDING_COLUMNS) state.pendingQueue.shift();
state.lastColumnTs = performance.now();
maybeDraw();
}
function allocateBuffers(width, height) {
const clampedWidth = Math.max(1, Math.floor(width));
const clampedHeight = Math.max(1, Math.floor(height));
// Vermeide unnötige Re-allokation
if (state.history &&
state.width === clampedWidth &&
state.height === clampedHeight) {
return; // Keine Größenänderung, behalte bestehende Buffers
}
state.width = clampedWidth;
state.height = clampedHeight;
if (state.canvas) {
state.canvas.width = clampedWidth;
state.canvas.height = clampedHeight;
}
// Allokiere neuen Buffer nur wenn nötig
const neededSize = clampedWidth * clampedHeight;
if (!state.history || state.history.length !== neededSize) {
state.history = new Float32Array(neededSize);
}
state.history.fill(state.bottomDb);
state.writeIndex = 0;
if (state.ctx) {
state.imgData = state.ctx.createImageData(clampedWidth, clampedHeight);
state.pixels = state.imgData.data;
function configure({ topDb, bottomDb, gamma }) {
const before = `${state.topDb}|${state.bottomDb}|${state.gamma}`;
applyConfig(topDb, bottomDb, gamma);
const after = `${state.topDb}|${state.bottomDb}|${state.gamma}`;
if (before !== after) {
// Old pixels use the previous colour transfer function. Clearing avoids a
// misleading mixed scale after a range/gamma change.
clearCanvas();
}
}
function rebuildLut() {
state.lut = buildLut(LUT_SIZE);
function applyConfig(topDb, bottomDb, gamma) {
if (Number.isFinite(topDb)) state.topDb = Number(topDb);
if (Number.isFinite(bottomDb)) state.bottomDb = Number(bottomDb);
if (state.topDb <= state.bottomDb) state.topDb = state.bottomDb + 1;
if (Number.isFinite(gamma)) state.gamma = clamp(Number(gamma), 0.3, 1.2);
state.lut = buildLut();
}
function maybeDraw() {
if (!state.ctx || !state.imgData || !state.history) return;
const now = performance.now();
const delta = now - state.lastDraw;
function allocate(width, height) {
state.width = positiveInt(width, 1);
state.height = positiveInt(height, 1);
state.canvas.width = state.width;
state.canvas.height = state.height;
state.stripe = null;
state.stripePixels = null;
}
const tooLongNoColumn = state.lastColumnTs && (now - state.lastColumnTs > MAX_QUEUE_AGE_MS);
const readyToDraw = delta >= FRAME_INTERVAL_MS || tooLongNoColumn;
function clearCanvas() {
if (!state.ctx) return;
state.ctx.save();
state.ctx.globalCompositeOperation = 'copy';
state.ctx.fillStyle = '#000';
state.ctx.fillRect(0, 0, state.width, state.height);
state.ctx.restore();
}
if (readyToDraw) {
if (drawFrame()) {
state.lastDraw = now;
}
} else if (!state.drawTimer) {
state.drawTimer = setTimeout(() => {
state.drawTimer = null;
if (drawFrame()) {
state.lastDraw = performance.now();
}
}, FRAME_INTERVAL_MS - delta);
function drawColumn({ id, data, repeat = 1, dropped = 0 }) {
const started = performance.now();
if (!state.ctx || !(data instanceof Float32Array) || !data.length) {
postMessage({ type: 'drawn', id, drawMs: 0, repeat: 0, error: 'invalid-column' });
return;
}
const shift = Math.min(state.width, Math.max(1, Math.floor(Number(repeat) || 1)));
ensureStripe(shift);
colourStripe(data, shift);
if (shift < state.width) {
// Canvas-to-self copy is defined as if the source were captured before the
// draw, so overlapping left shifts do not smear the image.
state.ctx.drawImage(
state.canvas,
shift, 0, state.width - shift, state.height,
0, 0, state.width - shift, state.height,
);
}
state.ctx.putImageData(state.stripe, state.width - shift, 0);
state.drawn += shift;
state.dropped += Math.max(0, Number(dropped) || 0);
postMessage({
type: 'drawn',
id,
repeat: shift,
drawMs: performance.now() - started,
drawn: state.drawn,
dropped: state.dropped,
});
}
function drawFrame() {
if (!state.ctx || !state.imgData || !state.history || !state.lut) return false;
if (!flushPendingColumn()) return false;
const { width, height, history, pixels, imgData } = state;
const range = Math.max(1e-3, state.topDb - state.bottomDb);
const lut = state.lut;
const base = state.writeIndex;
const invertedHeight = height - 1;
for (let x = 0; x < width; x++) {
const srcX = (base + x) % width;
const srcXOffset = srcX;
for (let y = 0; y < height; y++) {
const dB = history[y * width + srcXOffset];
const clamped = dB < state.bottomDb ? state.bottomDb :
dB > state.topDb ? state.topDb : dB;
let norm = (clamped - state.bottomDb) / range;
norm = norm < 0 ? 0 : norm > 1 ? 1 : norm;
norm = Math.pow(norm, state.gamma);
const lutIdx = Math.min(LUT_SIZE_MINUS_ONE, Math.round(norm * LUT_SIZE_MINUS_ONE));
const destY = invertedHeight - y;
const pixelIndex = (destY * width + x) * 4;
const lutOffset = lutIdx * 3;
pixels[pixelIndex + 0] = lut[lutOffset + 0];
pixels[pixelIndex + 1] = lut[lutOffset + 1];
pixels[pixelIndex + 2] = lut[lutOffset + 2];
pixels[pixelIndex + 3] = 255;
function ensureStripe(width) {
if (state.stripe && state.stripe.width === width && state.stripe.height === state.height) return;
state.stripe = state.ctx.createImageData(width, state.height);
state.stripePixels = state.stripe.data;
}
function colourStripe(column, stripeWidth) {
const pixels = state.stripePixels;
const range = Math.max(1e-6, state.topDb - state.bottomDb);
const sourceLast = Math.max(0, column.length - 1);
const targetLast = Math.max(1, state.height - 1);
for (let targetY = 0; targetY < state.height; targetY++) {
// Source columns are low-to-high frequency; canvas rows are top-to-bottom.
const sourcePos = (state.height - 1 - targetY) * sourceLast / targetLast;
const lo = Math.floor(sourcePos);
const hi = Math.min(sourceLast, lo + 1);
const frac = sourcePos - lo;
const a = finiteOr(column[lo], state.bottomDb);
const b = finiteOr(column[hi], a);
const db = a + (b - a) * frac;
const norm = Math.pow(clamp((db - state.bottomDb) / range, 0, 1), state.gamma);
const lutIndex = Math.min(LUT_SIZE - 1, Math.round(norm * (LUT_SIZE - 1))) * 3;
for (let x = 0; x < stripeWidth; x++) {
const pixel = (targetY * stripeWidth + x) * 4;
pixels[pixel] = state.lut[lutIndex];
pixels[pixel + 1] = state.lut[lutIndex + 1];
pixels[pixel + 2] = state.lut[lutIndex + 2];
pixels[pixel + 3] = 255;
}
}
state.ctx.putImageData(imgData, 0, 0);
return true;
}
function flushPendingColumn() {
if (!state.pendingQueue.length || !state.history) return false;
const width = state.width;
const height = state.height;
let writeIdx = state.writeIndex;
const hist = state.history;
while (state.pendingQueue.length) {
const column = state.pendingQueue.shift();
if (!column) continue;
const col = (column.length === height)
? column
: normalizeColumn(column, height, state.bottomDb);
for (let y = 0; y < height; y++) {
const value = col[y];
hist[y * width + writeIdx] = Number.isFinite(value) ? value : state.bottomDb;
}
writeIdx = (writeIdx + 1) % width;
}
state.writeIndex = writeIdx;
return true;
}
function normalizeColumn(column, targetHeight, fillDb) {
const adjusted = new Float32Array(targetHeight);
const copyLength = Math.min(column.length, targetHeight);
adjusted.set(column.subarray(0, copyLength));
adjusted.fill(fillDb, copyLength);
return adjusted;
}
function buildLut(size) {
const lut = new Uint8ClampedArray(size * 3);
const sizeMinusOne = size - 1;
for (let i = 0; i < size; i++) {
const t = i / sizeMinusOne;
const [r, g, b] = colorFromStops(t);
const offset = i * 3;
lut[offset + 0] = r;
function buildLut() {
const lut = new Uint8ClampedArray(LUT_SIZE * 3);
for (let index = 0; index < LUT_SIZE; index++) {
const [r, g, b] = colorFromStops(index / (LUT_SIZE - 1));
const offset = index * 3;
lut[offset] = r;
lut[offset + 1] = g;
lut[offset + 2] = b;
}
return lut;
}
function colorFromStops(tValue) {
const t = tValue < 0 ? 0 : tValue > 1 ? 1 : tValue;
for (let i = 1; i < COLOR_STOPS.length; i++) {
const left = COLOR_STOPS[i - 1];
const right = COLOR_STOPS[i];
function colorFromStops(value) {
const t = clamp(value, 0, 1);
for (let index = 1; index < COLOR_STOPS.length; index++) {
const left = COLOR_STOPS[index - 1];
const right = COLOR_STOPS[index];
if (t <= right.t) {
const span = right.t - left.t || 1;
const rel = (t - left.t) / span;
const rel = (t - left.t) / Math.max(1e-9, right.t - left.t);
return [
Math.round(left.color[0] + rel * (right.color[0] - left.color[0])),
Math.round(left.color[1] + rel * (right.color[1] - left.color[1])),
@@ -282,41 +205,27 @@ function colorFromStops(tValue) {
];
}
}
const last = COLOR_STOPS[COLOR_STOPS.length - 1].color;
return [last[0], last[1], last[2]];
return COLOR_STOPS[COLOR_STOPS.length - 1].color.slice();
}
function clampGamma(value) {
if (!Number.isFinite(value)) return 0.9;
if (value < 0.3) return 0.3;
if (value > 1.2) return 1.2;
return value;
function positiveInt(value, fallback) {
const number = Math.floor(Number(value));
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function finiteOr(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
function clamp(value, min, max) {
return value < min ? min : value > max ? max : value;
}
function dispose() {
if (state.drawTimer) {
clearTimeout(state.drawTimer);
state.drawTimer = null;
}
if (state.history) {
state.history = null;
}
if (state.imgData) {
state.imgData = null;
}
if (state.pixels) {
state.pixels = null;
}
if (state.lut) {
state.lut = null;
}
state.pendingColumn = null;
state.stripe = null;
state.stripePixels = null;
state.lut = null;
state.canvas = null;
state.ctx = null;
if (typeof self.close === 'function') {
try { self.close(); } catch (_) {}
}
if (typeof self.close === 'function') self.close();
}