Initial Phoenix analyzer

This commit is contained in:
Mikei386
2026-06-02 20:56:19 +02:00
commit e499bac928
62 changed files with 28893 additions and 0 deletions
+905
View File
@@ -0,0 +1,905 @@
// views/goniometer_rtw.js — XY-Goniometer (RTW-Look) + 3-Meter-Panel + Korrelation
import { DEFAULT_TOP_INSET, FRAME_COLOR, LABEL_COLOR, MID_COLOR, OK_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
export const id = 'goniometer-rtw';
const ALIGN_PEAK = Math.pow(10, -15 / 20); // 15 dBFS Peak ≈ 18 dBFS RMS Sine
const INNER_MARGIN = 0.05; // 5 % Innenabstand im Scope
const MIN_TRACE_POINTS = 128;
const MAX_TRACE_POINTS = 4096;
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
const METER_WIDTH = 420;
const METER_GAP = 8;
const METER_SLOTS = 3;
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;
const BASE_HEADROOM_DB = 0;
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;
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
const avail = Math.max(200, canvasWidth);
const totalGap = (n - 1) * Math.max(2, slotGap);
const usable = avail - totalGap;
return Math.floor(usable / n);
}
export function init() {
return {
corrDisplayed: 0,
corrMeter: 0,
corrLastTs: 0,
corrLastValid: 0,
corrSilenceSince: 0,
gateLevel: 1,
gateLastTs: 0,
agcEnv: 1e-3,
agcGainDb: 0,
agcLastTs: 0,
traceBuffer: new Float32Array(0),
lineTrails: [],
staticLayerCanvas: null,
staticLayerCtx: null,
staticLayerKey: '',
};
}
export function resize() {}
export function destroy() {}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
const frameNow = getNow();
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'];
const slots = slotsRaw.filter((v) => v && v !== 'none');
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : FRAME.top;
const layout = computeGoniometerLayout(rect, CONFIG, slots.length, topInset);
const settings = resolveScopeSettings(CONFIG);
const style = CONFIG.XY_STYLE === 'points' ? 'points' : 'lines';
const gate = resolveSilenceGate(env, CONFIG, state);
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio);
const lastSampleTs = Number.isFinite(env?.audio?.lastSampleTs) ? Number(env.audio.lastSampleTs) : 0;
const audioAgeMs = lastSampleTs ? (frameNow - lastSampleTs) : Infinity;
const audioFresh = Number.isFinite(audioAgeMs) && audioAgeMs >= 0 && audioAgeMs <= 250;
const xyReady = xyData.ready && audioFresh;
const agc = (CONFIG.GONIO_AGC_ENABLED && xyReady)
? computeAgcGain(state, xyData, frameNow)
: { gainDb: settings.gainDb, gain: dbToLinear(settings.gainDb) };
const appliedScale = BASE_GONIO_SCALE * agc.gain * gate.level;
const trace = xyReady
? buildTrace(state, xyData, layout.scope, appliedScale, CONFIG)
: 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;
}
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
);
if (slots.length) {
const panelSlotWidth = computePanelSlotWidth(
layout.meter.w,
slots.length,
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
);
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
drawMeterOutline(g, layout.meter);
}
}
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
if (!layer) {
drawPlotBackdrop(g, layout.plot);
drawFramework(g, layout, CONFIG);
drawScopeBackground(g, layout.scope);
drawCrosshair(g, layout.scope);
drawAxisLabels(g, layout.scope);
drawMeterPanelDividers(g, layout.meter, slotCount, CONFIG, computePanelSlotWidth(
layout.meter.w,
slotCount,
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
));
return;
}
g.drawImage(layer, 0, 0, rect.w, rect.h);
}
function ensureStaticLayer(state, rect, layout, CONFIG, slotCount) {
const w = Math.max(1, rect.w | 0);
const h = Math.max(1, rect.h | 0);
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const key = [
w, h,
layout.plot.x, layout.plot.y, layout.plot.w, layout.plot.h,
layout.scope.x, layout.scope.y, layout.scope.w, layout.scope.h,
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
slotCount,
slotGap,
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? CONFIG.AXIS_GUTTER_LEFT : 14,
].join('|');
if (state.staticLayerCanvas && state.staticLayerKey === key) {
return state.staticLayerCanvas;
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return null;
ctx.textBaseline = 'alphabetic';
ctx.font = 'bold 14px ui-monospace, monospace';
drawPlotBackdrop(ctx, layout.plot);
drawFramework(ctx, layout, CONFIG);
drawScopeBackground(ctx, layout.scope);
drawCrosshair(ctx, layout.scope);
drawAxisLabels(ctx, layout.scope);
drawMeterPanelDividers(
ctx,
layout.meter,
slotCount,
CONFIG,
computePanelSlotWidth(layout.meter.w, slotCount, slotGap)
);
state.staticLayerCanvas = canvas;
state.staticLayerCtx = ctx;
state.staticLayerKey = key;
return canvas;
}
async function drawRealtimeSlot(g, rect, slotId, CONFIG, meters) {
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const innerRect = {
x: rect.x,
y: rect.y + METER_PAD_TOP + innerOffset,
w: rect.w,
h: innerHeight,
};
try {
await meters.draw(g, innerRect, slotId, CONFIG);
} catch (e) {
console.warn(`Meter ${slotId} draw error:`, e);
}
}
// -----------------------------------------------------------------------------
// Layout & drawing helpers
function computeGoniometerLayout(rect, CONFIG = {}, slotCount = METER_SLOTS, topInset = FRAME.top) {
const plotX = FRAME.left;
const plotY = Number.isFinite(topInset) ? topInset : FRAME.top;
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
const minPlotW = 200;
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const panelSlotWidth = 100; // fixed width for better control
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
const panelSlotsWidth = n > 0 ? (panelSlotWidth * n + (n - 1) * slotGap) : 0;
let meterW = n > 0 ? Math.max(panelSlotsWidth, (n === 1 ? 160 : (n === 2 ? 280 : METER_WIDTH))) : 0;
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
if (plotW < minPlotW) {
plotW = minPlotW;
meterW = n > 0 ? (innerWidth - plotW - METER_GAP) : 0;
}
if (n > 0) meterW = Math.max(meterW, panelSlotsWidth);
if (plotW < 80) plotW = 80;
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
const plot = { x: plotX, y: plotY, w: plotW, h: plotH };
const scope = computeScopeBox(plot);
const meter = {
x: plot.x + plot.w + (n > 0 ? METER_GAP : 0),
y: plot.y,
w: meterW,
h: plotH,
};
return { plot, scope, meter };
}
function computeScopeBox(plot) {
const padding = 24;
const minDim = Math.min(plot.w, plot.h);
const tightSize = Math.max(120, minDim - padding * 2);
const size = Math.min(Math.max(140, tightSize), minDim);
const w = size;
const h = size;
const x = Math.round(plot.x + (plot.w - w) / 2);
const y = Math.round(plot.y + (plot.h - h) / 2);
const halfLimit = Math.max(24, Math.min(w, h) / 2 - 6);
const halfBase = Math.max(60, Math.min(w, h) / 2 - 18);
const half = Math.max(24, Math.min(halfBase, halfLimit));
const cx = x + w / 2;
const cy = y + h / 2;
const reach = Math.max(24, Math.min(half - 4, half * (1 - INNER_MARGIN)));
const diag = half * 0.9;
return { x, y, w, h, cx, cy, half, reach, diag };
}
function drawFramework(g, layout, CONFIG) {
drawPlotRails(g, layout.plot, CONFIG);
if (layout.meter?.w > 0) {
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h);
g.restore();
}
}
function drawPlotBackdrop(g, plot) {
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(plot.x, plot.y, plot.w, plot.h);
g.restore();
}
function drawPlotRails(g, plot, CONFIG) {
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
: 14;
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.beginPath();
g.moveTo(plot.x - gutterL, plot.y);
g.lineTo(plot.x + plot.w, plot.y);
g.stroke();
g.beginPath();
g.moveTo(plot.x - gutterL, plot.y + plot.h);
g.lineTo(plot.x + plot.w, plot.y + plot.h);
g.stroke();
g.beginPath();
g.moveTo(plot.x, plot.y);
g.lineTo(plot.x, plot.y + plot.h);
g.stroke();
g.beginPath();
g.moveTo(plot.x + plot.w, plot.y);
g.lineTo(plot.x + plot.w, plot.y + plot.h);
g.stroke();
g.restore();
}
function drawScopeBackground(g, scope) {
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(scope.x, scope.y, scope.w, scope.h);
g.restore();
}
function drawCrosshair(g, scope) {
g.save();
g.globalAlpha = 0.45;
g.strokeStyle = 'rgb(0,0,255)';
g.lineWidth = 1.5;
g.beginPath();
g.moveTo(scope.cx - scope.diag, scope.cy - scope.diag);
g.lineTo(scope.cx + scope.diag, scope.cy + scope.diag);
g.moveTo(scope.cx - scope.diag, scope.cy + scope.diag);
g.lineTo(scope.cx + scope.diag, scope.cy - scope.diag);
g.moveTo(scope.cx, scope.cy - scope.half);
g.lineTo(scope.cx, scope.cy + scope.half);
g.moveTo(scope.cx - scope.half, scope.cy);
g.lineTo(scope.cx + scope.half, scope.cy);
g.stroke();
g.restore();
}
function drawAxisLabels(g, scope) {
g.save();
g.fillStyle = FRAME_COLOR;
g.fillText('R', scope.cx + scope.diag - 10, scope.cy - scope.diag - 4);
g.fillText('L', scope.cx - scope.diag - 14, scope.cy - scope.diag - 4);
const labelOffset = scope.half + 6;
g.fillText('M', scope.cx - 6, scope.cy - labelOffset - 6);
g.fillText('S', scope.cx - labelOffset - 16, scope.cy + 6);
g.restore();
}
function drawIdleMessage(g, scope) {
g.save();
g.fillStyle = '#bcd';
g.fillText('Warte auf Audio …', scope.x + 12, scope.y + 20);
g.restore();
}
async function renderMeterPanel(g, meterLayout, slots, CONFIG, meters, panelSlotWidth) {
const slotCount = Array.isArray(slots) ? slots.length : 0;
if (!slotCount || meterLayout?.w <= 0) return;
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const slotW = panelSlotWidth;
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
const slotHeight = Math.max(40, meterLayout.h - slotTopPadding - slotBottomPadding);
const outerPad = 6;
for (let i = 0; i < slotCount; i++) {
const rectM = {
x: startX + i * (slotW + gap),
y: meterLayout.y + slotTopPadding,
w: slotW,
h: slotHeight,
};
await drawRealtimeSlot(g, rectM, slots[i], CONFIG, meters);
}
}
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const slotW = panelSlotWidth;
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
const outerPad = 6;
for (let i = 1; i < slotCount; i++) {
const dividerX = startX + i * slotW + (i - 1) * gap + gap / 2;
g.save();
g.strokeStyle = 'rgba(0,231,255,0.4)';
g.lineWidth = 1;
g.setLineDash([4, 3]);
g.beginPath();
g.moveTo(dividerX, meterLayout.y + slotTopPadding - outerPad);
g.lineTo(dividerX, meterLayout.y + meterLayout.h - slotBottomPadding + outerPad);
g.stroke();
g.restore();
}
}
// -----------------------------------------------------------------------------
// Trace extraction & rendering
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);
return {
ready,
xyL,
xyR,
length: ready ? Math.min(xyL.length, xyR.length) : 0,
};
}
function resolveScopeSettings(CONFIG) {
let gainDb = Number.isFinite(CONFIG?.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0;
gainDb = Math.max(GONIO_GAIN_MIN_DB, Math.min(GONIO_GAIN_MAX_DB, Math.round(gainDb / 5) * 5));
const lineFadeMs = Number.isFinite(CONFIG?.GONIO_LINE_FADE_MS) ? CONFIG.GONIO_LINE_FADE_MS : 300;
const rtwClassic = true;
return {
gainDb,
lineFadeMs,
rtwClassic,
traceColor: rtwClassic ? 'rgba(255,220,40,0.98)' : 'rgba(255,240,170,0.72)',
trailBaseAlpha: rtwClassic ? 0.18 : 0.18,
trailBoostAlpha: rtwClassic ? 0.42 : 0.42,
lineAlpha: rtwClassic ? 0.82 : 0.72,
lineWidth: 1.8,
pointBaseAlpha: rtwClassic ? 0.14 : 0.16,
pointBoostAlpha: rtwClassic ? 0.34 : 0.36,
pointSize: rtwClassic ? 1.4 : 2.2,
curveSmoothing: rtwClassic,
};
}
function resolveSilenceGate(env, CONFIG, state) {
const enabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
const threshold = Number.isFinite(CONFIG?.XY_SILENCE_THRESHOLD_RMS_DBFS)
? CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS
: CORR_SILENCE_THRESHOLD_DEFAULT;
const rmsMono = Number.isFinite(env.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120;
const targetActive = enabled ? (rmsMono >= threshold) : true;
const now = getNow();
const dt = state.gateLastTs ? Math.max(0, (now - state.gateLastTs) / 1000) : 0;
state.gateLastTs = now;
if (!Number.isFinite(state.gateLevel)) state.gateLevel = targetActive ? 1 : 0;
const tau = targetActive ? 0.06 : 0.35; // schneller rein, sanft raus
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
state.gateLevel += alpha * ((targetActive ? 1 : 0) - state.gateLevel);
state.gateLevel = Math.max(0, Math.min(1, state.gateLevel));
const active = state.gateLevel > 0.02;
return { active, level: state.gateLevel, rmsMono, threshold };
}
function buildTrace(state, xyData, scope, scale, CONFIG) {
if (xyData.length <= 1) {
return { buffer: null, count: 0 };
}
const targetPoints = resolveSampleTarget(CONFIG);
const total = xyData.length;
if (targetPoints <= 1 || total <= 1) {
return { buffer: null, count: 0 };
}
const sampleCount = Math.max(2, Math.min(targetPoints, MAX_TRACE_POINTS));
if (sampleCount <= 1) {
return { buffer: null, count: 0 };
}
const coords = ensureTraceBuffer(state, sampleCount * 2);
const reach = scope.reach;
const cx = scope.cx;
const cy = scope.cy;
const sourceLast = total - 1;
let write = 0;
for (let i = 0; i < sampleCount; i++) {
const pos = sampleCount <= 1 ? 0 : (i * sourceLast) / (sampleCount - 1);
const idx0 = Math.floor(pos);
const idx1 = Math.min(sourceLast, idx0 + 1);
const frac = pos - idx0;
const l0 = xyData.xyL[idx0];
const l1 = xyData.xyL[idx1];
const r0 = xyData.xyR[idx0];
const r1 = xyData.xyR[idx1];
const l = l0 + (l1 - l0) * frac;
const r = r0 + (r1 - r0) * frac;
const M = 0.5 * (l + r);
const S = 0.5 * (l - r);
const xNorm = clamp1(S * scale);
const yNorm = clamp1(M * scale);
coords[write++] = cx - xNorm * reach;
coords[write++] = cy - yNorm * reach;
if (write >= coords.length) break;
}
if (write < 4) {
return { buffer: null, count: 0 };
}
return { buffer: coords, count: write / 2 };
}
function renderTrace(g, state, trace, scope, style, xyReady, settings) {
const trails = state.lineTrails || (state.lineTrails = []);
const now = getNow();
const hasTrace = !!(trace && trace.buffer && trace.count);
const lineFadeMs = settings?.lineFadeMs ?? 0;
if (style === 'lines') {
if (lineFadeMs <= 0) {
trails.length = 0;
if (hasTrace && trace.count > 1) {
drawImmediateLine(g, trace, scope, settings);
} else if (!xyReady) {
drawIdleMessage(g, scope);
}
} else {
if (hasTrace && trace.count > 1) {
addLineTrail(trails, trace, now);
}
const rendered = drawLineTrails(g, trails, scope, now, lineFadeMs, settings);
if (!rendered && !xyReady) drawIdleMessage(g, scope);
}
} else {
if (lineFadeMs <= 0) {
trails.length = 0;
if (hasTrace) {
drawPointTrace(g, trace, scope, settings);
} else if (!xyReady) {
drawIdleMessage(g, scope);
}
} else {
if (hasTrace && trace.count > 0) {
addLineTrail(trails, trace, now);
}
const rendered = drawPointTrails(g, trails, scope, now, lineFadeMs, settings);
if (!rendered && !xyReady) {
drawIdleMessage(g, scope);
}
}
}
}
function addLineTrail(trails, trace, timestamp) {
const coords = new Float32Array(trace.count * 2);
coords.set(trace.buffer.subarray(0, trace.count * 2));
trails.push({ coords, count: trace.count, time: timestamp });
}
function drawLineTrails(g, trails, scope, now, fadeMs, settings) {
if (!trails.length) return false;
const cutoff = now - fadeMs;
const keep = [];
let drawn = false;
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
for (const trail of trails) {
if (trail.time < cutoff) continue;
const age = now - trail.time;
const fade = Math.max(0, 1 - age / fadeMs);
if (fade <= 0) continue;
drawn = true;
keep.push(trail);
g.save();
g.globalAlpha = (settings?.trailBaseAlpha ?? 0.35) + (settings?.trailBoostAlpha ?? 0.65) * fade;
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
g.lineWidth = settings?.lineWidth ?? 1.3;
beginTracePath(g, trail.coords, trail.count, settings);
g.stroke();
g.restore();
}
g.restore();
trails.length = 0;
Array.prototype.push.apply(trails, keep);
return drawn;
}
function drawImmediateLine(g, trace, scope, settings) {
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
g.globalAlpha = settings?.lineAlpha ?? 0.92;
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
g.lineWidth = settings?.lineWidth ?? 1.3;
beginTracePath(g, trace.buffer, trace.count, settings);
g.stroke();
g.restore();
}
function beginTracePath(g, coords, count, settings) {
g.beginPath();
if (!coords || count <= 0) return;
g.moveTo(coords[0], coords[1]);
if (!(settings?.curveSmoothing) || count < 3) {
for (let i = 1; i < count; i++) {
const idx = i * 2;
g.lineTo(coords[idx], coords[idx + 1]);
}
return;
}
for (let i = 1; i < count - 1; i++) {
const idx = i * 2;
const nextIdx = (i + 1) * 2;
const xc = (coords[idx] + coords[nextIdx]) * 0.5;
const yc = (coords[idx + 1] + coords[nextIdx + 1]) * 0.5;
g.quadraticCurveTo(coords[idx], coords[idx + 1], xc, yc);
}
const last = (count - 1) * 2;
const prev = (count - 2) * 2;
g.quadraticCurveTo(coords[prev], coords[prev + 1], coords[last], coords[last + 1]);
}
function drawPointTrails(g, trails, scope, now, fadeMs, settings) {
if (!trails.length) return false;
const cutoff = now - fadeMs;
const keep = [];
let drawn = false;
const pointSize = settings?.pointSize ?? 1.4;
const half = pointSize / 2;
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
for (const trail of trails) {
if (trail.time < cutoff) continue;
const age = now - trail.time;
const fade = Math.max(0, 1 - age / fadeMs);
if (fade <= 0) continue;
drawn = true;
keep.push(trail);
for (let i = 0; i < trail.count; i++) {
const idx = i * 2;
const localAge = trail.count > 1 ? i / (trail.count - 1) : 0;
const alpha = ((settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - localAge)) * fade;
g.fillStyle = `rgba(255,231,74,${alpha})`;
g.fillRect(trail.coords[idx] - half, trail.coords[idx + 1] - half, pointSize, pointSize);
}
}
g.restore();
trails.length = 0;
Array.prototype.push.apply(trails, keep);
return drawn;
}
function drawPointTrace(g, trace, scope, settings) {
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
const pointSize = settings?.pointSize ?? 1.4;
const half = pointSize / 2;
for (let i = 0; i < trace.count; i++) {
const idx = i * 2;
const age = trace.count > 1 ? i / (trace.count - 1) : 0;
const alpha = (settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - age);
g.fillStyle = `rgba(255,231,74,${alpha})`;
g.fillRect(trace.buffer[idx] - half, trace.buffer[idx + 1] - half, pointSize, pointSize);
}
g.restore();
}
function resolveSampleTarget(CONFIG) {
const target = Number(CONFIG?.XY_POINTS);
if (!Number.isFinite(target)) return 512;
return clampInt(target, MIN_TRACE_POINTS, MAX_TRACE_POINTS);
}
function ensureTraceBuffer(state, capacity) {
if (!(state.traceBuffer instanceof Float32Array) || state.traceBuffer.length < capacity) {
state.traceBuffer = new Float32Array(capacity);
}
return state.traceBuffer;
}
function clamp1(v) {
return v < -1 ? -1 : (v > 1 ? 1 : v);
}
function clampInt(value, min, max) {
if (!Number.isFinite(value)) return min;
const v = Math.round(value);
return Math.min(max, Math.max(min, v));
}
function getNow() {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
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;
const dt = state.agcLastTs ? Math.max(0, (nowTs - state.agcLastTs) / 1000) : 0;
state.agcLastTs = nowTs;
const peak = measureGoniometerPeak(xyData);
let env = Number.isFinite(state.agcEnv) && state.agcEnv > 0 ? state.agcEnv : 1e-3;
if (peak >= env) {
const attackAlpha = attackTau > 0 ? (1 - Math.exp(-Math.max(dt, 0) / attackTau)) : 1;
env = env + (peak - env) * Math.min(1, attackAlpha || 1);
} else {
const releaseFactor = Math.pow(10, -releaseDbPerS * Math.max(dt, 0) / 20);
env = Math.max(peak, env * releaseFactor);
}
env = Math.max(1e-6, env);
state.agcEnv = env;
const envDb = linearToDb(env);
let gainDb = AGC_TARGET_DB - envDb;
if (gainDb > GONIO_GAIN_MAX_DB) gainDb = GONIO_GAIN_MAX_DB;
if (gainDb < GONIO_GAIN_MIN_DB) gainDb = GONIO_GAIN_MIN_DB;
state.agcGainDb = gainDb;
return {
gainDb,
gain: dbToLinear(gainDb),
};
}
function measureGoniometerPeak(xyData) {
if (!xyData || !xyData.ready) return 1e-3;
let peak = 1e-6;
const len = xyData.length;
for (let i = 0; i < len; i++) {
const l = xyData.xyL[i];
const r = xyData.xyR[i];
const M = 0.5 * (l + r);
const S = 0.5 * (l - r);
const sample = Math.max(Math.abs(M), Math.abs(S));
if (sample > peak) peak = sample;
}
return peak;
}
function linearToDb(value) {
const v = Math.max(1e-9, value);
return 20 * Math.log10(v);
}
function dbToLinear(db) {
return Math.pow(10, db / 20);
}
// -----------------------------------------------------------------------------
// Correlation bar helper
function drawCorrelationBar(g, centerX, y, w, h, val) {
const x = Math.floor(centerX - w / 2);
g.save();
g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x, y, w, h);
const mid = x + w / 2;
g.fillStyle = LABEL_COLOR;
g.textAlign = 'right'; g.fillText('-1', x - 6, y + h / 2 + 5);
g.textAlign = 'left'; g.fillText('+1', x + w + 6, y + h / 2 + 5);
const padding = 3;
const cubeSize = Math.max(10, Math.min(h - padding * 2, w - padding * 2));
const xVal = mid + (val * 0.5) * w;
const clampCenter = (v) => {
const minC = x + padding + cubeSize * 0.5;
const maxC = x + w - padding - cubeSize * 0.5;
return Math.max(minC, Math.min(maxC, v));
};
const cubeX = clampCenter(xVal) - cubeSize / 2;
const cubeY = y + (h - cubeSize) / 2;
const cubeColor = resolveCorrColor(val);
g.fillStyle = 'rgba(255,255,255,0.08)';
g.fillRect(x + 1, y + 1, w - 2, h - 2);
g.fillStyle = cubeColor;
g.fillRect(cubeX, cubeY, cubeSize, cubeSize);
g.strokeStyle = '#0ff';
g.lineWidth = 1;
g.strokeRect(cubeX, cubeY, cubeSize, cubeSize);
g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke();
g.restore();
}
function resolveCorrColor(val) {
const t = Math.max(-1, Math.min(1, val));
if (t <= -0.33) return WARN_COLOR;
if (t >= 0.33) return OK_COLOR;
return MID_COLOR;
}
function drawMeterOutline(g, meterRect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
g.restore();
}