Correct audio metering and realtime displays

This commit is contained in:
Mikei386
2026-07-22 10:50:15 +02:00
parent 97352ecfce
commit 2e868bfa3a
26 changed files with 999 additions and 903 deletions
+25 -29
View File
@@ -2,6 +2,7 @@
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
import { drawCachedStaticLayer } from './static_layer.js';
import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js';
export const id = 'classic-needles';
@@ -47,22 +48,6 @@ const DIN_SCALE = [
{ db: +5, pos: 1.0000 },
];
const TP_PERCENT_SCALE = [
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0373 },
{ db: -40, frac: 0.1429 },
{ db: -35, frac: 0.2112 },
{ db: -30, frac: 0.2857 },
{ db: -25, frac: 0.3851 },
{ db: -20, frac: 0.4783 },
{ db: -15, frac: 0.6087 },
{ db: -10, frac: 0.7391 },
{ db: -5, frac: 0.8696 },
{ db: 0, frac: 1.0000 },
];
const TP_EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
const PPM_EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
@@ -122,7 +107,19 @@ export async function render(env, state) {
const raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio);
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
smoothNeedle(state, targets, now);
if (meterId === 'rms') {
// RMS ballistics are already measured sample-continuously in the backend.
// A second spring model here would add view-dependent delay and values.
state.smooth.L = targets.L;
state.smooth.R = targets.R;
state.goal.L = targets.L;
state.goal.R = targets.R;
state.vel.L = 0;
state.vel.R = 0;
state.lastTs = now;
} else {
smoothNeedle(state, targets, now);
}
// Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets)
const BOX_LEFT = 0;
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
@@ -425,24 +422,24 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
if (meterId === 'tp') {
const bottom = -60;
const top = 0;
const top = 6;
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
const mapRaw = (db) => {
const clamped = clamp(db, bottom, top);
if (clamped <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
if (clamped >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
const prev = TP_PERCENT_SCALE[i - 1];
const curr = TP_PERCENT_SCALE[i];
if (clamped <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
if (clamped >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
const prev = TRUE_PEAK_SCALE[i - 1];
const curr = TRUE_PEAK_SCALE[i];
if (clamped <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (clamped - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
};
const valueToNorm = (v) => clamp01(mapRaw(v));
return {
@@ -454,11 +451,10 @@ function getNeedleScaleDescriptor(meterId, CONFIG) {
warnColor,
normalColor,
unitLabel: 'dBTP',
majorTicks: TP_PERCENT_SCALE.map((p) => p.db),
minorTicks: TP_EXTRA_MINOR_TICKS,
majorTicks: TRUE_PEAK_SCALE.map((p) => p.db),
minorTicks: TRUE_PEAK_MINOR_TICKS,
formatMajor: (db) => {
const v = Math.abs(db);
return Number.isInteger(v) ? String(v) : v.toFixed(1);
return db > 0 ? `+${db}` : String(db);
},
valueToNorm,
};
@@ -633,7 +629,7 @@ function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) {
const isDBU = (mode === 'dbu');
if (isDBU) {
const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : 0;
rawL = (rawL - refDbfs) + refDbu;
rawR = (rawR - refDbfs) + refDbu;
}
+11 -57
View File
@@ -1,6 +1,7 @@
// views/peak_history.js - Meter-Verlauf (rechter Slot) mit RTA-Layout
import { FRAME_COLOR, GRID_MAJOR_COLOR, GRID_MINOR_COLOR, LABEL_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
import { TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js';
const PLOT = { left: 43, top: 70, right: 0, bottom: 18 };
const METER_WIDTH = 140;
@@ -47,24 +48,10 @@ const DIN_SCALE = [
{ db: +5, pos: 1.0000 },
];
const TP_PERCENT_SCALE = [
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0373 },
{ db: -40, frac: 0.1429 },
{ db: -35, frac: 0.2112 },
{ db: -30, frac: 0.2857 },
{ db: -25, frac: 0.3851 },
{ db: -20, frac: 0.4783 },
{ db: -15, frac: 0.6087 },
{ db: -10, frac: 0.7391 },
{ db: -5, frac: 0.8696 },
{ db: 0, frac: 1.0000 },
];
const VU_TICKS = [-20, -10, -7, -5, -3, 0, 1, 2, 3];
const PPM_DIN_TICKS = [-50, -40, -30, -20, -10, -5, 0, 5];
const PPM_EBU_TICKS = [-12, -8, -4, 0, 4, 8, 12];
const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0];
const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0, 3, 6];
const RMS_DBFS_TICKS = [0, -6, -12, -18, -24, -30, -40, -60];
const RMS_DBU_TICKS = [24, 20, 10, 0, -10, -20, -30, -40, -50, -60];
const LUFS_TICKS = [-50, -40, -30, -23, -18, -10, -5];
@@ -91,7 +78,6 @@ export function init() {
meterLabel: '',
neutralNorm: 0,
neutralValue: 0,
rmsSmooth: null,
staticLayer: null,
};
}
@@ -605,7 +591,6 @@ async function drawMeter(env, plotX, plotY, plotW, plotH, meterRects = null) {
function resolveMeterInfo(env, state, meterId) {
const cfg = env.config || {};
const shared = env.meters?.getState?.(meterId) || null;
if (meterId !== 'rms') state.rmsSmooth = null;
switch (meterId) {
case 'ppm-din':
@@ -615,7 +600,7 @@ function resolveMeterInfo(env, state, meterId) {
case 'tp':
return sampleTp(cfg, shared);
case 'rms':
return sampleRms(cfg, shared, state);
return sampleRms(cfg, shared);
case 'lufs':
return sampleLufs(cfg, shared);
case 'vu':
@@ -715,8 +700,8 @@ function samplePpmEbu(cfg, shared) {
}
function sampleTp(cfg, shared) {
const bottom = TP_PERCENT_SCALE[0].db;
const top = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
const bottom = TRUE_PEAK_SCALE[0].db;
const top = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db;
const effOff = Number(cfg?.TP_OFFSET_DB) || 0;
const offCorr = effOff - (shared?.offset || 0);
const warnVal = Number.isFinite(cfg?.TP_RED_START) ? cfg.TP_RED_START : -1;
@@ -740,21 +725,21 @@ function sampleTp(cfg, shared) {
});
}
function sampleRms(cfg, shared, state) {
function sampleRms(cfg, shared) {
const mode = String(cfg?.RMS_MODE || 'dbfs').toLowerCase();
const isDBU = mode === 'dbu';
const bottom = -60;
const top = isDBU ? 24 : 0;
const refDbfs = Number.isFinite(cfg?.RMS_REF_DBFS_FOR_REF_DBU) ? cfg.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(cfg?.RMS_REF_DBU) ? cfg.RMS_REF_DBU : 0;
const offset = Number.isFinite(cfg?.RMS_OFFSET_DB) ? cfg.RMS_OFFSET_DB : 0;
const warnVal = Number.isFinite(cfg?.RMS_RED_START)
? cfg.RMS_RED_START
: (isDBU ? 20 : 0);
const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom;
const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom;
const base = Math.max(vL, vR);
const smoothed = applyRmsSmoothing(state, base, cfg);
const display = isDBU ? (smoothed - refDbfs + refDbu) : smoothed;
const base = Math.max(vL, vR) + offset;
const display = isDBU ? (base - refDbfs + refDbu) : base;
const val = clamp(display, bottom, top);
const toNorm = (db) => {
const c = clamp(db, bottom, top);
@@ -778,43 +763,12 @@ function sampleRms(cfg, shared, state) {
toNorm,
range: { bottom, top },
ticks: isDBU ? RMS_DBU_TICKS : RMS_DBFS_TICKS,
signature: ['rms', mode, refDbfs, refDbu, cfg?.RMS_TC_MODE, cfg?.RMS_TC_MS].join('|'),
signature: ['rms', mode, refDbfs, refDbu, offset, cfg?.RMS_TC_MODE].join('|'),
hasValue: !!shared,
warnValue: warnVal,
});
}
function applyRmsSmoothing(state, value, cfg) {
const tauMs = resolveRmsTau(cfg);
if (!Number.isFinite(tauMs) || tauMs <= 0) {
state.rmsSmooth = null;
return value;
}
const now = performance.now();
if (!state.rmsSmooth || !Number.isFinite(state.rmsSmooth.value)) {
state.rmsSmooth = { value, lastTs: now };
return value;
}
const dt = Math.max(1 / 240, (now - (state.rmsSmooth.lastTs || now)) / 1000);
state.rmsSmooth.lastTs = now;
const alpha = 1 - Math.exp(-dt / (tauMs / 1000));
state.rmsSmooth.value += alpha * (value - state.rmsSmooth.value);
return state.rmsSmooth.value;
}
function resolveRmsTau(cfg = {}) {
const mode = String(cfg.RMS_TC_MODE || 'fast').toLowerCase();
const custom = Number(cfg.RMS_TC_MS);
if (Number.isFinite(custom) && custom > 0) return custom;
switch (mode) {
case 'impulse': return 35;
case 'slow': return 1000;
case 'none': return 0;
case 'fast':
default: return 125;
}
}
function sampleLufs(cfg, shared) {
const bottom = -50;
const top = -5;
@@ -888,7 +842,7 @@ function interpolateScale(table, db) {
}
function interpolateTp(db) {
return interpolateScale(TP_PERCENT_SCALE, db);
return interpolateScale(TRUE_PEAK_SCALE, db);
}
function deflectionFrac(db) {
+69 -351
View File
@@ -13,30 +13,19 @@ const METER_SLOTS = 3;
const METER_PAD_TOP = 30;
const METER_PAD_BOTTOM = 20;
const METER_EXTRA_BOTTOM_PAD = 6;
const TARGET_POINTS = 1024;
const COLOR_STOPS = [
{ t: 0.0, color: [0, 0, 50] },
{ t: 0.3, color: [0, 120, 180] },
{ t: 0.6, color: [0, 205, 120] },
{ t: 0.8, color: [210, 220, 0] },
{ t: 1.0, color: [255, 120, 0] },
];
const RING_DBFS = [0, -8, -16, -24, -32, -40];
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
const PHASE_GAIN_MIN_DB = -35;
const PHASE_GAIN_MAX_DB = 35;
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
const PHASE_AGC_BASE_GAIN = 1 / PHASE_ALIGN_TARGET;
const PHASE_AGC_ATTACK_S = 0.02;
const PHASE_AGC_RELEASE_DB_PER_S = 12;
const PHASE_LEVEL_THRESHOLD_DB = -60;
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
const PHASE_IDLE_DECAY = 0.85;
const PHASE_PHASE_SMOOTH_ALPHA = 0.08;
const PHASE_RADIUS_SMOOTH_ALPHA = 0.18;
const PHASE_BANDPASS_LOW_HZ = 300;
const PHASE_BANDPASS_HIGH_HZ = 5000;
const PHASE_IDLE_TAU_S = 0.103;
const PHASE_SMOOTH_TAU_S = 0.2;
const PHASE_RADIUS_TAU_S = 0.085;
const PHASE_TRAIL_FADE_MS = 2000;
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
@@ -48,32 +37,10 @@ const PHASE_SECTORS = [
{ startDeg: -180, endDeg: -60, color: 'rgba(255,120,120,0.25)' },
];
// Numerisch stabilere Hilbert-Kernel-Erstellung
const HILBERT_KERNEL = buildHilbertKernel(33);
const HILBERT_HALF = (HILBERT_KERNEL.length - 1) / 2;
// Lookup-Tables für häufig verwendete Werte
const ANGLE_COS = new Float32Array(360);
const ANGLE_SIN = new Float32Array(360);
for (let i = 0; i < 360; i++) {
const rad = (i * Math.PI) / 180;
ANGLE_COS[i] = Math.cos(rad);
ANGLE_SIN[i] = Math.sin(rad);
}
export function init() {
return {
traceBuffer: new Float32Array(0),
ampBuffer: new Float32Array(0),
filteredL: new Float32Array(0),
filteredR: new Float32Array(0),
phaseAngleBuffer: new Float32Array(0),
phaseAmplitudeBuffer: new Float32Array(0),
phaseWeightBuffer: new Float32Array(0),
phaseAnalysisSeq: -1,
phaseAnalysisLength: 0,
phaseAnalysisSampleRate: 0,
phaseAnalysisCount: 0,
phaseDisplaySeq: -1,
phaseDisplayLastTs: 0,
phaseConfidence: 0,
currentPhase: null,
currentRadius: 0,
@@ -82,9 +49,7 @@ export function init() {
phaseAgcEnv: 1e-3,
phaseAgcGainDb: 0,
phaseAgcLastTs: 0,
bandpass: createBandpassState(),
bufferGrowthCount: 0,
maxBufferSize: 0,
phaseAgcSeq: -1,
phaseTrail: [],
staticLayerCanvas: null,
staticLayerKey: '',
@@ -107,13 +72,13 @@ export async function render(env, state) {
const now = getNow();
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio);
if (xyData.ready) {
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
renderWheel(g, trace, layout.wheel, state, CONFIG, now);
const phaseData = extractPhaseData(audio);
if (phaseData.ready) {
const gainCtrl = resolvePhaseGain(state, phaseData, CONFIG);
const updated = updatePhasePointer(state, phaseData, gainCtrl.gain, CONFIG, audio, now);
renderWheel(g, updated, layout.wheel, state, CONFIG, now);
} else {
decayPhasePointer(state);
updatePhasePointer(state, phaseData, 1, CONFIG, audio, now);
trimPhaseTrail(state, CONFIG, now);
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
drawPhasePointer(g, layout.wheel, state);
@@ -327,77 +292,54 @@ function radToDeg(rad) {
return (rad * 180) / Math.PI;
}
function extractXYData(audio) {
const xyL = audio?.xyL;
const xyR = audio?.xyR;
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
function extractPhaseData(audio) {
const angle = Number(audio?.phaseAngleRad);
const level = Number(audio?.phaseLevel);
const peak = Number(audio?.phasePeak);
return {
ready,
xyL,
xyR,
length: ready ? Math.min(xyL.length, xyR.length) : 0,
sampleRate: audio?.sampleRate || 48000,
seq: Number(audio?.xySeq) || 0,
ready: !!audio?.alive,
angle: Number.isFinite(angle) ? angle : null,
coherence: clamp01(Number(audio?.phaseCoherence) || 0),
level: Number.isFinite(level) ? Math.max(0, level) : 0,
peak: Number.isFinite(peak) ? Math.max(0, peak) : 0,
seq: Number(audio?.phaseSeq) || 0,
};
}
function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
if (!xyData.ready || !xyData.length) {
decayPhasePointer(state);
return null;
}
const analysis = preparePhaseAnalysis(state, xyData);
function updatePhasePointer(state, phaseData, gain = 1, CONFIG, audio, nowMs) {
const seq = Number(phaseData?.seq) || 0;
if (seq > 0 && state.phaseDisplaySeq === seq) return false;
const now = Number.isFinite(nowMs) ? nowMs : getNow();
const previousTs = Number.isFinite(state.phaseDisplayLastTs) && state.phaseDisplayLastTs > 0
? state.phaseDisplayLastTs
: now - (1000 / 60);
const dt = Math.max(1 / 240, Math.min(0.25, (now - previousTs) / 1000));
state.phaseDisplayLastTs = now;
state.phaseDisplaySeq = seq;
const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
const ringDbValues = getRingDbValues(CONFIG);
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
: 0;
const radius = wheel.radius;
let ampIdx = 0;
let sumRadius = 0;
let levelAcc = 0;
const gainLinear = Number.isFinite(gain) ? gain : 1;
for (let i = 0; i < analysis.count; i++) {
const angle = analysis.angles[i];
const amp = analysis.amplitudes[i];
const ampScaled = Math.min(1, amp * gainLinear);
const radiusNorm = amplitudeMode === 'ppm-din'
? ppmRadiusNorm
: linearToRadiusNorm(ampScaled, ringDbValues);
ampIdx++;
sumRadius += radiusNorm;
levelAcc += amp * amp;
}
if (ampIdx > 0) {
const invCount = 1 / ampIdx;
const phaseSummary = summarizeWeightedPhase(
analysis.angles,
analysis.weights,
analysis.count,
);
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount);
const blockLevel = Math.sqrt(levelAcc * invCount);
if (blockLevel >= PHASE_LEVEL_THRESHOLD && phaseSummary) {
const avgAngle = phaseSummary.angle;
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
state.currentPhase = avgAngle;
state.currentRadius = avgRadius;
state.phaseConfidence = phaseSummary.coherence;
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
} else {
state.phaseConfidence = 0;
decayPhasePointer(state);
}
const targetRadius = amplitudeMode === 'ppm-din'
? ppmRadiusNorm
: linearToRadiusNorm(Math.min(1, phaseData.level * gainLinear), ringDbValues);
if (Number.isFinite(phaseData.angle) && phaseData.level >= PHASE_LEVEL_THRESHOLD) {
const targetPhase = wrapAngle(phaseData.angle - Math.PI / 2);
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : targetPhase;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : targetRadius;
state.currentPhase = targetPhase;
state.currentRadius = targetRadius;
state.phaseConfidence = phaseData.coherence;
state.smoothPhase = smoothAngle(prevPhase, targetPhase, smoothingAlpha(dt, PHASE_SMOOTH_TAU_S));
state.smoothRadius = lerp(prevRadius, targetRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
} else {
state.phaseConfidence = 0;
decayPhasePointer(state);
decayPhasePointer(state, dt);
}
return { count: ampIdx, radius };
return true;
}
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
@@ -650,11 +592,6 @@ function trailColorForAngle(angle) {
return '#ff7a78';
}
function clamp1(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function ringFraction(index, ringCount) {
const n = Math.max(1, ringCount | 0);
const idx = Math.max(0, Math.min(n - 1, index | 0));
@@ -722,205 +659,32 @@ function computePpmDinRadiusNorm(audio, cfg, ringDbValues) {
return dbToRadiusNorm(db, ringDbValues);
}
function createBandpassState() {
return {
sampleRate: 0,
hpAlpha: 0,
lpAlpha: 0,
channels: {
L: { hpX: 0, hpY: 0, lpY: 0 },
R: { hpX: 0, hpY: 0, lpY: 0 },
},
};
}
function ensureBandpassCoeffs(state, sampleRate) {
if (!state.bandpass) state.bandpass = createBandpassState();
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
if (state.bandpass.sampleRate === sr) return;
state.bandpass.sampleRate = sr;
state.bandpass.hpAlpha = computeHighpassAlpha(sr, PHASE_BANDPASS_LOW_HZ);
state.bandpass.lpAlpha = computeLowpassAlpha(sr, PHASE_BANDPASS_HIGH_HZ);
}
function computeHighpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, rc / (rc + dt)));
}
function computeLowpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, dt / (rc + dt)));
}
function ensureFilteredBuffers(state, length) {
const neededLength = Math.ceil(length * 1.1); // 10% Puffer für Stabilität
if (!state.filteredL || state.filteredL.length < neededLength) {
state.filteredL = new Float32Array(neededLength);
}
if (!state.filteredR || state.filteredR.length < neededLength) {
state.filteredR = new Float32Array(neededLength);
}
return { L: state.filteredL, R: state.filteredR };
}
function applyBandpassSample(sample, channelState, bandpassState) {
const hpAlpha = bandpassState.hpAlpha;
const lpAlpha = bandpassState.lpAlpha;
if (!Number.isFinite(sample)) sample = 0;
const hpY = hpAlpha * (channelState.hpY + sample - channelState.hpX);
channelState.hpY = Number.isFinite(hpY) ? hpY : 0;
channelState.hpX = sample;
const lpY = lpAlpha * hpY + (1 - lpAlpha) * channelState.lpY;
channelState.lpY = Number.isFinite(lpY) ? lpY : 0;
return lpY;
}
function preparePhaseFilteredBuffers(state, xyData) {
ensureBandpassCoeffs(state, xyData.sampleRate || 48000);
const filtered = ensureFilteredBuffers(state, xyData.length);
// Reset channel states if they contain NaN/Infinity
if (!Number.isFinite(state.bandpass.channels.L.hpY)) {
state.bandpass.channels.L = { hpX: 0, hpY: 0, lpY: 0 };
}
if (!Number.isFinite(state.bandpass.channels.R.hpY)) {
state.bandpass.channels.R = { hpX: 0, hpY: 0, lpY: 0 };
}
for (let i = 0; i < xyData.length; i++) {
filtered.L[i] = applyBandpassSample(
xyData.xyL[i],
state.bandpass.channels.L,
state.bandpass
);
filtered.R[i] = applyBandpassSample(
xyData.xyR[i],
state.bandpass.channels.R,
state.bandpass
);
}
return filtered;
}
function ensurePhaseAnalysisBuffers(state, length) {
if (!state.phaseAngleBuffer || state.phaseAngleBuffer.length < length) {
state.phaseAngleBuffer = new Float32Array(length);
}
if (!state.phaseAmplitudeBuffer || state.phaseAmplitudeBuffer.length < length) {
state.phaseAmplitudeBuffer = new Float32Array(length);
}
if (!state.phaseWeightBuffer || state.phaseWeightBuffer.length < length) {
state.phaseWeightBuffer = new Float32Array(length);
}
}
export function summarizeWeightedPhase(angles, weights, count) {
const limit = Math.max(0, Math.min(
Number.isFinite(count) ? Math.floor(count) : 0,
angles?.length || 0,
weights?.length || 0,
));
let sumSin = 0;
let sumCos = 0;
let sumWeight = 0;
for (let i = 0; i < limit; i++) {
const angle = Number(angles[i]);
const weight = Number(weights[i]);
if (!Number.isFinite(angle) || !Number.isFinite(weight) || weight <= 0) continue;
sumSin += Math.sin(angle) * weight;
sumCos += Math.cos(angle) * weight;
sumWeight += weight;
}
if (!(sumWeight > 1e-12)) return null;
const resultant = Math.hypot(sumSin, sumCos);
return {
angle: Math.atan2(sumSin, sumCos),
coherence: Math.max(0, Math.min(1, resultant / sumWeight)),
weight: sumWeight,
};
}
function preparePhaseAnalysis(state, xyData) {
const sampleRate = Math.max(1, Math.round(xyData.sampleRate) || 48000);
const seq = Number(xyData.seq) || 0;
const canReuse = seq > 0
&& state.phaseAnalysisSeq === seq
&& state.phaseAnalysisLength === xyData.length
&& state.phaseAnalysisSampleRate === sampleRate;
if (canReuse) {
return {
angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer,
weights: state.phaseWeightBuffer,
count: state.phaseAnalysisCount,
};
}
const filtered = preparePhaseFilteredBuffers(state, xyData);
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
const sampleCount = Math.ceil(xyData.length / step);
ensurePhaseAnalysisBuffers(state, sampleCount);
let count = 0;
for (let i = 0; i < xyData.length; i += step) {
const lRe = clamp1(filtered.L[i]);
const rRe = clamp1(filtered.R[i]);
const lIm = hilbertAt(filtered.L, i);
const rIm = hilbertAt(filtered.R, i);
const phaseL = Math.atan2(lIm, lRe);
const phaseR = Math.atan2(rIm, rRe);
let phaseDiff = phaseL - phaseR;
if (!Number.isFinite(phaseDiff)) continue;
phaseDiff = wrapAngle(phaseDiff);
const magL = Math.min(1, Math.hypot(lRe, lIm));
const magR = Math.min(1, Math.hypot(rRe, rIm));
state.phaseAngleBuffer[count] = phaseDiff - Math.PI / 2;
state.phaseAmplitudeBuffer[count] = Math.min(1, 0.5 * (magL + magR));
// The phase of a strong component must contribute more than the phase of
// a quiet or one-sided component. magL * magR is the magnitude of the
// complex L/R cross power for this analytic sample.
state.phaseWeightBuffer[count] = magL * magR;
count++;
}
state.phaseAnalysisSeq = seq;
state.phaseAnalysisLength = xyData.length;
state.phaseAnalysisSampleRate = sampleRate;
state.phaseAnalysisCount = count;
return {
angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer,
weights: state.phaseWeightBuffer,
count,
};
}
function decayPhasePointer(state) {
function decayPhasePointer(state, dt = 1 / 60) {
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;
const decayedRadius = prevRadius * PHASE_IDLE_DECAY;
const decayedRadius = prevRadius * Math.exp(-Math.max(0, dt) / PHASE_IDLE_TAU_S);
state.currentPhase = prevPhase;
state.currentRadius = decayedRadius;
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
state.smoothPhase = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5);
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA);
state.smoothPhase = prevSmoothPhase;
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
}
function resolvePhaseGain(state, xyData, CONFIG) {
function resolvePhaseGain(state, phaseData, CONFIG) {
const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
if (allowAgc && xyData?.ready) {
const auto = computePhaseAgcGain(state, xyData);
return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN };
if (allowAgc && phaseData?.ready) {
const seq = Number(phaseData.seq) || 0;
if (seq > 0 && state.phaseAgcSeq === seq) {
return { gainDb: state.phaseAgcGainDb, gain: dbToLinear(state.phaseAgcGainDb) };
}
const auto = computePhaseAgcGain(state, phaseData);
state.phaseAgcSeq = seq;
return { gainDb: auto.gainDb, gain: auto.gain };
}
state.phaseAgcGainDb = gainDb;
state.phaseAgcSeq = Number(phaseData?.seq) || 0;
return { gainDb, gain: dbToLinear(gainDb) };
}
@@ -949,51 +713,22 @@ function lerp(a, b, t) {
return a + (b - a) * clampedT;
}
function hilbertAt(buffer, idx) {
if (!buffer || idx < 0 || idx >= buffer.length) return 0;
let acc = 0;
for (let k = 0; k < HILBERT_KERNEL.length; k++) {
const src = idx + k - HILBERT_HALF;
if (src < 0 || src >= buffer.length) continue;
const sample = buffer[src];
const kernel = HILBERT_KERNEL[k];
if (Number.isFinite(sample) && Number.isFinite(kernel)) {
acc += sample * kernel;
}
}
return Number.isFinite(acc) ? acc : 0;
export function smoothingAlpha(dt, tau) {
const seconds = Number.isFinite(dt) ? Math.max(0, dt) : 0;
const timeConstant = Number.isFinite(tau) ? Math.max(1e-6, tau) : 1e-6;
return 1 - Math.exp(-seconds / timeConstant);
}
function buildHilbertKernel(size = 33) {
const taps = size % 2 === 0 ? size + 1 : size;
const mid = (taps - 1) / 2;
const kernel = new Float32Array(taps);
for (let n = 0; n < taps; n++) {
const k = n - mid;
// Verbesserte numerische Stabilität + even k = 0 wie idealer Hilbert-Kernel
if (Math.abs(k) < 1e-10 || k % 2 === 0) {
kernel[n] = 0;
continue;
}
const window = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / Math.max(1, taps - 1));
const value = (2 / (Math.PI * k)) * window;
// Sicherstellen, dass der Wert finite ist
kernel[n] = Number.isFinite(value) ? value : 0;
}
return kernel;
function clamp01(value) {
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}
function computePhaseAgcGain(state, xyData) {
function computePhaseAgcGain(state, phaseData) {
const now = getNow();
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
state.phaseAgcLastTs = now;
const peak = measurePhasePeak(xyData);
const peak = Math.max(1e-8, Number(phaseData?.peak) || 0);
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
if (peak >= env) {
@@ -1017,23 +752,6 @@ function computePhaseAgcGain(state, xyData) {
return { gainDb, gain: dbToLinear(gainDb) };
}
function measurePhasePeak(xyData) {
if (!xyData || !xyData.ready) return 1e-4; // Verbesserter Default-Wert
let peak = 1e-8; // Höhere Präzision
const len = Math.min(xyData.length, 1000); // Begrenzung für Performance
for (let i = 0; i < len; i++) {
const sample = Math.max(
Math.abs(xyData.xyL[i] || 0),
Math.abs(xyData.xyR[i] || 0)
);
if (sample > peak) peak = sample;
}
return Math.max(1e-8, peak); // Sicherstellen, dass nicht 0 zurückgegeben wird
}
function clampPhaseGain(db) {
let val = Number(db);
if (!Number.isFinite(val)) val = 0;
+6 -3
View File
@@ -79,7 +79,7 @@ export async function render(env, state) {
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw')
? selectLocalRtwPacket(nativeRtaPacket, nativeRtaPacket.bpo || '1_3')
? selectLocalRtwPacket(nativeRtaPacket, CONFIG.RTA_BPO_MODE || nativeRtaPacket.bpo || '1_3')
: nativeRtaPacket;
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
@@ -824,11 +824,13 @@ function buildFixedRtwBands(bpoMode, freqBounds, nyq, overrideCenters) {
return bands;
}
function selectLocalRtwPacket(packet, bpoMode) {
export function selectLocalRtwPacket(packet, bpoMode) {
if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet;
const desiredCenters = getRtwCenters(bpoMode);
if (!desiredCenters.length) return packet;
if (packet.centers.length === desiredCenters.length) return packet;
if (packet.centers.length === desiredCenters.length) {
return packet.bpo === bpoMode ? packet : { ...packet, bpo: bpoMode };
}
const keyFor = (value) => Number(value).toFixed(1);
const indexByCenter = new Map();
@@ -873,6 +875,7 @@ function selectLocalRtwPacket(packet, bpoMode) {
return {
...packet,
bpo: bpoMode,
centers: selectedCenters,
bands_avg: pickVector(packet.bands_avg || packet.bands),
bands_peak: pickVector(packet.bands_peak),