Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,905 @@
|
||||
// views/goniometer_rtw.js — XY-Goniometer (RTW-Look) + 3-Meter-Panel + Korrelation
|
||||
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, LABEL_COLOR, MID_COLOR, OK_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'goniometer-rtw';
|
||||
|
||||
const ALIGN_PEAK = Math.pow(10, -15 / 20); // −15 dBFS Peak ≈ −18 dBFS RMS Sine
|
||||
const INNER_MARGIN = 0.05; // 5 % Innenabstand im Scope
|
||||
const MIN_TRACE_POINTS = 128;
|
||||
const MAX_TRACE_POINTS = 4096;
|
||||
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
|
||||
const METER_WIDTH = 420;
|
||||
const METER_GAP = 8;
|
||||
const METER_SLOTS = 3;
|
||||
const METER_SLOT_SHRINK = 24;
|
||||
const METER_PAD_TOP = 1;
|
||||
const METER_PAD_BOTTOM = -7;
|
||||
const METER_EXTRA_BOTTOM_PAD = 6;
|
||||
const CORR_ATTACK_S = 1.5;
|
||||
const CORR_RELEASE_S = 2.5;
|
||||
const GONIO_GAIN_MIN_DB = -35;
|
||||
const GONIO_GAIN_MAX_DB = 35;
|
||||
const BASE_TARGET = 1.0;
|
||||
const BASE_HEADROOM_DB = 0;
|
||||
const BASE_ZOOM = 1.0;
|
||||
const BASE_GONIO_SCALE = (BASE_TARGET / ALIGN_PEAK) * Math.pow(10, -BASE_HEADROOM_DB / 20) * BASE_ZOOM;
|
||||
const AGC_TARGET_DB = linearToDb(ALIGN_PEAK);
|
||||
const CORR_SILENCE_THRESHOLD_DEFAULT = -75;
|
||||
const CORR_SILENCE_HOLD_MS = 30000;
|
||||
const CORR_SILENCE_DRIFT_MS = 60000;
|
||||
|
||||
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
|
||||
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
||||
const avail = Math.max(200, canvasWidth);
|
||||
const totalGap = (n - 1) * Math.max(2, slotGap);
|
||||
const usable = avail - totalGap;
|
||||
return Math.floor(usable / n);
|
||||
}
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
corrDisplayed: 0,
|
||||
corrMeter: 0,
|
||||
corrLastTs: 0,
|
||||
corrLastValid: 0,
|
||||
corrSilenceSince: 0,
|
||||
gateLevel: 1,
|
||||
gateLastTs: 0,
|
||||
agcEnv: 1e-3,
|
||||
agcGainDb: 0,
|
||||
agcLastTs: 0,
|
||||
traceBuffer: new Float32Array(0),
|
||||
lineTrails: [],
|
||||
staticLayerCanvas: null,
|
||||
staticLayerCtx: null,
|
||||
staticLayerKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function resize() {}
|
||||
export function destroy() {}
|
||||
|
||||
export async function render(env, state) {
|
||||
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
|
||||
const frameNow = getNow();
|
||||
|
||||
const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0;
|
||||
if (wakeTs && state?._lastWakeTs !== wakeTs) {
|
||||
// Nach Screensaver-Wake: Korrelation sauber zurücksetzen, damit kein "alter" Wert (z.B. +1) stehen bleibt.
|
||||
state._lastWakeTs = wakeTs;
|
||||
state.corrDisplayed = 0;
|
||||
state.corrMeter = 0;
|
||||
state.corrLastTs = 0;
|
||||
state.corrLastValid = 0;
|
||||
state.corrSilenceSince = frameNow;
|
||||
}
|
||||
|
||||
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
|
||||
const slots = slotsRaw.filter((v) => v && v !== 'none');
|
||||
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : FRAME.top;
|
||||
const layout = computeGoniometerLayout(rect, CONFIG, slots.length, topInset);
|
||||
const settings = resolveScopeSettings(CONFIG);
|
||||
const style = CONFIG.XY_STYLE === 'points' ? 'points' : 'lines';
|
||||
const gate = resolveSilenceGate(env, CONFIG, state);
|
||||
|
||||
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
|
||||
|
||||
const xyData = extractXYData(audio);
|
||||
const lastSampleTs = Number.isFinite(env?.audio?.lastSampleTs) ? Number(env.audio.lastSampleTs) : 0;
|
||||
const audioAgeMs = lastSampleTs ? (frameNow - lastSampleTs) : Infinity;
|
||||
const audioFresh = Number.isFinite(audioAgeMs) && audioAgeMs >= 0 && audioAgeMs <= 250;
|
||||
const xyReady = xyData.ready && audioFresh;
|
||||
|
||||
const agc = (CONFIG.GONIO_AGC_ENABLED && xyReady)
|
||||
? computeAgcGain(state, xyData, frameNow)
|
||||
: { gainDb: settings.gainDb, gain: dbToLinear(settings.gainDb) };
|
||||
const appliedScale = BASE_GONIO_SCALE * agc.gain * gate.level;
|
||||
|
||||
const trace = xyReady
|
||||
? buildTrace(state, xyData, layout.scope, appliedScale, CONFIG)
|
||||
: null;
|
||||
renderTrace(g, state, trace, layout.scope, style, xyReady && gate.active, settings);
|
||||
|
||||
const silenceGateEnabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
|
||||
const corrThreshold = resolveCorrThreshold(CONFIG);
|
||||
const rmsL = Number.isFinite(env.audio?.rmsDb?.L) ? env.audio.rmsDb.L : -120;
|
||||
const rmsR = Number.isFinite(env.audio?.rmsDb?.R) ? env.audio.rmsDb.R : -120;
|
||||
const activeL = audioFresh && (!silenceGateEnabled || (rmsL >= corrThreshold));
|
||||
const activeR = audioFresh && (!silenceGateEnabled || (rmsR >= corrThreshold));
|
||||
|
||||
let corrTarget = 0;
|
||||
const canCorr = xyReady && utils?.correlation;
|
||||
const zeroOnSilence = !!CONFIG?.CORR_ZERO_ON_SILENCE;
|
||||
const bothSilent = !activeL && !activeR;
|
||||
|
||||
if (activeL && activeR && canCorr) {
|
||||
// Beide Kanäle aktiv → echte Korrelation aus L/R-Samples
|
||||
const corrRaw = utils.correlation(
|
||||
xyData.xyL,
|
||||
xyData.xyR,
|
||||
Number.isFinite(state.corrDisplayed) ? state.corrDisplayed : 0,
|
||||
CONFIG.CORR_SMOOTH,
|
||||
);
|
||||
if (Number.isFinite(corrRaw)) {
|
||||
state.corrDisplayed = corrRaw;
|
||||
state.corrLastValid = corrRaw;
|
||||
state.corrSilenceSince = 0;
|
||||
corrTarget = corrRaw;
|
||||
}
|
||||
} else if (activeL !== activeR) {
|
||||
// Nur ein Kanal aktiv → hart auf 0, keine Hold-/Decay-Logik
|
||||
state.corrSilenceSince = 0;
|
||||
corrTarget = 0;
|
||||
} else {
|
||||
if (zeroOnSilence && bothSilent) {
|
||||
// Beide still → zügig Richtung 0 (ohne Hold/Drift)
|
||||
state.corrLastValid = 0;
|
||||
state.corrSilenceSince = frameNow;
|
||||
corrTarget = 0;
|
||||
} else {
|
||||
// Beide still → Hold + Drift zur Mitte
|
||||
if (!state.corrSilenceSince) state.corrSilenceSince = frameNow;
|
||||
corrTarget = resolveCorrTarget(state, frameNow);
|
||||
}
|
||||
}
|
||||
|
||||
const fastZero = zeroOnSilence && bothSilent;
|
||||
const corrVisual = updateCorrelationDisplay(state, corrTarget, frameNow, fastZero ? true : gate.active);
|
||||
if (fastZero) {
|
||||
// Damit die nächste echte Korrelation nicht noch an einem alten Glättungswert "hängt".
|
||||
state.corrDisplayed = corrVisual;
|
||||
}
|
||||
drawCorrelationBar(
|
||||
g,
|
||||
layout.plot.x + layout.plot.w / 2,
|
||||
layout.plot.y + layout.plot.h - 28,
|
||||
Math.round(Math.min(layout.scope.w * 0.75, layout.plot.w - 50)),
|
||||
18,
|
||||
corrVisual
|
||||
);
|
||||
|
||||
if (slots.length) {
|
||||
const panelSlotWidth = computePanelSlotWidth(
|
||||
layout.meter.w,
|
||||
slots.length,
|
||||
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
|
||||
);
|
||||
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
|
||||
drawMeterOutline(g, layout.meter);
|
||||
}
|
||||
}
|
||||
|
||||
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
|
||||
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
|
||||
if (!layer) {
|
||||
drawPlotBackdrop(g, layout.plot);
|
||||
drawFramework(g, layout, CONFIG);
|
||||
drawScopeBackground(g, layout.scope);
|
||||
drawCrosshair(g, layout.scope);
|
||||
drawAxisLabels(g, layout.scope);
|
||||
drawMeterPanelDividers(g, layout.meter, slotCount, CONFIG, computePanelSlotWidth(
|
||||
layout.meter.w,
|
||||
slotCount,
|
||||
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
|
||||
));
|
||||
return;
|
||||
}
|
||||
g.drawImage(layer, 0, 0, rect.w, rect.h);
|
||||
}
|
||||
|
||||
function ensureStaticLayer(state, rect, layout, CONFIG, slotCount) {
|
||||
const w = Math.max(1, rect.w | 0);
|
||||
const h = Math.max(1, rect.h | 0);
|
||||
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const key = [
|
||||
w, h,
|
||||
layout.plot.x, layout.plot.y, layout.plot.w, layout.plot.h,
|
||||
layout.scope.x, layout.scope.y, layout.scope.w, layout.scope.h,
|
||||
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
|
||||
slotCount,
|
||||
slotGap,
|
||||
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
|
||||
Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? CONFIG.AXIS_GUTTER_LEFT : 14,
|
||||
].join('|');
|
||||
if (state.staticLayerCanvas && state.staticLayerKey === key) {
|
||||
return state.staticLayerCanvas;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return null;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.font = 'bold 14px ui-monospace, monospace';
|
||||
drawPlotBackdrop(ctx, layout.plot);
|
||||
drawFramework(ctx, layout, CONFIG);
|
||||
drawScopeBackground(ctx, layout.scope);
|
||||
drawCrosshair(ctx, layout.scope);
|
||||
drawAxisLabels(ctx, layout.scope);
|
||||
drawMeterPanelDividers(
|
||||
ctx,
|
||||
layout.meter,
|
||||
slotCount,
|
||||
CONFIG,
|
||||
computePanelSlotWidth(layout.meter.w, slotCount, slotGap)
|
||||
);
|
||||
state.staticLayerCanvas = canvas;
|
||||
state.staticLayerCtx = ctx;
|
||||
state.staticLayerKey = key;
|
||||
return canvas;
|
||||
}
|
||||
async function drawRealtimeSlot(g, rect, slotId, CONFIG, meters) {
|
||||
const shrink = Math.max(0, METER_SLOT_SHRINK);
|
||||
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
|
||||
const innerOffset = shrink / 2;
|
||||
const innerRect = {
|
||||
x: rect.x,
|
||||
y: rect.y + METER_PAD_TOP + innerOffset,
|
||||
w: rect.w,
|
||||
h: innerHeight,
|
||||
};
|
||||
|
||||
try {
|
||||
await meters.draw(g, innerRect, slotId, CONFIG);
|
||||
} catch (e) {
|
||||
console.warn(`Meter ${slotId} draw error:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Layout & drawing helpers
|
||||
|
||||
function computeGoniometerLayout(rect, CONFIG = {}, slotCount = METER_SLOTS, topInset = FRAME.top) {
|
||||
const plotX = FRAME.left;
|
||||
const plotY = Number.isFinite(topInset) ? topInset : FRAME.top;
|
||||
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
|
||||
const minPlotW = 200;
|
||||
|
||||
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const panelSlotWidth = 100; // fixed width for better control
|
||||
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
|
||||
const panelSlotsWidth = n > 0 ? (panelSlotWidth * n + (n - 1) * slotGap) : 0;
|
||||
|
||||
let meterW = n > 0 ? Math.max(panelSlotsWidth, (n === 1 ? 160 : (n === 2 ? 280 : METER_WIDTH))) : 0;
|
||||
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
|
||||
|
||||
if (plotW < minPlotW) {
|
||||
plotW = minPlotW;
|
||||
meterW = n > 0 ? (innerWidth - plotW - METER_GAP) : 0;
|
||||
}
|
||||
|
||||
if (n > 0) meterW = Math.max(meterW, panelSlotsWidth);
|
||||
if (plotW < 80) plotW = 80;
|
||||
|
||||
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
|
||||
const plot = { x: plotX, y: plotY, w: plotW, h: plotH };
|
||||
const scope = computeScopeBox(plot);
|
||||
|
||||
const meter = {
|
||||
x: plot.x + plot.w + (n > 0 ? METER_GAP : 0),
|
||||
y: plot.y,
|
||||
w: meterW,
|
||||
h: plotH,
|
||||
};
|
||||
|
||||
return { plot, scope, meter };
|
||||
}
|
||||
|
||||
function computeScopeBox(plot) {
|
||||
const padding = 24;
|
||||
const minDim = Math.min(plot.w, plot.h);
|
||||
const tightSize = Math.max(120, minDim - padding * 2);
|
||||
const size = Math.min(Math.max(140, tightSize), minDim);
|
||||
const w = size;
|
||||
const h = size;
|
||||
const x = Math.round(plot.x + (plot.w - w) / 2);
|
||||
const y = Math.round(plot.y + (plot.h - h) / 2);
|
||||
const halfLimit = Math.max(24, Math.min(w, h) / 2 - 6);
|
||||
const halfBase = Math.max(60, Math.min(w, h) / 2 - 18);
|
||||
const half = Math.max(24, Math.min(halfBase, halfLimit));
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const reach = Math.max(24, Math.min(half - 4, half * (1 - INNER_MARGIN)));
|
||||
const diag = half * 0.9;
|
||||
return { x, y, w, h, cx, cy, half, reach, diag };
|
||||
}
|
||||
|
||||
function drawFramework(g, layout, CONFIG) {
|
||||
drawPlotRails(g, layout.plot, CONFIG);
|
||||
if (layout.meter?.w > 0) {
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h);
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPlotBackdrop(g, plot) {
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(plot.x, plot.y, plot.w, plot.h);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawPlotRails(g, plot, CONFIG) {
|
||||
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||
: 14;
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x - gutterL, plot.y);
|
||||
g.lineTo(plot.x + plot.w, plot.y);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x - gutterL, plot.y + plot.h);
|
||||
g.lineTo(plot.x + plot.w, plot.y + plot.h);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x, plot.y);
|
||||
g.lineTo(plot.x, plot.y + plot.h);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(plot.x + plot.w, plot.y);
|
||||
g.lineTo(plot.x + plot.w, plot.y + plot.h);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawScopeBackground(g, scope) {
|
||||
g.save();
|
||||
g.fillStyle = PANEL_BG;
|
||||
g.fillRect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawCrosshair(g, scope) {
|
||||
g.save();
|
||||
g.globalAlpha = 0.45;
|
||||
g.strokeStyle = 'rgb(0,0,255)';
|
||||
g.lineWidth = 1.5;
|
||||
g.beginPath();
|
||||
g.moveTo(scope.cx - scope.diag, scope.cy - scope.diag);
|
||||
g.lineTo(scope.cx + scope.diag, scope.cy + scope.diag);
|
||||
g.moveTo(scope.cx - scope.diag, scope.cy + scope.diag);
|
||||
g.lineTo(scope.cx + scope.diag, scope.cy - scope.diag);
|
||||
g.moveTo(scope.cx, scope.cy - scope.half);
|
||||
g.lineTo(scope.cx, scope.cy + scope.half);
|
||||
g.moveTo(scope.cx - scope.half, scope.cy);
|
||||
g.lineTo(scope.cx + scope.half, scope.cy);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawAxisLabels(g, scope) {
|
||||
g.save();
|
||||
g.fillStyle = FRAME_COLOR;
|
||||
g.fillText('R', scope.cx + scope.diag - 10, scope.cy - scope.diag - 4);
|
||||
g.fillText('L', scope.cx - scope.diag - 14, scope.cy - scope.diag - 4);
|
||||
const labelOffset = scope.half + 6;
|
||||
g.fillText('M', scope.cx - 6, scope.cy - labelOffset - 6);
|
||||
g.fillText('S', scope.cx - labelOffset - 16, scope.cy + 6);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawIdleMessage(g, scope) {
|
||||
g.save();
|
||||
g.fillStyle = '#bcd';
|
||||
g.fillText('Warte auf Audio …', scope.x + 12, scope.y + 20);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
async function renderMeterPanel(g, meterLayout, slots, CONFIG, meters, panelSlotWidth) {
|
||||
const slotCount = Array.isArray(slots) ? slots.length : 0;
|
||||
if (!slotCount || meterLayout?.w <= 0) return;
|
||||
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const slotW = panelSlotWidth;
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
||||
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
||||
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
|
||||
const slotHeight = Math.max(40, meterLayout.h - slotTopPadding - slotBottomPadding);
|
||||
const outerPad = 6;
|
||||
|
||||
for (let i = 0; i < slotCount; i++) {
|
||||
const rectM = {
|
||||
x: startX + i * (slotW + gap),
|
||||
y: meterLayout.y + slotTopPadding,
|
||||
w: slotW,
|
||||
h: slotHeight,
|
||||
};
|
||||
|
||||
await drawRealtimeSlot(g, rectM, slots[i], CONFIG, meters);
|
||||
}
|
||||
}
|
||||
|
||||
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
|
||||
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
|
||||
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
|
||||
const slotW = panelSlotWidth;
|
||||
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
||||
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
||||
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
||||
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
|
||||
const outerPad = 6;
|
||||
for (let i = 1; i < slotCount; i++) {
|
||||
const dividerX = startX + i * slotW + (i - 1) * gap + gap / 2;
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(0,231,255,0.4)';
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash([4, 3]);
|
||||
g.beginPath();
|
||||
g.moveTo(dividerX, meterLayout.y + slotTopPadding - outerPad);
|
||||
g.lineTo(dividerX, meterLayout.y + meterLayout.h - slotBottomPadding + outerPad);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Trace extraction & rendering
|
||||
|
||||
function extractXYData(audio) {
|
||||
const xyL = audio?.xyL;
|
||||
const xyR = audio?.xyR;
|
||||
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
|
||||
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
|
||||
return {
|
||||
ready,
|
||||
xyL,
|
||||
xyR,
|
||||
length: ready ? Math.min(xyL.length, xyR.length) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveScopeSettings(CONFIG) {
|
||||
let gainDb = Number.isFinite(CONFIG?.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0;
|
||||
gainDb = Math.max(GONIO_GAIN_MIN_DB, Math.min(GONIO_GAIN_MAX_DB, Math.round(gainDb / 5) * 5));
|
||||
const lineFadeMs = Number.isFinite(CONFIG?.GONIO_LINE_FADE_MS) ? CONFIG.GONIO_LINE_FADE_MS : 300;
|
||||
const rtwClassic = true;
|
||||
return {
|
||||
gainDb,
|
||||
lineFadeMs,
|
||||
rtwClassic,
|
||||
traceColor: rtwClassic ? 'rgba(255,220,40,0.98)' : 'rgba(255,240,170,0.72)',
|
||||
trailBaseAlpha: rtwClassic ? 0.18 : 0.18,
|
||||
trailBoostAlpha: rtwClassic ? 0.42 : 0.42,
|
||||
lineAlpha: rtwClassic ? 0.82 : 0.72,
|
||||
lineWidth: 1.8,
|
||||
pointBaseAlpha: rtwClassic ? 0.14 : 0.16,
|
||||
pointBoostAlpha: rtwClassic ? 0.34 : 0.36,
|
||||
pointSize: rtwClassic ? 1.4 : 2.2,
|
||||
curveSmoothing: rtwClassic,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSilenceGate(env, CONFIG, state) {
|
||||
const enabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
|
||||
const threshold = Number.isFinite(CONFIG?.XY_SILENCE_THRESHOLD_RMS_DBFS)
|
||||
? CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS
|
||||
: CORR_SILENCE_THRESHOLD_DEFAULT;
|
||||
const rmsMono = Number.isFinite(env.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120;
|
||||
const targetActive = enabled ? (rmsMono >= threshold) : true;
|
||||
const now = getNow();
|
||||
const dt = state.gateLastTs ? Math.max(0, (now - state.gateLastTs) / 1000) : 0;
|
||||
state.gateLastTs = now;
|
||||
if (!Number.isFinite(state.gateLevel)) state.gateLevel = targetActive ? 1 : 0;
|
||||
const tau = targetActive ? 0.06 : 0.35; // schneller rein, sanft raus
|
||||
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
|
||||
state.gateLevel += alpha * ((targetActive ? 1 : 0) - state.gateLevel);
|
||||
state.gateLevel = Math.max(0, Math.min(1, state.gateLevel));
|
||||
const active = state.gateLevel > 0.02;
|
||||
return { active, level: state.gateLevel, rmsMono, threshold };
|
||||
}
|
||||
|
||||
function buildTrace(state, xyData, scope, scale, CONFIG) {
|
||||
if (xyData.length <= 1) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
|
||||
const targetPoints = resolveSampleTarget(CONFIG);
|
||||
const total = xyData.length;
|
||||
if (targetPoints <= 1 || total <= 1) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
const sampleCount = Math.max(2, Math.min(targetPoints, MAX_TRACE_POINTS));
|
||||
if (sampleCount <= 1) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
|
||||
const coords = ensureTraceBuffer(state, sampleCount * 2);
|
||||
const reach = scope.reach;
|
||||
const cx = scope.cx;
|
||||
const cy = scope.cy;
|
||||
const sourceLast = total - 1;
|
||||
|
||||
let write = 0;
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
const pos = sampleCount <= 1 ? 0 : (i * sourceLast) / (sampleCount - 1);
|
||||
const idx0 = Math.floor(pos);
|
||||
const idx1 = Math.min(sourceLast, idx0 + 1);
|
||||
const frac = pos - idx0;
|
||||
const l0 = xyData.xyL[idx0];
|
||||
const l1 = xyData.xyL[idx1];
|
||||
const r0 = xyData.xyR[idx0];
|
||||
const r1 = xyData.xyR[idx1];
|
||||
const l = l0 + (l1 - l0) * frac;
|
||||
const r = r0 + (r1 - r0) * frac;
|
||||
const M = 0.5 * (l + r);
|
||||
const S = 0.5 * (l - r);
|
||||
const xNorm = clamp1(S * scale);
|
||||
const yNorm = clamp1(M * scale);
|
||||
coords[write++] = cx - xNorm * reach;
|
||||
coords[write++] = cy - yNorm * reach;
|
||||
if (write >= coords.length) break;
|
||||
}
|
||||
|
||||
if (write < 4) {
|
||||
return { buffer: null, count: 0 };
|
||||
}
|
||||
|
||||
return { buffer: coords, count: write / 2 };
|
||||
}
|
||||
|
||||
function renderTrace(g, state, trace, scope, style, xyReady, settings) {
|
||||
const trails = state.lineTrails || (state.lineTrails = []);
|
||||
const now = getNow();
|
||||
const hasTrace = !!(trace && trace.buffer && trace.count);
|
||||
const lineFadeMs = settings?.lineFadeMs ?? 0;
|
||||
|
||||
if (style === 'lines') {
|
||||
if (lineFadeMs <= 0) {
|
||||
trails.length = 0;
|
||||
if (hasTrace && trace.count > 1) {
|
||||
drawImmediateLine(g, trace, scope, settings);
|
||||
} else if (!xyReady) {
|
||||
drawIdleMessage(g, scope);
|
||||
}
|
||||
} else {
|
||||
if (hasTrace && trace.count > 1) {
|
||||
addLineTrail(trails, trace, now);
|
||||
}
|
||||
const rendered = drawLineTrails(g, trails, scope, now, lineFadeMs, settings);
|
||||
if (!rendered && !xyReady) drawIdleMessage(g, scope);
|
||||
}
|
||||
} else {
|
||||
if (lineFadeMs <= 0) {
|
||||
trails.length = 0;
|
||||
if (hasTrace) {
|
||||
drawPointTrace(g, trace, scope, settings);
|
||||
} else if (!xyReady) {
|
||||
drawIdleMessage(g, scope);
|
||||
}
|
||||
} else {
|
||||
if (hasTrace && trace.count > 0) {
|
||||
addLineTrail(trails, trace, now);
|
||||
}
|
||||
const rendered = drawPointTrails(g, trails, scope, now, lineFadeMs, settings);
|
||||
if (!rendered && !xyReady) {
|
||||
drawIdleMessage(g, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addLineTrail(trails, trace, timestamp) {
|
||||
const coords = new Float32Array(trace.count * 2);
|
||||
coords.set(trace.buffer.subarray(0, trace.count * 2));
|
||||
trails.push({ coords, count: trace.count, time: timestamp });
|
||||
}
|
||||
|
||||
function drawLineTrails(g, trails, scope, now, fadeMs, settings) {
|
||||
if (!trails.length) return false;
|
||||
|
||||
const cutoff = now - fadeMs;
|
||||
const keep = [];
|
||||
let drawn = false;
|
||||
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
for (const trail of trails) {
|
||||
if (trail.time < cutoff) continue;
|
||||
const age = now - trail.time;
|
||||
const fade = Math.max(0, 1 - age / fadeMs);
|
||||
if (fade <= 0) continue;
|
||||
|
||||
drawn = true;
|
||||
keep.push(trail);
|
||||
|
||||
g.save();
|
||||
g.globalAlpha = (settings?.trailBaseAlpha ?? 0.35) + (settings?.trailBoostAlpha ?? 0.65) * fade;
|
||||
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
|
||||
g.lineWidth = settings?.lineWidth ?? 1.3;
|
||||
beginTracePath(g, trail.coords, trail.count, settings);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
g.restore();
|
||||
|
||||
trails.length = 0;
|
||||
Array.prototype.push.apply(trails, keep);
|
||||
|
||||
return drawn;
|
||||
}
|
||||
|
||||
function drawImmediateLine(g, trace, scope, settings) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
g.globalAlpha = settings?.lineAlpha ?? 0.92;
|
||||
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
|
||||
g.lineWidth = settings?.lineWidth ?? 1.3;
|
||||
beginTracePath(g, trace.buffer, trace.count, settings);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function beginTracePath(g, coords, count, settings) {
|
||||
g.beginPath();
|
||||
if (!coords || count <= 0) return;
|
||||
g.moveTo(coords[0], coords[1]);
|
||||
if (!(settings?.curveSmoothing) || count < 3) {
|
||||
for (let i = 1; i < count; i++) {
|
||||
const idx = i * 2;
|
||||
g.lineTo(coords[idx], coords[idx + 1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i < count - 1; i++) {
|
||||
const idx = i * 2;
|
||||
const nextIdx = (i + 1) * 2;
|
||||
const xc = (coords[idx] + coords[nextIdx]) * 0.5;
|
||||
const yc = (coords[idx + 1] + coords[nextIdx + 1]) * 0.5;
|
||||
g.quadraticCurveTo(coords[idx], coords[idx + 1], xc, yc);
|
||||
}
|
||||
const last = (count - 1) * 2;
|
||||
const prev = (count - 2) * 2;
|
||||
g.quadraticCurveTo(coords[prev], coords[prev + 1], coords[last], coords[last + 1]);
|
||||
}
|
||||
|
||||
function drawPointTrails(g, trails, scope, now, fadeMs, settings) {
|
||||
if (!trails.length) return false;
|
||||
|
||||
const cutoff = now - fadeMs;
|
||||
const keep = [];
|
||||
let drawn = false;
|
||||
const pointSize = settings?.pointSize ?? 1.4;
|
||||
const half = pointSize / 2;
|
||||
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
for (const trail of trails) {
|
||||
if (trail.time < cutoff) continue;
|
||||
const age = now - trail.time;
|
||||
const fade = Math.max(0, 1 - age / fadeMs);
|
||||
if (fade <= 0) continue;
|
||||
|
||||
drawn = true;
|
||||
keep.push(trail);
|
||||
|
||||
for (let i = 0; i < trail.count; i++) {
|
||||
const idx = i * 2;
|
||||
const localAge = trail.count > 1 ? i / (trail.count - 1) : 0;
|
||||
const alpha = ((settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - localAge)) * fade;
|
||||
g.fillStyle = `rgba(255,231,74,${alpha})`;
|
||||
g.fillRect(trail.coords[idx] - half, trail.coords[idx + 1] - half, pointSize, pointSize);
|
||||
}
|
||||
}
|
||||
|
||||
g.restore();
|
||||
|
||||
trails.length = 0;
|
||||
Array.prototype.push.apply(trails, keep);
|
||||
|
||||
return drawn;
|
||||
}
|
||||
|
||||
function drawPointTrace(g, trace, scope, settings) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(scope.x, scope.y, scope.w, scope.h);
|
||||
g.clip();
|
||||
|
||||
const pointSize = settings?.pointSize ?? 1.4;
|
||||
const half = pointSize / 2;
|
||||
for (let i = 0; i < trace.count; i++) {
|
||||
const idx = i * 2;
|
||||
const age = trace.count > 1 ? i / (trace.count - 1) : 0;
|
||||
const alpha = (settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - age);
|
||||
g.fillStyle = `rgba(255,231,74,${alpha})`;
|
||||
g.fillRect(trace.buffer[idx] - half, trace.buffer[idx + 1] - half, pointSize, pointSize);
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function resolveSampleTarget(CONFIG) {
|
||||
const target = Number(CONFIG?.XY_POINTS);
|
||||
if (!Number.isFinite(target)) return 512;
|
||||
return clampInt(target, MIN_TRACE_POINTS, MAX_TRACE_POINTS);
|
||||
}
|
||||
|
||||
function ensureTraceBuffer(state, capacity) {
|
||||
if (!(state.traceBuffer instanceof Float32Array) || state.traceBuffer.length < capacity) {
|
||||
state.traceBuffer = new Float32Array(capacity);
|
||||
}
|
||||
return state.traceBuffer;
|
||||
}
|
||||
|
||||
function clamp1(v) {
|
||||
return v < -1 ? -1 : (v > 1 ? 1 : v);
|
||||
}
|
||||
|
||||
function clampInt(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
const v = Math.round(value);
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function getNow() {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function updateCorrelationDisplay(state, target, nowTs, gateActive = true) {
|
||||
const prev = Number.isFinite(state.corrMeter) ? state.corrMeter : target;
|
||||
const lastTs = Number.isFinite(state.corrLastTs) ? state.corrLastTs : nowTs;
|
||||
const dt = Math.max(0, (nowTs - lastTs) / 1000);
|
||||
if (!dt) {
|
||||
state.corrMeter = clamp1(target);
|
||||
state.corrLastTs = nowTs;
|
||||
return state.corrMeter;
|
||||
}
|
||||
const rising = Math.abs(target) > Math.abs(prev);
|
||||
// Schneller einrasten, wenn Gate aktiv ist; etwas zäher auf Null auslaufen, wenn Stille.
|
||||
const tauAttack = gateActive ? 0.08 : CORR_ATTACK_S;
|
||||
const tauRelease = gateActive ? 0.35 : CORR_RELEASE_S;
|
||||
const tau = rising ? tauAttack : tauRelease;
|
||||
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
|
||||
const next = clamp1(prev + (target - prev) * alpha);
|
||||
state.corrMeter = next;
|
||||
state.corrLastTs = nowTs;
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveCorrThreshold(CONFIG = {}) {
|
||||
if (Number.isFinite(CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS)) {
|
||||
return CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS;
|
||||
}
|
||||
if (Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)) {
|
||||
return CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS;
|
||||
}
|
||||
return CORR_SILENCE_THRESHOLD_DEFAULT;
|
||||
}
|
||||
|
||||
function resolveCorrTarget(state, nowTs) {
|
||||
const lastValid = Number.isFinite(state.corrLastValid) ? state.corrLastValid : 0;
|
||||
if (!state.corrSilenceSince) {
|
||||
return lastValid;
|
||||
}
|
||||
const holdMs = CORR_SILENCE_HOLD_MS;
|
||||
const driftMs = CORR_SILENCE_DRIFT_MS;
|
||||
const elapsed = Math.max(0, nowTs - state.corrSilenceSince);
|
||||
if (elapsed <= holdMs) {
|
||||
return lastValid;
|
||||
}
|
||||
const t = Math.min(1, (elapsed - holdMs) / Math.max(1, driftMs));
|
||||
return lastValid * (1 - t);
|
||||
}
|
||||
|
||||
function computeAgcGain(state, xyData, nowTs) {
|
||||
const attackTau = 0.001; // 1 ms
|
||||
const releaseDbPerS = 10;
|
||||
const dt = state.agcLastTs ? Math.max(0, (nowTs - state.agcLastTs) / 1000) : 0;
|
||||
state.agcLastTs = nowTs;
|
||||
|
||||
const peak = measureGoniometerPeak(xyData);
|
||||
let env = Number.isFinite(state.agcEnv) && state.agcEnv > 0 ? state.agcEnv : 1e-3;
|
||||
if (peak >= env) {
|
||||
const attackAlpha = attackTau > 0 ? (1 - Math.exp(-Math.max(dt, 0) / attackTau)) : 1;
|
||||
env = env + (peak - env) * Math.min(1, attackAlpha || 1);
|
||||
} else {
|
||||
const releaseFactor = Math.pow(10, -releaseDbPerS * Math.max(dt, 0) / 20);
|
||||
env = Math.max(peak, env * releaseFactor);
|
||||
}
|
||||
env = Math.max(1e-6, env);
|
||||
state.agcEnv = env;
|
||||
|
||||
const envDb = linearToDb(env);
|
||||
let gainDb = AGC_TARGET_DB - envDb;
|
||||
if (gainDb > GONIO_GAIN_MAX_DB) gainDb = GONIO_GAIN_MAX_DB;
|
||||
if (gainDb < GONIO_GAIN_MIN_DB) gainDb = GONIO_GAIN_MIN_DB;
|
||||
state.agcGainDb = gainDb;
|
||||
return {
|
||||
gainDb,
|
||||
gain: dbToLinear(gainDb),
|
||||
};
|
||||
}
|
||||
|
||||
function measureGoniometerPeak(xyData) {
|
||||
if (!xyData || !xyData.ready) return 1e-3;
|
||||
let peak = 1e-6;
|
||||
const len = xyData.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const l = xyData.xyL[i];
|
||||
const r = xyData.xyR[i];
|
||||
const M = 0.5 * (l + r);
|
||||
const S = 0.5 * (l - r);
|
||||
const sample = Math.max(Math.abs(M), Math.abs(S));
|
||||
if (sample > peak) peak = sample;
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
function linearToDb(value) {
|
||||
const v = Math.max(1e-9, value);
|
||||
return 20 * Math.log10(v);
|
||||
}
|
||||
|
||||
function dbToLinear(db) {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Correlation bar helper
|
||||
|
||||
function drawCorrelationBar(g, centerX, y, w, h, val) {
|
||||
const x = Math.floor(centerX - w / 2);
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x, y, w, h);
|
||||
const mid = x + w / 2;
|
||||
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'right'; g.fillText('-1', x - 6, y + h / 2 + 5);
|
||||
g.textAlign = 'left'; g.fillText('+1', x + w + 6, y + h / 2 + 5);
|
||||
|
||||
const padding = 3;
|
||||
const cubeSize = Math.max(10, Math.min(h - padding * 2, w - padding * 2));
|
||||
const xVal = mid + (val * 0.5) * w;
|
||||
const clampCenter = (v) => {
|
||||
const minC = x + padding + cubeSize * 0.5;
|
||||
const maxC = x + w - padding - cubeSize * 0.5;
|
||||
return Math.max(minC, Math.min(maxC, v));
|
||||
};
|
||||
const cubeX = clampCenter(xVal) - cubeSize / 2;
|
||||
const cubeY = y + (h - cubeSize) / 2;
|
||||
const cubeColor = resolveCorrColor(val);
|
||||
g.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
g.fillRect(x + 1, y + 1, w - 2, h - 2);
|
||||
g.fillStyle = cubeColor;
|
||||
g.fillRect(cubeX, cubeY, cubeSize, cubeSize);
|
||||
g.strokeStyle = '#0ff';
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(cubeX, cubeY, cubeSize, cubeSize);
|
||||
g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function resolveCorrColor(val) {
|
||||
const t = Math.max(-1, Math.min(1, val));
|
||||
if (t <= -0.33) return WARN_COLOR;
|
||||
if (t >= 0.33) return OK_COLOR;
|
||||
return MID_COLOR;
|
||||
}
|
||||
|
||||
function drawMeterOutline(g, meterRect) {
|
||||
g.save();
|
||||
g.strokeStyle = FRAME_COLOR;
|
||||
g.lineWidth = 2;
|
||||
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
||||
g.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user