// 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(); }