// 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 TARGET_POINTS = 1024; const COLOR_STOPS = [ { t: 0.0, color: [0, 0, 50] }, { t: 0.3, color: [0, 120, 180] }, { t: 0.6, color: [0, 205, 120] }, { t: 0.8, color: [210, 220, 0] }, { t: 1.0, color: [255, 120, 0] }, ]; 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_BASE_GAIN = 1 / 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_DECAY = 0.85; const PHASE_PHASE_SMOOTH_ALPHA = 0.08; const PHASE_RADIUS_SMOOTH_ALPHA = 0.18; const PHASE_BANDPASS_LOW_HZ = 300; const PHASE_BANDPASS_HIGH_HZ = 5000; 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)' }, ]; // Numerisch stabilere Hilbert-Kernel-Erstellung const HILBERT_KERNEL = buildHilbertKernel(33); const HILBERT_HALF = (HILBERT_KERNEL.length - 1) / 2; // Lookup-Tables für häufig verwendete Werte const ANGLE_COS = new Float32Array(360); const ANGLE_SIN = new Float32Array(360); for (let i = 0; i < 360; i++) { const rad = (i * Math.PI) / 180; ANGLE_COS[i] = Math.cos(rad); ANGLE_SIN[i] = Math.sin(rad); } export function init() { return { traceBuffer: new Float32Array(0), ampBuffer: new Float32Array(0), filteredL: new Float32Array(0), filteredR: new Float32Array(0), currentPhase: null, currentRadius: 0, smoothPhase: null, smoothRadius: 0, phaseAgcEnv: 1e-3, phaseAgcGainDb: 0, phaseAgcLastTs: 0, bandpass: createBandpassState(), bufferGrowthCount: 0, maxBufferSize: 0, 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 xyData = extractXYData(audio); const gainCtrl = resolvePhaseGain(state, xyData, CONFIG); if (xyData.ready) { const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio); renderWheel(g, trace, layout.wheel, state, CONFIG, now); } else { decayPhasePointer(state); 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 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, sampleRate: audio?.sampleRate || 48000, }; } function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) { if (!xyData.ready || !xyData.length) { decayPhasePointer(state); return null; } const filtered = preparePhaseFilteredBuffers(state, xyData); const amplitudeMode = getPhaseAmplitudeMode(CONFIG); const ringDbValues = getRingDbValues(CONFIG); const ppmRadiusNorm = amplitudeMode === 'ppm-din' ? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues) : 0; const targetPoints = Math.min(TARGET_POINTS, xyData.length); const step = Math.max(1, Math.floor(xyData.length / targetPoints)); const radius = wheel.radius; let ampIdx = 0; let sumSin = 0; let sumCos = 0; let sumRadius = 0; let levelAcc = 0; const gainLinear = Number.isFinite(gain) ? gain : 1; for (let i = 0; i < xyData.length; i += step) { const lRe = clamp1(filtered.L[i]); const rRe = clamp1(filtered.R[i]); const lIm = hilbertAt(filtered.L, i); const rIm = hilbertAt(filtered.R, i); const phaseL = Math.atan2(lIm, lRe); const phaseR = Math.atan2(rIm, rRe); let phaseDiff = phaseL - phaseR; if (!Number.isFinite(phaseDiff)) continue; phaseDiff = wrapAngle(phaseDiff); const angle = phaseDiff - Math.PI / 2; const magL = Math.min(1, Math.hypot(lRe, lIm)); const magR = Math.min(1, Math.hypot(rRe, rIm)); const amp = Math.min(1, 0.5 * (magL + magR)); const ampScaled = Math.min(1, amp * gainLinear); const radiusNorm = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : linearToRadiusNorm(ampScaled, ringDbValues); ampIdx++; sumSin += Math.sin(angle); sumCos += Math.cos(angle); sumRadius += radiusNorm; levelAcc += amp * amp; } if (ampIdx > 0) { const invCount = 1 / ampIdx; const avgAngle = Math.atan2(sumSin * invCount, sumCos * invCount); const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount); const blockLevel = Math.sqrt(levelAcc * invCount); if (blockLevel >= PHASE_LEVEL_THRESHOLD) { const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle; const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius; state.currentPhase = avgAngle; state.currentRadius = avgRadius; state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA); state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA); } else { decayPhasePointer(state); } } else { decayPhasePointer(state); } return { count: ampIdx, radius }; } 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 clamp1(value) { if (!Number.isFinite(value)) return 0; return Math.max(-1, Math.min(1, value)); } 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 createBandpassState() { return { sampleRate: 0, hpAlpha: 0, lpAlpha: 0, channels: { L: { hpX: 0, hpY: 0, lpY: 0 }, R: { hpX: 0, hpY: 0, lpY: 0 }, }, }; } function ensureBandpassCoeffs(state, sampleRate) { if (!state.bandpass) state.bandpass = createBandpassState(); const sr = Math.max(8000, Math.round(sampleRate) || 48000); if (state.bandpass.sampleRate === sr) return; state.bandpass.sampleRate = sr; state.bandpass.hpAlpha = computeHighpassAlpha(sr, PHASE_BANDPASS_LOW_HZ); state.bandpass.lpAlpha = computeLowpassAlpha(sr, PHASE_BANDPASS_HIGH_HZ); } function computeHighpassAlpha(sampleRate, cutoff) { const rc = 1 / (2 * Math.PI * Math.max(1, cutoff)); const dt = 1 / Math.max(1, sampleRate); return Math.max(0, Math.min(1, rc / (rc + dt))); } function computeLowpassAlpha(sampleRate, cutoff) { const rc = 1 / (2 * Math.PI * Math.max(1, cutoff)); const dt = 1 / Math.max(1, sampleRate); return Math.max(0, Math.min(1, dt / (rc + dt))); } function ensureFilteredBuffers(state, length) { const neededLength = Math.ceil(length * 1.1); // 10% Puffer für Stabilität if (!state.filteredL || state.filteredL.length < neededLength) { state.filteredL = new Float32Array(neededLength); } if (!state.filteredR || state.filteredR.length < neededLength) { state.filteredR = new Float32Array(neededLength); } return { L: state.filteredL, R: state.filteredR }; } function applyBandpassSample(sample, channelState, bandpassState) { const hpAlpha = bandpassState.hpAlpha; const lpAlpha = bandpassState.lpAlpha; if (!Number.isFinite(sample)) sample = 0; const hpY = hpAlpha * (channelState.hpY + sample - channelState.hpX); channelState.hpY = Number.isFinite(hpY) ? hpY : 0; channelState.hpX = sample; const lpY = lpAlpha * hpY + (1 - lpAlpha) * channelState.lpY; channelState.lpY = Number.isFinite(lpY) ? lpY : 0; return lpY; } function preparePhaseFilteredBuffers(state, xyData) { ensureBandpassCoeffs(state, xyData.sampleRate || 48000); const filtered = ensureFilteredBuffers(state, xyData.length); // Reset channel states if they contain NaN/Infinity if (!Number.isFinite(state.bandpass.channels.L.hpY)) { state.bandpass.channels.L = { hpX: 0, hpY: 0, lpY: 0 }; } if (!Number.isFinite(state.bandpass.channels.R.hpY)) { state.bandpass.channels.R = { hpX: 0, hpY: 0, lpY: 0 }; } for (let i = 0; i < xyData.length; i++) { filtered.L[i] = applyBandpassSample( xyData.xyL[i], state.bandpass.channels.L, state.bandpass ); filtered.R[i] = applyBandpassSample( xyData.xyR[i], state.bandpass.channels.R, state.bandpass ); } return filtered; } function decayPhasePointer(state) { const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0; const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0; const decayedRadius = prevRadius * PHASE_IDLE_DECAY; 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 = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5); state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA); } function resolvePhaseGain(state, xyData, CONFIG) { const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0); const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din'; if (allowAgc && xyData?.ready) { const auto = computePhaseAgcGain(state, xyData); return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN }; } state.phaseAgcGainDb = gainDb; 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; } function hilbertAt(buffer, idx) { if (!buffer || idx < 0 || idx >= buffer.length) return 0; let acc = 0; for (let k = 0; k < HILBERT_KERNEL.length; k++) { const src = idx + k - HILBERT_HALF; if (src < 0 || src >= buffer.length) continue; const sample = buffer[src]; const kernel = HILBERT_KERNEL[k]; if (Number.isFinite(sample) && Number.isFinite(kernel)) { acc += sample * kernel; } } return Number.isFinite(acc) ? acc : 0; } function buildHilbertKernel(size = 33) { const taps = size % 2 === 0 ? size + 1 : size; const mid = (taps - 1) / 2; const kernel = new Float32Array(taps); for (let n = 0; n < taps; n++) { const k = n - mid; // Verbesserte numerische Stabilität + even k = 0 wie idealer Hilbert-Kernel if (Math.abs(k) < 1e-10 || k % 2 === 0) { kernel[n] = 0; continue; } const window = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / Math.max(1, taps - 1)); const value = (2 / (Math.PI * k)) * window; // Sicherstellen, dass der Wert finite ist kernel[n] = Number.isFinite(value) ? value : 0; } return kernel; } function computePhaseAgcGain(state, xyData) { const now = getNow(); const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0; state.phaseAgcLastTs = now; const peak = measurePhasePeak(xyData); 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 measurePhasePeak(xyData) { if (!xyData || !xyData.ready) return 1e-4; // Verbesserter Default-Wert let peak = 1e-8; // Höhere Präzision const len = Math.min(xyData.length, 1000); // Begrenzung für Performance for (let i = 0; i < len; i++) { const sample = Math.max( Math.abs(xyData.xyL[i] || 0), Math.abs(xyData.xyR[i] || 0) ); if (sample > peak) peak = sample; } return Math.max(1e-8, peak); // Sicherstellen, dass nicht 0 zurückgegeben wird } 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; }