919 lines
30 KiB
JavaScript
919 lines
30 KiB
JavaScript
// 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 CORR_HOLD_MS = 4000;
|
||
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: '',
|
||
corrNegativeMarkerMode: 'memory',
|
||
corrHoldPeak: 0,
|
||
corrHoldUntil: 0,
|
||
corrResetToken: 0,
|
||
};
|
||
}
|
||
|
||
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 corrMemoryPeak = Number.isFinite(audio?.correlationNegativePeak)
|
||
? clamp1(audio.correlationNegativePeak)
|
||
: 0;
|
||
const corrNegativeMarker = resolveCorrelationNegativeMarker(
|
||
state,
|
||
CONFIG,
|
||
corrVisual,
|
||
corrMemoryPeak,
|
||
frameNow,
|
||
);
|
||
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,
|
||
corrNegativeMarker,
|
||
);
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
export function resolveCorrelationNegativeMarker(state, CONFIG = {}, current = 0, memoryPeak = 0, nowMs = 0) {
|
||
const requestedMode = String(CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory').toLowerCase();
|
||
const mode = ['memory', 'hold', 'off'].includes(requestedMode) ? requestedMode : 'memory';
|
||
const resetToken = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0));
|
||
|
||
if (state.corrResetToken !== resetToken || state.corrNegativeMarkerMode !== mode) {
|
||
state.corrResetToken = resetToken;
|
||
state.corrNegativeMarkerMode = mode;
|
||
state.corrHoldPeak = 0;
|
||
state.corrHoldUntil = 0;
|
||
}
|
||
|
||
if (mode === 'off') return 0;
|
||
if (mode === 'memory') return Math.max(-1, Math.min(1, Number(memoryPeak) || 0));
|
||
|
||
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
||
const currentNegative = Math.min(0, Math.max(-1, Math.min(1, Number(current) || 0)));
|
||
const holdActive = Number.isFinite(state.corrHoldUntil) && now < state.corrHoldUntil;
|
||
if (!holdActive) {
|
||
state.corrHoldPeak = currentNegative;
|
||
state.corrHoldUntil = currentNegative < -0.001 ? now + CORR_HOLD_MS : 0;
|
||
} else if (currentNegative < state.corrHoldPeak) {
|
||
state.corrHoldPeak = currentNegative;
|
||
state.corrHoldUntil = now + CORR_HOLD_MS;
|
||
}
|
||
return Number.isFinite(state.corrHoldPeak) ? state.corrHoldPeak : 0;
|
||
}
|
||
|
||
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();
|
||
}
|