820 lines
28 KiB
JavaScript
820 lines
28 KiB
JavaScript
// IN USE
|
|
// views/phase_wheel.js — Phase/Frequency Wheel (polar phase display) + meter panel
|
|
|
|
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
|
|
|
export const id = 'phase-wheel';
|
|
|
|
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
|
|
const WHEEL_METER_GAP = 8;
|
|
const METER_WIDTH = 420;
|
|
const METER_GAP = 8;
|
|
const METER_SLOTS = 3;
|
|
const METER_PAD_TOP = 30;
|
|
const METER_PAD_BOTTOM = 20;
|
|
const METER_EXTRA_BOTTOM_PAD = 6;
|
|
const RING_DBFS = [0, -8, -16, -24, -32, -40];
|
|
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
|
|
const PHASE_GAIN_MIN_DB = -35;
|
|
const PHASE_GAIN_MAX_DB = 35;
|
|
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
|
|
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
|
|
const PHASE_AGC_ATTACK_S = 0.02;
|
|
const PHASE_AGC_RELEASE_DB_PER_S = 12;
|
|
const PHASE_LEVEL_THRESHOLD_DB = -60;
|
|
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
|
|
const PHASE_IDLE_TAU_S = 0.103;
|
|
const PHASE_SMOOTH_TAU_S = 0.2;
|
|
const PHASE_RADIUS_TAU_S = 0.085;
|
|
const PHASE_TRAIL_FADE_MS = 2000;
|
|
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
|
|
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
|
|
const PHASE_TRAIL_MAX_ANGLE_STEP_RAD = (2 * Math.PI) / 180;
|
|
const PHASE_TRAIL_MAX_POINTS = 1024;
|
|
const PHASE_SECTORS = [
|
|
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
|
|
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' },
|
|
{ startDeg: -60, endDeg: -30, color: 'rgba(255,214,120,0.25)' },
|
|
{ startDeg: 60, endDeg: 180, color: 'rgba(255,120,120,0.25)' },
|
|
{ startDeg: -180, endDeg: -60, color: 'rgba(255,120,120,0.25)' },
|
|
];
|
|
|
|
export function init() {
|
|
return {
|
|
phaseDisplaySeq: -1,
|
|
phaseDisplayLastTs: 0,
|
|
phaseConfidence: 0,
|
|
currentPhase: null,
|
|
currentRadius: 0,
|
|
smoothPhase: null,
|
|
smoothRadius: 0,
|
|
phaseAgcEnv: 1e-3,
|
|
phaseAgcGainDb: 0,
|
|
phaseAgcLastTs: 0,
|
|
phaseAgcSeq: -1,
|
|
phaseTrail: [],
|
|
staticLayerCanvas: null,
|
|
staticLayerKey: '',
|
|
};
|
|
}
|
|
|
|
export function destroy() {
|
|
// Cleanup - Setze Referenzen für Garbage Collection frei
|
|
return null;
|
|
}
|
|
|
|
export function resize() {}
|
|
|
|
export async function render(env, state) {
|
|
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
|
|
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 = computeLayout(rect, slots.length, topInset);
|
|
const now = getNow();
|
|
|
|
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
|
|
const phaseData = extractPhaseData(audio);
|
|
if (phaseData.ready) {
|
|
const gainCtrl = resolvePhaseGain(state, phaseData, CONFIG);
|
|
const updated = updatePhasePointer(state, phaseData, gainCtrl.gain, CONFIG, audio, now);
|
|
renderWheel(g, updated, layout.wheel, state, CONFIG, now);
|
|
} else {
|
|
updatePhasePointer(state, phaseData, 1, CONFIG, audio, now);
|
|
trimPhaseTrail(state, CONFIG, now);
|
|
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
|
|
drawPhasePointer(g, layout.wheel, state);
|
|
drawIdleMessage(g, layout.wheel);
|
|
}
|
|
|
|
if (slots.length) {
|
|
const panelSlotWidth = computePanelSlotWidth(layout.meter.w, slots.length);
|
|
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
|
|
drawMeterOutline(g, layout.meter);
|
|
}
|
|
drawPhaseAngleLabel(g, layout.wheelPanel, layout.wheel, state, now);
|
|
}
|
|
|
|
function computeLayout(rect, 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 n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
|
|
let meterW = n > 0 ? resolveMeterWidth(rect.w, n) : 0;
|
|
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
|
|
if (plotW < minPlotW) {
|
|
plotW = minPlotW;
|
|
meterW = n > 0 ? Math.max(0, innerWidth - plotW - METER_GAP) : 0;
|
|
}
|
|
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
|
|
const wheelSize = Math.min(plotW, plotH);
|
|
const wheelPanel = {
|
|
x: plotX,
|
|
y: plotY,
|
|
w: plotW,
|
|
h: plotH,
|
|
};
|
|
const meterX = plotX + plotW + (n > 0 ? METER_GAP : 0);
|
|
const wheel = {
|
|
x: wheelPanel.x + (wheelPanel.w - wheelSize) / 2,
|
|
y: plotY + (plotH - wheelSize) / 2,
|
|
w: wheelSize,
|
|
h: wheelSize,
|
|
cx: wheelPanel.x + wheelPanel.w / 2,
|
|
cy: plotY + plotH / 2,
|
|
radius: wheelSize / 2 - 8,
|
|
};
|
|
const meter = {
|
|
x: meterX,
|
|
y: plotY,
|
|
w: meterW,
|
|
h: plotH,
|
|
};
|
|
return { wheelPanel, wheel, meter };
|
|
}
|
|
|
|
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
|
|
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
|
|
if (!layer) {
|
|
drawWheelBackground(g, layout.wheelPanel, layout.wheel, CONFIG);
|
|
if (slotCount) drawMeterBackground(g, layout.meter);
|
|
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 key = [
|
|
w, h,
|
|
layout.wheelPanel.x, layout.wheelPanel.y, layout.wheelPanel.w, layout.wheelPanel.h,
|
|
layout.wheel.x, layout.wheel.y, layout.wheel.w, layout.wheel.h, layout.wheel.radius,
|
|
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
|
|
slotCount,
|
|
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
|
|
CONFIG?.PHASE_AMPLITUDE_MODE || 'bandpass',
|
|
].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';
|
|
drawWheelBackground(ctx, layout.wheelPanel, layout.wheel, CONFIG);
|
|
if (slotCount) {
|
|
drawMeterBackground(ctx, layout.meter);
|
|
drawMeterPanelDividers(ctx, layout.meter, slotCount, CONFIG, computePanelSlotWidth(layout.meter.w, slotCount));
|
|
}
|
|
state.staticLayerCanvas = canvas;
|
|
state.staticLayerKey = key;
|
|
return canvas;
|
|
}
|
|
|
|
function resolveMeterWidth(totalWidth, slotCount) {
|
|
if (slotCount >= 3) return Math.max(METER_WIDTH, Math.round(totalWidth * 0.35));
|
|
return slotCount === 2 ? 280 : 160;
|
|
}
|
|
|
|
function drawWheelBackground(g, panel, wheel, CONFIG) {
|
|
const ringDbValues = getRingDbValues(CONFIG);
|
|
g.save();
|
|
g.fillStyle = PANEL_BG;
|
|
g.fillRect(panel.x, panel.y, panel.w, panel.h);
|
|
g.strokeStyle = FRAME_COLOR;
|
|
g.lineWidth = 2;
|
|
g.strokeRect(panel.x, panel.y, panel.w, panel.h);
|
|
g.translate(wheel.cx, wheel.cy);
|
|
drawPhaseSectors(g, wheel);
|
|
const rings = ringDbValues.length;
|
|
for (let i = 0; i < rings; i++) {
|
|
const frac = ringFraction(i, rings);
|
|
const r = wheel.radius * frac;
|
|
g.strokeStyle = 'rgba(0,231,255,0.2)';
|
|
g.lineWidth = i === 0 ? 1.5 : 1;
|
|
g.setLineDash(i === 0 ? [] : [4, 4]);
|
|
g.beginPath();
|
|
g.arc(0, 0, r, 0, Math.PI * 2);
|
|
g.stroke();
|
|
}
|
|
g.setLineDash([]);
|
|
g.strokeStyle = 'rgba(0,231,255,0.35)';
|
|
for (let i = 0; i < 4; i++) {
|
|
const angle = (Math.PI / 2) * i;
|
|
g.beginPath();
|
|
g.moveTo(0, 0);
|
|
g.lineTo(Math.cos(angle) * wheel.radius, Math.sin(angle) * wheel.radius);
|
|
g.stroke();
|
|
}
|
|
drawAmplitudeScale(g, wheel, ringDbValues, CONFIG);
|
|
g.fillStyle = '#bcd';
|
|
g.font = '12px ui-monospace, monospace';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
const labels = [
|
|
{ text: '0°', angle: -Math.PI / 2 },
|
|
{ text: '-90°', angle: Math.PI },
|
|
{ text: '+90°', angle: 0 },
|
|
{ text: '180°', angle: Math.PI / 2 },
|
|
];
|
|
for (const lbl of labels) {
|
|
const inset = (lbl.text === '+90°' || lbl.text === '-90°') ? 34 : 24;
|
|
const x = Math.cos(lbl.angle) * (wheel.radius - inset);
|
|
const y = Math.sin(lbl.angle) * (wheel.radius - inset);
|
|
g.fillText(lbl.text, x, y);
|
|
}
|
|
g.restore();
|
|
}
|
|
|
|
function drawPhaseSectors(g, wheel) {
|
|
const radius = Math.max(10, wheel.radius - 6);
|
|
const width = 12;
|
|
for (const sector of PHASE_SECTORS) {
|
|
g.save();
|
|
g.strokeStyle = sector.color;
|
|
g.lineWidth = width;
|
|
g.beginPath();
|
|
g.arc(
|
|
0,
|
|
0,
|
|
radius,
|
|
degToCanvas(sector.startDeg),
|
|
degToCanvas(sector.endDeg),
|
|
false
|
|
);
|
|
g.stroke();
|
|
g.restore();
|
|
}
|
|
}
|
|
|
|
function drawIdleMessage(g, wheel) {
|
|
g.save();
|
|
g.fillStyle = '#bcd';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
g.font = 'bold 16px ui-monospace, monospace';
|
|
g.fillText('Waiting for audio…', wheel.cx, wheel.cy);
|
|
g.restore();
|
|
}
|
|
|
|
function drawAmplitudeScale(g, wheel, ringDbValues, CONFIG) {
|
|
const axisAngle = (3 * Math.PI) / 4;
|
|
const mode = getPhaseAmplitudeMode(CONFIG);
|
|
g.save();
|
|
g.fillStyle = '#9fe';
|
|
g.font = '11px ui-monospace, monospace';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
const rings = Array.isArray(ringDbValues) ? ringDbValues.length : 0;
|
|
for (let i = 0; i < rings; i++) {
|
|
const db = ringDbValues[i];
|
|
const r = wheel.radius * ringFraction(i, rings);
|
|
const x = Math.cos(axisAngle) * (r + 14);
|
|
const y = Math.sin(axisAngle) * (r + 14);
|
|
g.fillText(formatAmplitudeLabel(db, mode), x, y);
|
|
}
|
|
const titleX = Math.cos(axisAngle) * (wheel.radius + 34);
|
|
const titleY = Math.sin(axisAngle) * (wheel.radius + 34);
|
|
g.fillStyle = 'rgba(170,220,220,0.9)';
|
|
g.font = 'bold 11px ui-monospace, monospace';
|
|
g.fillText(mode === 'ppm-din' ? 'PPM DIN' : 'dBFS', titleX, titleY);
|
|
g.restore();
|
|
}
|
|
|
|
function degToCanvas(deg) {
|
|
return (deg * Math.PI) / 180 - Math.PI / 2;
|
|
}
|
|
|
|
function radToDeg(rad) {
|
|
return (rad * 180) / Math.PI;
|
|
}
|
|
|
|
function extractPhaseData(audio) {
|
|
const angle = Number(audio?.phaseAngleRad);
|
|
const level = Number(audio?.phaseLevel);
|
|
const peak = Number(audio?.phasePeak);
|
|
return {
|
|
ready: !!audio?.alive,
|
|
angle: Number.isFinite(angle) ? angle : null,
|
|
coherence: clamp01(Number(audio?.phaseCoherence) || 0),
|
|
level: Number.isFinite(level) ? Math.max(0, level) : 0,
|
|
peak: Number.isFinite(peak) ? Math.max(0, peak) : 0,
|
|
seq: Number(audio?.phaseSeq) || 0,
|
|
};
|
|
}
|
|
|
|
function updatePhasePointer(state, phaseData, gain = 1, CONFIG, audio, nowMs) {
|
|
const seq = Number(phaseData?.seq) || 0;
|
|
if (seq > 0 && state.phaseDisplaySeq === seq) return false;
|
|
const now = Number.isFinite(nowMs) ? nowMs : getNow();
|
|
const previousTs = Number.isFinite(state.phaseDisplayLastTs) && state.phaseDisplayLastTs > 0
|
|
? state.phaseDisplayLastTs
|
|
: now - (1000 / 60);
|
|
const dt = Math.max(1 / 240, Math.min(0.25, (now - previousTs) / 1000));
|
|
state.phaseDisplayLastTs = now;
|
|
state.phaseDisplaySeq = seq;
|
|
|
|
const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
|
|
const ringDbValues = getRingDbValues(CONFIG);
|
|
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
|
|
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
|
|
: 0;
|
|
const gainLinear = Number.isFinite(gain) ? gain : 1;
|
|
const targetRadius = amplitudeMode === 'ppm-din'
|
|
? ppmRadiusNorm
|
|
: linearToRadiusNorm(Math.min(1, phaseData.level * gainLinear), ringDbValues);
|
|
if (Number.isFinite(phaseData.angle) && phaseData.level >= PHASE_LEVEL_THRESHOLD) {
|
|
const targetPhase = wrapAngle(phaseData.angle - Math.PI / 2);
|
|
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : targetPhase;
|
|
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : targetRadius;
|
|
state.currentPhase = targetPhase;
|
|
state.currentRadius = targetRadius;
|
|
state.phaseConfidence = phaseData.coherence;
|
|
state.smoothPhase = smoothAngle(prevPhase, targetPhase, smoothingAlpha(dt, PHASE_SMOOTH_TAU_S));
|
|
state.smoothRadius = lerp(prevRadius, targetRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
|
|
} else {
|
|
state.phaseConfidence = 0;
|
|
decayPhasePointer(state, dt);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
|
|
g.save();
|
|
g.lineWidth = 1.5;
|
|
g.globalAlpha = 0.95;
|
|
if (trace && trace.count > 1) {
|
|
// reserved for future trace rendering
|
|
}
|
|
g.restore();
|
|
updatePhaseTrail(state, wheel, CONFIG, timestamp);
|
|
drawPhaseTrail(g, wheel, state, CONFIG, timestamp);
|
|
drawPhasePointer(g, wheel, state);
|
|
}
|
|
|
|
function drawPhasePointer(g, wheel, state) {
|
|
if (!Number.isFinite(state?.smoothPhase) || !Number.isFinite(state?.smoothRadius)) return;
|
|
const phase = state.smoothPhase;
|
|
const radiusNorm = Math.max(0, state.smoothRadius);
|
|
if (radiusNorm <= 0.002) return;
|
|
const length = radiusNorm * wheel.radius;
|
|
g.save();
|
|
g.translate(wheel.cx, wheel.cy);
|
|
g.strokeStyle = '#ffe36e';
|
|
g.fillStyle = '#ffe36e';
|
|
g.lineWidth = 3;
|
|
g.beginPath();
|
|
g.moveTo(0, 0);
|
|
g.lineTo(Math.cos(phase) * length, Math.sin(phase) * length);
|
|
g.stroke();
|
|
g.beginPath();
|
|
g.arc(0, 0, 4, 0, Math.PI * 2);
|
|
g.fill();
|
|
g.restore();
|
|
}
|
|
|
|
function drawPhaseAngleLabel(g, wheelPanel, wheel, state, nowMs) {
|
|
const hasPhase = Number.isFinite(state?.smoothPhase) && Number.isFinite(state?.smoothRadius) && state.smoothRadius > 0.002;
|
|
const now = Number.isFinite(nowMs) ? nowMs : getNow();
|
|
let degText = '--°';
|
|
if (hasPhase) {
|
|
const prevTs = Number.isFinite(state?._phaseLabelLastTs) ? state._phaseLabelLastTs : now;
|
|
const dt = Math.max(0, Math.min(0.25, (now - prevTs) / 1000));
|
|
// Die Zahl soll dem Zeiger folgen. Der Zeiger ist bereits geglättet (smoothPhase).
|
|
// Hier nur eine leichte, adaptive Glättung + Snap bei großen Sprüngen, damit Zahl und Zeiger
|
|
// bei schnellen Änderungen nicht auseinanderlaufen.
|
|
const targetPhase = state.smoothPhase;
|
|
const prevPhase = Number.isFinite(state?._phaseLabelPhase) ? state._phaseLabelPhase : targetPhase;
|
|
const diff = wrapAngle(targetPhase - prevPhase);
|
|
const absDiff = Math.abs(diff);
|
|
const snap = absDiff > (Math.PI / 3); // >60°: sofort folgen
|
|
const tau = 0.18; // schneller als vorher, damit der Wert "mitkommt"
|
|
const alpha = (snap || dt <= 0) ? 1 : (1 - Math.exp(-dt / tau));
|
|
const labelPhase = smoothAngle(prevPhase, targetPhase, alpha);
|
|
state._phaseLabelPhase = labelPhase;
|
|
state._phaseLabelLastTs = now;
|
|
|
|
let deg = radToDeg(labelPhase) + 90;
|
|
while (deg <= -180) deg += 360;
|
|
while (deg > 180) deg -= 360;
|
|
const minIntervalMs = 1000 / 30; // bis zu 30 updates/s (sonst wirkt es "hinterher")
|
|
const prevText = typeof state?._phaseLabelText === 'string' ? state._phaseLabelText : null;
|
|
const prevDisplayTs = Number.isFinite(state?._phaseLabelDisplayTs) ? state._phaseLabelDisplayTs : 0;
|
|
const nextText = `${deg > 0 ? '+' : ''}${deg.toFixed(0)}°`;
|
|
if (!prevText || snap || (now - prevDisplayTs) >= minIntervalMs) {
|
|
degText = nextText;
|
|
state._phaseLabelText = degText;
|
|
state._phaseLabelDisplayTs = now;
|
|
} else {
|
|
degText = prevText;
|
|
}
|
|
} else {
|
|
if (state) {
|
|
state._phaseLabelPhase = null;
|
|
state._phaseLabelLastTs = now;
|
|
state._phaseLabelText = '--°';
|
|
state._phaseLabelDisplayTs = now;
|
|
}
|
|
}
|
|
const boxPadding = 7;
|
|
const boxW = 96;
|
|
const boxH = 42;
|
|
const panelX = Number.isFinite(wheelPanel?.x) ? wheelPanel.x : 0;
|
|
const panelY = Number.isFinite(wheelPanel?.y) ? wheelPanel.y : wheel.y;
|
|
const boxX = panelX + 12;
|
|
const boxY = panelY + 12;
|
|
g.save();
|
|
g.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
g.strokeStyle = 'rgba(0, 231, 255, 0.65)';
|
|
g.lineWidth = 1;
|
|
g.fillRect(boxX, boxY, boxW, boxH);
|
|
g.strokeRect(boxX, boxY, boxW, boxH);
|
|
g.fillStyle = '#ffe36e';
|
|
g.font = 'bold 15px ui-monospace, monospace';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
g.fillText('Phase', boxX + boxW / 2, boxY + boxPadding + 4);
|
|
g.font = 'bold 19px ui-monospace, monospace';
|
|
g.fillText(degText, boxX + boxW / 2, boxY + boxH - boxPadding - 2);
|
|
g.restore();
|
|
}
|
|
|
|
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS) {
|
|
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
|
const avail = Math.max(200, canvasWidth);
|
|
const totalGap = (n - 1) * 12;
|
|
const usable = avail - totalGap;
|
|
return Math.floor(usable / n);
|
|
}
|
|
|
|
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 = 12;
|
|
const slotW = panelSlotWidth;
|
|
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
|
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
|
|
|
for (let i = 0; i < slotCount; i++) {
|
|
const rectM = {
|
|
x: startX + i * (slotW + gap),
|
|
y: meterLayout.y + METER_PAD_TOP,
|
|
w: slotW,
|
|
h: Math.max(40, meterLayout.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_EXTRA_BOTTOM_PAD),
|
|
};
|
|
|
|
try {
|
|
await meters.draw(g, rectM, slots[i], CONFIG);
|
|
} catch (e) {
|
|
console.warn(`Meter ${slots[i]} draw error:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
|
|
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
|
|
const 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));
|
|
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 + 6);
|
|
g.lineTo(dividerX, meterLayout.y + meterLayout.h - 6);
|
|
g.stroke();
|
|
g.restore();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function drawMeterBackground(g, meterRect) {
|
|
g.save();
|
|
g.fillStyle = PANEL_BG;
|
|
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
|
g.restore();
|
|
}
|
|
|
|
function updatePhaseTrail(state, wheel, CONFIG, now) {
|
|
if (!state.phaseTrail) state.phaseTrail = [];
|
|
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
|
state.phaseTrail.length = 0;
|
|
return;
|
|
}
|
|
const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
|
|
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
|
|
const phase = wrapAngle(state.smoothPhase);
|
|
const point = createPhaseTrailPoint(wheel, phase, radiusNorm, now);
|
|
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
|
|
if (!last) {
|
|
state.phaseTrail.push(point);
|
|
} else {
|
|
const dt = now - last.t;
|
|
const dx = point.x - last.x;
|
|
const dy = point.y - last.y;
|
|
const dist2 = dx * dx + dy * dy;
|
|
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== point.color) {
|
|
appendPhaseTrailSegment(state.phaseTrail, wheel, last, phase, radiusNorm, now);
|
|
} else {
|
|
last.x = point.x;
|
|
last.y = point.y;
|
|
last.t = now;
|
|
last.color = point.color;
|
|
last.phase = phase;
|
|
last.radiusNorm = radiusNorm;
|
|
}
|
|
}
|
|
}
|
|
trimPhaseTrail(state, CONFIG, now);
|
|
}
|
|
|
|
function createPhaseTrailPoint(wheel, phase, radiusNorm, timestamp) {
|
|
const length = radiusNorm * wheel.radius;
|
|
return {
|
|
x: wheel.cx + Math.cos(phase) * length,
|
|
y: wheel.cy + Math.sin(phase) * length,
|
|
t: timestamp,
|
|
color: trailColorForAngle(phase),
|
|
phase,
|
|
radiusNorm,
|
|
};
|
|
}
|
|
|
|
function appendPhaseTrailSegment(trail, wheel, last, phase, radiusNorm, timestamp) {
|
|
const startPhase = Number.isFinite(last?.phase) ? last.phase : phase;
|
|
const startRadius = Number.isFinite(last?.radiusNorm) ? last.radiusNorm : radiusNorm;
|
|
const phaseDelta = wrapAngle(phase - startPhase);
|
|
const steps = Math.max(1, Math.ceil(Math.abs(phaseDelta) / PHASE_TRAIL_MAX_ANGLE_STEP_RAD));
|
|
const startTime = Number.isFinite(last?.t) ? last.t : timestamp;
|
|
|
|
for (let step = 1; step <= steps; step++) {
|
|
const fraction = step / steps;
|
|
const interpolatedPhase = wrapAngle(startPhase + phaseDelta * fraction);
|
|
const interpolatedRadius = lerp(startRadius, radiusNorm, fraction);
|
|
const interpolatedTime = lerp(startTime, timestamp, fraction);
|
|
trail.push(createPhaseTrailPoint(
|
|
wheel,
|
|
interpolatedPhase,
|
|
interpolatedRadius,
|
|
interpolatedTime,
|
|
));
|
|
}
|
|
}
|
|
|
|
function trimPhaseTrail(state, CONFIG, now) {
|
|
if (!state.phaseTrail) state.phaseTrail = [];
|
|
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
|
state.phaseTrail.length = 0;
|
|
return;
|
|
}
|
|
const cutoff = now - PHASE_TRAIL_FADE_MS;
|
|
let write = 0;
|
|
for (let i = 0; i < state.phaseTrail.length; i++) {
|
|
const pt = state.phaseTrail[i];
|
|
if (pt.t >= cutoff) {
|
|
state.phaseTrail[write++] = pt;
|
|
}
|
|
}
|
|
state.phaseTrail.length = write;
|
|
if (state.phaseTrail.length > PHASE_TRAIL_MAX_POINTS) {
|
|
state.phaseTrail.splice(0, state.phaseTrail.length - PHASE_TRAIL_MAX_POINTS);
|
|
}
|
|
}
|
|
|
|
function drawPhaseTrail(g, wheel, state, CONFIG, now) {
|
|
if (!CONFIG?.PHASE_TRAIL_ENABLED) return;
|
|
const pts = state.phaseTrail;
|
|
if (!pts || pts.length < 2) return;
|
|
g.save();
|
|
g.lineCap = 'round';
|
|
g.lineWidth = 2.5;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
const prev = pts[i - 1];
|
|
const curr = pts[i];
|
|
const age = Math.max(0, now - curr.t);
|
|
const alpha = Math.max(0, 1 - age / PHASE_TRAIL_FADE_MS);
|
|
if (alpha <= 0) continue;
|
|
g.globalAlpha = alpha;
|
|
g.strokeStyle = curr.color;
|
|
g.beginPath();
|
|
g.moveTo(prev.x, prev.y);
|
|
g.lineTo(curr.x, curr.y);
|
|
g.stroke();
|
|
}
|
|
g.restore();
|
|
}
|
|
|
|
function trailColorForAngle(angle) {
|
|
let deg = radToDeg(angle) + 90;
|
|
while (deg <= -180) deg += 360;
|
|
while (deg > 180) deg -= 360;
|
|
const centered = deg;
|
|
if (Math.abs(centered) <= 30) return '#48d296';
|
|
if (Math.abs(centered) <= 60) return '#ffd678';
|
|
return '#ff7a78';
|
|
}
|
|
|
|
function ringFraction(index, ringCount) {
|
|
const n = Math.max(1, ringCount | 0);
|
|
const idx = Math.max(0, Math.min(n - 1, index | 0));
|
|
return (n - idx) / n;
|
|
}
|
|
|
|
function getPhaseAmplitudeMode(CONFIG) {
|
|
return CONFIG?.PHASE_AMPLITUDE_MODE === 'ppm-din' ? 'ppm-din' : 'bandpass';
|
|
}
|
|
|
|
function getRingDbValues(CONFIG) {
|
|
return getPhaseAmplitudeMode(CONFIG) === 'ppm-din' ? RING_PPM_DIN : RING_DBFS;
|
|
}
|
|
|
|
function formatAmplitudeLabel(db, mode) {
|
|
const num = Number(db);
|
|
if (!Number.isFinite(num)) return '';
|
|
if (mode === 'ppm-din') return num > 0 ? `+${num}` : `${num}`;
|
|
return `${num} dB`;
|
|
}
|
|
|
|
function dbToRadiusNorm(db, ringDbValues) {
|
|
if (!Array.isArray(ringDbValues) || !ringDbValues.length) return 0;
|
|
const n = ringDbValues.length;
|
|
const maxDb = ringDbValues[0];
|
|
const minDb = ringDbValues[n - 1];
|
|
if (!Number.isFinite(db)) return ringFraction(n - 1, n);
|
|
if (db >= maxDb) return ringFraction(0, n);
|
|
if (db <= minDb) return ringFraction(n - 1, n);
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const hiDb = ringDbValues[i];
|
|
const loDb = ringDbValues[i + 1];
|
|
if (db <= hiDb && db >= loDb) {
|
|
const span = Math.max(1e-10, hiDb - loDb);
|
|
const t = (db - loDb) / span;
|
|
const hiFrac = ringFraction(i, n);
|
|
const loFrac = ringFraction(i + 1, n);
|
|
return loFrac + (hiFrac - loFrac) * t;
|
|
}
|
|
}
|
|
return ringFraction(n - 1, n);
|
|
}
|
|
|
|
function linearToRadiusNorm(amp, ringDbValues) {
|
|
const ampDb = linearToDb(Math.max(1e-10, Math.min(1, amp)));
|
|
return dbToRadiusNorm(ampDb, ringDbValues);
|
|
}
|
|
|
|
function mapPpmRawToDin(raw, cfg) {
|
|
const minDb = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : -50;
|
|
const maxDb = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : +5;
|
|
if (!Number.isFinite(raw)) return minDb;
|
|
const base = Number.isFinite(cfg?.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? cfg.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15;
|
|
const effOff = (cfg?.PPM_DIN_MODE === 'al_minus6' ? -6 : -9) + (Number(cfg?.PPM_DIN_TRIM_DB) || 0);
|
|
const mapped = (raw - base) + effOff;
|
|
return Math.max(minDb, Math.min(maxDb, mapped));
|
|
}
|
|
|
|
function computePpmDinRadiusNorm(audio, cfg, ringDbValues) {
|
|
const rawL = Number.isFinite(audio?.ppmDinL) ? audio.ppmDinL : audio?.ppmL;
|
|
const rawR = Number.isFinite(audio?.ppmDinR) ? audio.ppmDinR : audio?.ppmR;
|
|
const l = mapPpmRawToDin(rawL, cfg);
|
|
const r = mapPpmRawToDin(rawR, cfg);
|
|
const db = Math.max(l, r);
|
|
return dbToRadiusNorm(db, ringDbValues);
|
|
}
|
|
|
|
function decayPhasePointer(state, dt = 1 / 60) {
|
|
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
|
|
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;
|
|
const decayedRadius = prevRadius * Math.exp(-Math.max(0, dt) / PHASE_IDLE_TAU_S);
|
|
state.currentPhase = prevPhase;
|
|
state.currentRadius = decayedRadius;
|
|
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
|
|
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
|
|
state.smoothPhase = prevSmoothPhase;
|
|
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, smoothingAlpha(dt, PHASE_RADIUS_TAU_S));
|
|
}
|
|
|
|
function resolvePhaseGain(state, phaseData, CONFIG) {
|
|
const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
|
|
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
|
|
if (allowAgc && phaseData?.ready) {
|
|
const seq = Number(phaseData.seq) || 0;
|
|
if (seq > 0 && state.phaseAgcSeq === seq) {
|
|
return { gainDb: state.phaseAgcGainDb, gain: dbToLinear(state.phaseAgcGainDb) };
|
|
}
|
|
const auto = computePhaseAgcGain(state, phaseData);
|
|
state.phaseAgcSeq = seq;
|
|
return { gainDb: auto.gainDb, gain: auto.gain };
|
|
}
|
|
state.phaseAgcGainDb = gainDb;
|
|
state.phaseAgcSeq = Number(phaseData?.seq) || 0;
|
|
return { gainDb, gain: dbToLinear(gainDb) };
|
|
}
|
|
|
|
function wrapAngle(rad) {
|
|
if (!Number.isFinite(rad)) return 0;
|
|
|
|
// Verbesserte Winkel-Normalisierung mit besserer numerischer Stabilität
|
|
const TWO_PI = Math.PI * 2;
|
|
const normalized = ((rad % TWO_PI) + TWO_PI) % TWO_PI;
|
|
|
|
// Sicherstellen, dass der Winkel im Bereich [-π, π] liegt
|
|
return normalized > Math.PI ? normalized - TWO_PI : normalized;
|
|
}
|
|
|
|
function smoothAngle(prev, next, alpha) {
|
|
if (!Number.isFinite(prev)) return next;
|
|
if (!Number.isFinite(next)) return prev;
|
|
const diff = wrapAngle(next - prev);
|
|
return wrapAngle(prev + diff * Math.max(0, Math.min(1, alpha)));
|
|
}
|
|
|
|
function lerp(a, b, t) {
|
|
if (!Number.isFinite(a)) return b;
|
|
if (!Number.isFinite(b)) return a;
|
|
const clampedT = Math.max(0, Math.min(1, t));
|
|
return a + (b - a) * clampedT;
|
|
}
|
|
|
|
export function smoothingAlpha(dt, tau) {
|
|
const seconds = Number.isFinite(dt) ? Math.max(0, dt) : 0;
|
|
const timeConstant = Number.isFinite(tau) ? Math.max(1e-6, tau) : 1e-6;
|
|
return 1 - Math.exp(-seconds / timeConstant);
|
|
}
|
|
|
|
function clamp01(value) {
|
|
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
|
}
|
|
|
|
function computePhaseAgcGain(state, phaseData) {
|
|
const now = getNow();
|
|
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
|
|
state.phaseAgcLastTs = now;
|
|
|
|
const peak = Math.max(1e-8, Number(phaseData?.peak) || 0);
|
|
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
|
|
|
|
if (peak >= env) {
|
|
const alpha = 1 - Math.exp(-Math.max(dt, 0) / Math.max(0.001, PHASE_AGC_ATTACK_S));
|
|
env = env + (peak - env) * Math.min(1, alpha || 1);
|
|
} else {
|
|
const releaseFactor = Math.pow(10, -PHASE_AGC_RELEASE_DB_PER_S * Math.max(dt, 0) / 20);
|
|
env = Math.max(peak, env * releaseFactor);
|
|
}
|
|
|
|
env = Math.max(1e-8, env); // Verbesserter minimaler Wert
|
|
state.phaseAgcEnv = env;
|
|
|
|
const envDb = linearToDb(env);
|
|
let gainDb = PHASE_AGC_TARGET_DB - envDb;
|
|
|
|
if (gainDb > PHASE_GAIN_MAX_DB) gainDb = PHASE_GAIN_MAX_DB;
|
|
if (gainDb < PHASE_GAIN_MIN_DB) gainDb = PHASE_GAIN_MIN_DB;
|
|
|
|
state.phaseAgcGainDb = gainDb;
|
|
return { gainDb, gain: dbToLinear(gainDb) };
|
|
}
|
|
|
|
function clampPhaseGain(db) {
|
|
let val = Number(db);
|
|
if (!Number.isFinite(val)) val = 0;
|
|
if (val < PHASE_GAIN_MIN_DB) val = PHASE_GAIN_MIN_DB;
|
|
if (val > PHASE_GAIN_MAX_DB) val = PHASE_GAIN_MAX_DB;
|
|
return val;
|
|
}
|
|
|
|
function getNow() {
|
|
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
return performance.now();
|
|
}
|
|
return Date.now();
|
|
}
|
|
|
|
function linearToDb(value) {
|
|
if (value <= 1e-10) return -200; // Explizite Behandlung sehr kleiner Werte
|
|
const result = 20 * Math.log10(value);
|
|
return Number.isFinite(result) ? result : -200;
|
|
}
|
|
|
|
function dbToLinear(db) {
|
|
if (!Number.isFinite(db)) return 0;
|
|
if (db < -200) return 0;
|
|
const result = Math.pow(10, db / 20);
|
|
return Number.isFinite(result) ? result : 0;
|
|
}
|