Files
Phoenix/www/views/goniometer_rtw.js
T

879 lines
28 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 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 MAX_TRAIL_FRAMES = 48;
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 {
gateLevel: 1,
gateLastTs: 0,
agcEnv: 1e-3,
agcGainDb: 0,
agcLastTs: 0,
agcLastXySeq: 0,
traceBuffer: new Float32Array(0),
lineTrails: [],
trailPool: [],
staticLayerCanvas: null,
staticLayerCtx: null,
staticLayerKey: '',
};
}
export function resize() {}
export function destroy(state) {
if (!state) return;
clearTrails(state);
state.trailPool.length = 0;
state.traceBuffer = new Float32Array(0);
}
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) {
state._lastWakeTs = wakeTs;
}
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';
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio);
const gate = resolveSilenceGate(CONFIG, state, xyData);
const lastSampleTs = Number.isFinite(env?.audio?.xyLastSampleTs)
? Number(env.audio.xyLastSampleTs)
: 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);
// 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,
corrNegativePeak,
);
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,
seq: Number(audio?.xySeq) || 0,
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 = resolveGoniometerPersistenceMs(CONFIG);
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,
// Do not bend the measured M/S sample path with cosmetic Bézier curves.
curveSmoothing: false,
};
}
export function resolveGoniometerPersistenceMs(CONFIG = {}) {
const mode = String(CONFIG.GONIO_PERSISTENCE_MODE || 'fast').toLowerCase();
if (mode === 'medium') return 150;
if (mode === 'slow') return 300;
if (mode === 'custom') {
const custom = Number(CONFIG.GONIO_LINE_FADE_MS);
return Number.isFinite(custom) ? Math.max(0, Math.min(600, custom)) : 50;
}
return 50;
}
function resolveSilenceGate(CONFIG, state, xyData) {
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;
let power = 0;
if (xyData?.ready && xyData.length > 0) {
for (let index = 0; index < xyData.length; index++) {
const left = Number(xyData.xyL[index]) || 0;
const right = Number(xyData.xyR[index]) || 0;
power += left * left + right * right;
}
power /= 2 * xyData.length;
}
const rmsMono = power > 1e-12 ? 10 * Math.log10(power) : -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 };
}
// The browser may reduce density but must never invent interpolated samples.
const sampleCount = Math.max(2, Math.min(targetPoints, total, 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) {
clearTrails(state);
if (hasTrace && trace.count > 1) {
drawImmediateLine(g, trace, scope, settings);
} else if (!xyReady) {
drawIdleMessage(g, scope);
}
} else {
if (hasTrace && trace.count > 1) {
addLineTrail(state, trace, now);
}
const rendered = drawLineTrails(g, trails, state.trailPool, scope, now, lineFadeMs, settings);
if (!rendered && !xyReady) drawIdleMessage(g, scope);
}
} else {
if (lineFadeMs <= 0) {
clearTrails(state);
if (hasTrace) {
drawPointTrace(g, trace, scope, settings);
} else if (!xyReady) {
drawIdleMessage(g, scope);
}
} else {
if (hasTrace && trace.count > 0) {
addLineTrail(state, trace, now);
}
const rendered = drawPointTrails(g, trails, state.trailPool, scope, now, lineFadeMs, settings);
if (!rendered && !xyReady) {
drawIdleMessage(g, scope);
}
}
}
}
function addLineTrail(state, trace, timestamp) {
const trails = state.lineTrails || (state.lineTrails = []);
const pool = state.trailPool || (state.trailPool = []);
while (trails.length >= MAX_TRAIL_FRAMES) releaseTrail(pool, trails.shift());
let coords = pool.pop();
if (!(coords instanceof Float32Array) || coords.length < trace.count * 2) {
coords = new Float32Array(trace.count * 2);
}
coords.set(trace.buffer.subarray(0, trace.count * 2));
trails.push({ coords, count: trace.count, time: timestamp });
}
function releaseTrail(pool, trail) {
if (trail?.coords instanceof Float32Array && pool.length < MAX_TRAIL_FRAMES) {
pool.push(trail.coords);
}
}
function clearTrails(state) {
const trails = state.lineTrails || [];
const pool = state.trailPool || (state.trailPool = []);
for (const trail of trails) releaseTrail(pool, trail);
trails.length = 0;
}
function drawLineTrails(g, trails, pool, scope, now, fadeMs, settings) {
if (!trails.length) return false;
const cutoff = now - fadeMs;
let keepCount = 0;
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) {
releaseTrail(pool, trail);
continue;
}
const age = now - trail.time;
const fade = Math.max(0, 1 - age / fadeMs);
if (fade <= 0) {
releaseTrail(pool, trail);
continue;
}
drawn = true;
trails[keepCount++] = 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 = keepCount;
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, pool, scope, now, fadeMs, settings) {
if (!trails.length) return false;
const cutoff = now - fadeMs;
let keepCount = 0;
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) {
releaseTrail(pool, trail);
continue;
}
const age = now - trail.time;
const fade = Math.max(0, 1 - age / fadeMs);
if (fade <= 0) {
releaseTrail(pool, trail);
continue;
}
drawn = true;
trails[keepCount++] = 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 = keepCount;
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 computeAgcGain(state, xyData, nowTs) {
const attackTau = 0.001; // 1 ms
const releaseDbPerS = 10;
if (xyData.seq > 0 && xyData.seq === state.agcLastXySeq) {
return { gainDb: state.agcGainDb, gain: dbToLinear(state.agcGainDb) };
}
state.agcLastXySeq = xyData.seq;
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, negativePeak) {
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();
if (Number.isFinite(negativePeak) && negativePeak < -0.001) {
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();
}
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();
}