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