Files
Phoenix/www/views/phase_wheel.js
T

782 lines
27 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_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 length = radiusNorm * wheel.radius;
const x = wheel.cx + Math.cos(state.smoothPhase) * length;
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
const color = trailColorForAngle(state.smoothPhase);
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
if (!last) {
state.phaseTrail.push({ x, y, t: now, color });
} else {
const dt = now - last.t;
const dx = x - last.x;
const dy = 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 !== color) {
state.phaseTrail.push({ x, y, t: now, color });
} else {
last.x = x;
last.y = y;
last.t = now;
last.color = color;
}
}
}
trimPhaseTrail(state, CONFIG, now);
}
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;
}
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;
}