Files
Phoenix/www/views/goniometer_rtw.js
T
2026-06-02 20:56:19 +02:00

906 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}