// views/classic_needles.js - VU-Meter als klassisches Nadelinstrument // Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet // zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl. import { drawCachedStaticLayer } from './static_layer.js'; import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js'; export const id = 'classic-needles'; const PANEL_BG = 'rgba(5,5,11,0.78)'; const FRAME_STROKE = 'rgba(0,231,255,0.8)'; const SCALE_ARC_STROKE = 'rgb(0,0,255)'; // wie Tick-Linien der Balken-Meter const METER_PIVOT_Y_FRAC = 0.70; const METER_RADIUS_FRAC = 0.50; const NEEDLE_CAP_RADIUS = 8; const NEEDLE_SPRING_DAMPING = 0.92; // 1.0 = kritisch gedämpft (kein Überschwingen) const NEEDLE_SPRING_HZ_UP = 6.5; // nur UI-Physik (VU-Ballistik ist bereits im Worklet) const NEEDLE_SPRING_HZ_DOWN = 4.2; const NEEDLE_GOAL_TAU_S = 0.04; // ganz leichtes Smoothing gegen UI-Stufen/Jitter const FIELD_DIVIDER_STROKE = 'rgba(0,231,255,0.4)'; // wie Felder-Trennlinien const FIELD_DIVIDER_DASH = [4, 3]; // Spannweite wie klassisches VU-Foto: kein Halbkreis, sondern flacherer Bogen const ANGLE_START = degToRad(200) - Math.PI * 2; // links unten const ANGLE_END = degToRad(340) - Math.PI * 2; // rechts unten (-20°) const VU_DEFLECTION = [ { db: -20, frac: 0.000 }, { db: -10, frac: 0.165 }, { db: -7, frac: 0.264 }, { db: -5, frac: 0.352 }, { db: -3, frac: 0.463 }, { db: 0, frac: 0.686 }, { db: +1, frac: 0.779 }, { db: +2, frac: 0.883 }, { db: +3, frac: 1.000 }, ]; const DIN_SCALE = [ { db: -50, pos: 0.0205 }, { db: -40, pos: 0.0564 }, { db: -35, pos: 0.0974 }, { db: -30, pos: 0.1538 }, { db: -25, pos: 0.2308 }, { db: -20, pos: 0.3179 }, { db: -15, pos: 0.4359 }, { db: -10, pos: 0.5538 }, { db: -5, pos: 0.7026 }, { db: 0, pos: 0.8513 }, { db: +5, pos: 1.0000 }, ]; const PPM_EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12]; const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10]; const PPM_DIN_MAJOR_TICKS = [-50, -40, -30, -20, -10, -5, 0, +5]; const PPM_DIN_MINOR_TICKS = [ { db: -35 }, { db: -25 }, { db: -21, color: 'warn' }, { db: -15 }, { db: -9 }, { db: -8 }, { db: -7 }, { db: -6 }, { db: -4 }, { db: -3 }, { db: -2 }, { db: -1 }, { db: +1 }, { db: +2 }, { db: +3 }, { db: +4 }, ]; export function init() { return { staticLayers: new Map(), smooth: { L: null, R: null }, goal: { L: null, R: null }, vel: { L: 0, R: 0 }, lastTs: (typeof performance !== 'undefined' ? performance.now() : Date.now()), }; } export function resize() {} export function destroy(state) { if (state) state.staticLayers = null; } export async function render(env, state) { const { ctx: g, rect, config: CONFIG, meters, audio } = env; const meterId = resolveNeedleMeterId(env); if (!meterId) { drawCachedStaticLayer(state, g, 'classic-empty', 'empty', rect, (cg) => { cg.fillStyle = PANEL_BG; cg.fillRect(0, 0, rect.w, rect.h); cg.fillStyle = '#bcd'; cg.textAlign = 'left'; cg.font = 'bold 16px ui-monospace, monospace'; cg.fillText('Classic Needles', 12, 44); cg.fillStyle = '#9aa'; cg.font = '14px ui-monospace, monospace'; cg.fillText('Slot leer — wähle ein Meter im HUD.', 12, 66); }); return; } const scale = getNeedleScaleDescriptor(meterId, CONFIG); const meterState = meters?.getState?.(meterId) || null; const raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio); const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) }; const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()); if (meterId === 'rms') { // RMS ballistics are already measured sample-continuously in the backend. // A second spring model here would add view-dependent delay and values. state.smooth.L = targets.L; state.smooth.R = targets.R; state.goal.L = targets.L; state.goal.R = targets.R; state.vel.L = 0; state.vel.R = 0; state.lastTs = now; } else { smoothNeedle(state, targets, now); } // Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets) const BOX_LEFT = 0; const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70; const BOX_RIGHT = 0; const BOX_BOTTOM = 0; const outerRect = { x: rect.x + BOX_LEFT, y: rect.y + BOX_TOP, w: Math.max(140, rect.w - BOX_LEFT - BOX_RIGHT), h: Math.max(140, rect.h - BOX_TOP - BOX_BOTTOM), }; const INNER_INSET_X = Math.round(Math.min(140, outerRect.w * 0.08)); const innerRect = { x: outerRect.x + INNER_INSET_X, y: outerRect.y, w: Math.max(140, outerRect.w - INNER_INSET_X * 2), h: outerRect.h, }; const innerGap = Math.max(56, innerRect.w * 0.12); const availableW = Math.max(100, innerRect.w - innerGap); const meterW = availableW / 2; const meterH = outerRect.h; const baseY = outerRect.y; const decorMetrics = createDecorMetrics(meterW, innerGap); const gapCenterX = innerRect.x + meterW + innerGap / 2; const leftBoxClamp = decorMetrics.ownBounds ? { x: outerRect.x, y: outerRect.y, w: Math.max(0, Math.floor(gapCenterX - outerRect.x)), h: outerRect.h, } : outerRect; const rightBoxClamp = decorMetrics.ownBounds ? { x: Math.ceil(gapCenterX), y: outerRect.y, w: Math.max(0, (outerRect.x + outerRect.w) - Math.ceil(gapCenterX)), h: outerRect.h, } : outerRect; const metersRects = [ { x: innerRect.x, y: baseY, w: meterW, h: meterH, label: 'L', norm: state.smooth.L, raw: raw.L, boxClamp: leftBoxClamp }, { x: innerRect.x + meterW + innerGap, y: baseY, w: meterW, h: meterH, label: 'R', norm: state.smooth.R, raw: raw.R, boxClamp: rightBoxClamp }, ]; drawCachedStaticLayer( state, g, 'classic-shell', [ meterId, rect.w, rect.h, outerRect.x, outerRect.y, outerRect.w, outerRect.h, innerRect.x, innerRect.w, innerGap, scale.kind, scale.bottom, scale.top, scale.redStart, getFooterText(meterId, CONFIG), JSON.stringify(scale.majorTicks || []), JSON.stringify(scale.minorTicks || []), ].join('|'), rect, (cg) => { cg.fillStyle = PANEL_BG; cg.fillRect(0, 0, rect.w, rect.h); cg.save(); cg.translate(-rect.x, -rect.y); cg.strokeStyle = FRAME_STROKE; cg.lineWidth = 2; cg.strokeRect(outerRect.x, outerRect.y, outerRect.w, outerRect.h); metersRects.forEach((m) => { drawClassicMeter(cg, m, scale, decorMetrics); drawMeterBox(cg, m, m.boxClamp || outerRect, decorMetrics); drawRefLabel(cg, m, meterId, CONFIG, decorMetrics); drawNeedleCap(cg, m); }); cg.restore(); }, ); metersRects.forEach((m) => { drawNeedle(g, m, m.norm, getNeedleColor(m.norm, scale)); }); } function createDecorMetrics(meterW, innerGap) { const widthT = clamp((meterW - 150) / 170, 0, 1); const gapT = clamp((innerGap - 56) / 36, 0, 1); const t = Math.min(widthT, gapT); return { ownBounds: meterW < 300 || innerGap < 72, refFont: roundLerp(10, 12, t), refOffsetY: roundLerp(7, 10, t), boxOuterExtra: roundLerp(30, 48, t), boxPadX: roundLerp(4, 10, t), boxPadTop: roundLerp(6, 10, t), boxPadBottom: roundLerp(10, 16, t), channelFont: roundLerp(18, 26, t), channelPad: roundLerp(5, 8, t), majorFont: roundLerp(9, 13, t), majorTickWarn: roundLerp(11, 16, t), majorTickNormal: roundLerp(9, 14, t), majorLabelOffset: roundLerp(14, 20, t), unitFont: roundLerp(9, 12, t), unitAngleOffset: lerp(0.10, 0.16, t), unitOffset: roundLerp(24, 34, t), pctFont: roundLerp(10, 16, t), pctInset: roundLerp(30, 48, t), pct100YOffset: roundLerp(5, 10, t), pctSignFont: roundLerp(11, 18, t), pctSignOffsetX: roundLerp(10, 18, t), pctSignOffsetY: roundLerp(14, 28, t), }; } function drawRefLabel(g, rect, meterId, CONFIG, metrics) { const modeText = getFooterText(meterId, CONFIG); const refFont = metrics?.refFont ?? 12; const refOffsetY = metrics?.refOffsetY ?? 10; g.save(); g.fillStyle = 'rgba(207,233,255,0.7)'; g.font = `${refFont}px ui-monospace, monospace`; g.textAlign = 'center'; g.textBaseline = 'alphabetic'; g.fillText(modeText, rect.x + rect.w / 2, rect.y + rect.h - refOffsetY); g.restore(); } function drawMeterBox(g, rect, clampRect, metrics) { const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC; const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC; // Include labels (+20) and "- dB" (+34) plus some safety margin const outerR = radius + (metrics?.boxOuterExtra ?? 48); const xSpan = Math.max(Math.abs(Math.cos(ANGLE_START)), Math.abs(Math.cos(ANGLE_END))) * outerR; const padX = metrics?.boxPadX ?? 10; const padTop = metrics?.boxPadTop ?? 10; const padBottom = metrics?.boxPadBottom ?? 16; let x = cx - xSpan - padX; let y = cy - outerR - padTop; let w = (xSpan + padX) * 2; let h = (cy + NEEDLE_CAP_RADIUS + padBottom) - y; const clampSource = clampRect || rect; // Clamp to the available panel area. In compact layouts keep each box inside its own half. const minX = (clampSource?.x ?? rect.x) + 2; const minY = (clampSource?.y ?? rect.y) + 2; const maxX = (clampSource?.x ?? rect.x) + (clampSource?.w ?? rect.w) - 2; const maxY = (clampSource?.y ?? rect.y) + (clampSource?.h ?? rect.h) - 2; if (x < minX) { w -= (minX - x); x = minX; } if (y < minY) { h -= (minY - y); y = minY; } if (x + w > maxX) w = maxX - x; if (y + h > maxY) h = maxY - y; if (w <= 2 || h <= 2) return; g.save(); g.strokeStyle = FIELD_DIVIDER_STROKE; g.lineWidth = 1; g.setLineDash(FIELD_DIVIDER_DASH); g.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, Math.round(w) - 1, Math.round(h) - 1); g.setLineDash([]); const chan = rect?.label; if (chan === 'L' || chan === 'R') { g.fillStyle = 'rgba(207,233,255,0.7)'; g.font = `bold ${metrics?.channelFont ?? 26}px ui-monospace, monospace`; g.textBaseline = 'top'; const pad = metrics?.channelPad ?? 8; if (chan === 'L') { g.textAlign = 'left'; g.fillText('L', x + pad, y + pad - 2); } else { g.textAlign = 'right'; g.fillText('R', x + w - pad, y + pad - 2); } } g.restore(); } function getNeedleColor(norm, scale) { const warn = scale?.warnColor || '#ff3b3b'; const normal = scale?.normalColor || '#ffe066'; if (Number.isFinite(scale?.redStart) && typeof scale?.valueToNorm === 'function') { const redNorm = scale.valueToNorm(scale.redStart); if (Number.isFinite(redNorm) && Number.isFinite(norm) && norm >= redNorm) return warn; } return normal; } function resolveNeedleMeterId(env) { const slotList = env?.slots?.(id); const selected = Array.isArray(slotList) ? slotList[0] : null; if (selected === 'none') return null; return selected || 'vu'; } function getFooterText(meterId, CONFIG) { if (meterId === 'vu') { return Number.isFinite(CONFIG?.VU_DBFS_REF) ? `Ref ${CONFIG.VU_DBFS_REF.toFixed(1)} dBFS` : 'VU'; } if (meterId === 'ppm-ebu') return 'PPM (EBU)'; if (meterId === 'ppm-din') return 'PPM (DIN)'; if (meterId === 'tp') return 'dBTP'; if (meterId === 'rms') { const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu'); return isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)'; } if (meterId === 'lufs') return 'LUFS (M)'; return String(meterId || 'meter'); } function getNeedleScaleDescriptor(meterId, CONFIG) { if (meterId === 'ppm-ebu') { const bottom = Number.isFinite(CONFIG?.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14; const top = Number.isFinite(CONFIG?.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14; const redStart = Number.isFinite(CONFIG?.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9; const warnColor = CONFIG?.PPM_EBU_COLOR_WARN || '#ff3b3b'; const normalColor = CONFIG?.PPM_EBU_COLOR_NORMAL || '#ffe066'; const valueToNorm = (v) => { const clamped = clamp(v, bottom, top); return clamp01((clamped - bottom) / (top - bottom || 1)); }; return { id: meterId, kind: 'ppm-ebu', bottom, top, redStart, warnColor, normalColor, unitLabel: 'dB', majorTicks: PPM_EBU_MAJOR_TICKS, minorTicks: PPM_EBU_MINOR_TICKS, formatMajor: (db) => (db === 0 ? 'TEST' : (db > 0 ? `+${db}` : `${db}`)), valueToNorm, }; } if (meterId === 'ppm-din') { const bottom = Number.isFinite(CONFIG?.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50; const top = Number.isFinite(CONFIG?.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5; const redStart = Number.isFinite(CONFIG?.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0; const warnColor = CONFIG?.PPM_DIN_COLOR_WARN || '#ff3b3b'; const normalColor = CONFIG?.PPM_DIN_COLOR_NORMAL || '#ffe066'; const mapRaw = (db) => { const clamped = clamp(db, bottom, top); let prev = DIN_SCALE[0]; for (let i = 1; i < DIN_SCALE.length; i++) { const curr = DIN_SCALE[i]; if (clamped <= curr.db) { const span = curr.db - prev.db || 1; const t = (clamped - prev.db) / span; return prev.pos + t * (curr.pos - prev.pos); } prev = curr; } return DIN_SCALE[DIN_SCALE.length - 1].pos; }; const normBottom = mapRaw(bottom); const normSpan = Math.max(1e-6, 1 - normBottom); const valueToNorm = (v) => { const n = mapRaw(v); return clamp01((n - normBottom) / normSpan); }; const showAl = CONFIG?.AL_MARKERS_ENABLED !== false; const dynWarnDb = showAl ? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9) : null; const minors = PPM_DIN_MINOR_TICKS.slice().map((t) => ({ value: t.db, color: (t.color === 'warn' || (dynWarnDb !== null && t.db === dynWarnDb)) ? 'warn' : null, })); return { id: meterId, kind: 'ppm-din', bottom, top, redStart, warnColor, normalColor, unitLabel: 'dB', majorTicks: PPM_DIN_MAJOR_TICKS, minorTicks: minors, formatMajor: (db) => (db > 0 ? `+${db}` : `${db}`), valueToNorm, }; } if (meterId === 'tp') { const bottom = -60; const top = 6; const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1; const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b'; const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066'; const mapRaw = (db) => { const clamped = clamp(db, bottom, top); if (clamped <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac; if (clamped >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac; for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) { const prev = TRUE_PEAK_SCALE[i - 1]; const curr = TRUE_PEAK_SCALE[i]; if (clamped <= curr.db) { const span = curr.db - prev.db || 1; const t = (clamped - prev.db) / span; return prev.frac + t * (curr.frac - prev.frac); } } return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac; }; const valueToNorm = (v) => clamp01(mapRaw(v)); return { id: meterId, kind: 'tp', bottom, top, redStart, warnColor, normalColor, unitLabel: 'dBTP', majorTicks: TRUE_PEAK_SCALE.map((p) => p.db), minorTicks: TRUE_PEAK_MINOR_TICKS, formatMajor: (db) => { return db > 0 ? `+${db}` : String(db); }, valueToNorm, }; } if (meterId === 'rms') { const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu'); const bottom = -60; const top = isDBU ? +24 : 0; const redStart = Number.isFinite(CONFIG?.RMS_RED_START) ? CONFIG.RMS_RED_START : (isDBU ? +20 : 0); const warnColor = CONFIG?.RMS_COLOR_WARN || '#ff3b3b'; const normalColor = CONFIG?.RMS_COLOR_NORMAL || '#34d399'; const valueToNorm = (db) => { const v = clamp(db, bottom, top); if (!isDBU) return clamp01((v - bottom) / (top - bottom || 1)); if (v <= -20) { const t = (v + 60) / 40; return clamp01(Math.pow(t, 1.6) * 0.45); } if (v <= 0) { const t = (v + 20) / 20; return clamp01(0.45 + t * 0.25); } if (v <= 20) { const t = v / 20; return clamp01(0.70 + t * 0.25); } if (v <= 24) { const t = (v - 20) / 4; return clamp01(0.95 + t * 0.05); } return 1; }; const majors = isDBU ? [24, 20, 10, 0, -10, -20, -30, -40, -50, -60] : [0, -6, -12, -18, -24, -30, -40, -60]; const minors = isDBU ? [] : [-3, -9, -15, -21, -27, -33, -36, -42, -48, -54]; return { id: meterId, kind: 'rms', bottom, top, redStart, warnColor, normalColor, unitLabel: isDBU ? 'dBu' : 'dBFS', majorTicks: majors, minorTicks: minors, formatMajor: (v) => (v > 0 ? `+${v}` : `${v}`), valueToNorm, rmsMode: isDBU ? 'dbu' : 'dbfs', }; } if (meterId === 'lufs') { const bottom = -50; const top = -5; const redStart = Number.isFinite(CONFIG?.LUFS_RED_START) ? CONFIG.LUFS_RED_START : (Number.isFinite(CONFIG?.LUFS_YELLOW_START) ? CONFIG.LUFS_YELLOW_START : -2); const warnColor = CONFIG?.LUFS_COLOR_RED || '#ff3b3b'; const normalColor = CONFIG?.LUFS_COLOR_YELLOW || '#ffe066'; const valueToNorm = (v) => { const clamped = clamp(v, bottom, top); return clamp01((clamped - bottom) / (top - bottom || 1)); }; return { id: meterId, kind: 'lufs', bottom, top, redStart, warnColor, normalColor, unitLabel: 'LUFS', majorTicks: [-50, -40, -30, -23, -18, -10, -5], minorTicks: [-45, -35, -25, -20, -15], formatMajor: (v) => `${v}`, valueToNorm, }; } // Default: VU (Classic-Needles Look) { const bottom = -20; const top = +3; const redStart = Number.isFinite(CONFIG?.VU_RED_START) ? CONFIG.VU_RED_START : 0; const warnColor = CONFIG?.VU_COLOR_WARN || '#ff3b3b'; const normalColor = CONFIG?.VU_COLOR_NORMAL || '#ffe066'; const mapRaw = (dB) => { const db = clamp(dB, bottom, top); const table = VU_DEFLECTION; if (db <= table[0].db) return table[0].frac; if (db >= table[table.length - 1].db) return table[table.length - 1].frac; for (let i = 1; i < table.length; i++) { const prev = table[i - 1]; const curr = table[i]; if (db <= curr.db) { const span = curr.db - prev.db || 1; const t = (db - prev.db) / span; return prev.frac + t * (curr.frac - prev.frac); } } return table[table.length - 1].frac; }; const valueToNorm = (v) => clamp01(mapRaw(v)); return { id: 'vu', kind: 'vu', bottom, top, redStart, warnColor, normalColor, unitLabel: '- dB', majorTicks: [-20, -10, -7, -5, -3, -1, 0, 1, 2, 3], minorTicks: [-15, -12, -9, -8, -6, -4, -2], formatMajor: (db) => { if (db < 0) return String(Math.abs(db)); if (db === 3) return '*'; return String(db); }, valueToNorm, vuPercentMarks: true, }; } } function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) { const fallback = () => scale?.bottom ?? -60; if (meterId === 'lufs') { const v = Number.isFinite(shared?.momentary) ? shared.momentary : fallback(); return { L: v, R: v }; } const hasLR = Number.isFinite(shared?.values?.L) || Number.isFinite(shared?.values?.R); if (!shared || !hasLR) return { L: fallback(), R: fallback() }; let rawL = Number.isFinite(shared.values.L) ? shared.values.L : fallback(); let rawR = Number.isFinite(shared.values.R) ? shared.values.R : fallback(); if (meterId === 'vu') { const effOff = Number.isFinite(CONFIG?.VU_OFFSET_DB) ? CONFIG.VU_OFFSET_DB : 0; const offCorr = effOff - (shared.offset || 0); rawL += offCorr; rawR += offCorr; } else if (meterId === 'tp') { const effOff = Number.isFinite(CONFIG?.TP_OFFSET_DB) ? CONFIG.TP_OFFSET_DB : 0; const offCorr = effOff - (shared.offset || 0); rawL += offCorr; rawR += offCorr; } else if (meterId === 'ppm-ebu') { const effOff = Number.isFinite(CONFIG?.PPM_EBU_OFFSET) ? CONFIG.PPM_EBU_OFFSET : 0; const offCorr = effOff - (shared.offset || 0); rawL += offCorr; rawR += offCorr; } else if (meterId === 'ppm-din') { const baseMode = (CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9; const effOff = baseMode + (Number(CONFIG?.PPM_DIN_TRIM_DB) || 0); const offCorr = effOff - (shared.offset || 0); rawL += offCorr; rawR += offCorr; } else if (meterId === 'rms') { const effOff = Number.isFinite(CONFIG?.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0; const offCorr = effOff - (shared.offset || 0); rawL += offCorr; rawR += offCorr; const mode = String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase(); const isDBU = (mode === 'dbu'); if (isDBU) { const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18; const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : 0; rawL = (rawL - refDbfs) + refDbu; rawR = (rawR - refDbfs) + refDbu; } } return { L: rawL, R: rawR }; } function smoothNeedle(state, target, now) { const prevTs = state.lastTs || now; const dtFull = Math.max(1e-3, Math.min(0.20, (now - prevTs) / 1000)); const maxStep = 1 / 120; const steps = Math.max(1, Math.ceil(dtFull / maxStep)); const dt = dtFull / steps; if (!state.vel) state.vel = { L: 0, R: 0 }; if (!state.goal) state.goal = { L: null, R: null }; const step = (ch) => { const goal = target[ch]; if (!Number.isFinite(goal)) return; if (!Number.isFinite(state.smooth[ch])) { state.smooth[ch] = goal; state.vel[ch] = 0; state.goal[ch] = goal; return; } let x = state.smooth[ch]; let v = Number.isFinite(state.vel[ch]) ? state.vel[ch] : 0; let g = Number.isFinite(state.goal[ch]) ? state.goal[ch] : goal; for (let i = 0; i < steps; i++) { const alphaGoal = 1 - Math.exp(-dt / NEEDLE_GOAL_TAU_S); g = g + alphaGoal * (goal - g); const rising = g > x; const hz = rising ? NEEDLE_SPRING_HZ_UP : NEEDLE_SPRING_HZ_DOWN; const omega = 2 * Math.PI * hz; const zeta = NEEDLE_SPRING_DAMPING; // Gedämpfter Feder-Masse-Dämpfer (semi-implicit Euler): x'' + 2ζω x' + ω²(x-goal)=0 const a = (omega * omega) * (g - x) - (2 * zeta * omega) * v; v = v + a * dt; x = x + v * dt; } state.goal[ch] = g; state.vel[ch] = v; state.smooth[ch] = x; }; step('L'); step('R'); state.lastTs = now; } function drawClassicMeter(g, rect, scale, metrics) { const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC; const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC; drawScale(g, cx, cy, radius, scale, metrics); } function drawScale(g, cx, cy, radius, scale, metrics) { const mapValueToAngle = (v) => lerp(ANGLE_START, ANGLE_END, scale.valueToNorm(v)); const redStart = scale.redStart; const warnCol = scale.warnColor || '#ff3b3b'; const majors = Array.isArray(scale.majorTicks) ? scale.majorTicks : []; const minors = Array.isArray(scale.minorTicks) ? scale.minorTicks : []; // Skalenbogen g.save(); g.lineWidth = 3; g.strokeStyle = SCALE_ARC_STROKE; g.beginPath(); g.arc(cx, cy, radius, ANGLE_START, ANGLE_END); g.stroke(); // Major ticks + Beschriftung g.font = `bold ${metrics?.majorFont ?? 13}px ui-monospace, monospace`; g.textAlign = 'center'; g.textBaseline = 'middle'; for (const v of majors) { const ang = mapValueToAngle(v); const inRed = Number.isFinite(redStart) && v >= redStart; const tickCol = inRed ? warnCol : '#cfe9ff'; g.lineWidth = inRed ? 2.5 : 2; g.strokeStyle = tickCol; g.fillStyle = tickCol; drawTick(g, cx, cy, radius, ang, inRed ? (metrics?.majorTickWarn ?? 16) : (metrics?.majorTickNormal ?? 14)); const labelOffset = metrics?.majorLabelOffset ?? 20; const tx = cx + Math.cos(ang) * (radius + labelOffset); const ty = cy + Math.sin(ang) * (radius + labelOffset); const label = scale.formatMajor ? scale.formatMajor(v) : String(v); g.fillText(label, tx, ty); } // Minor dots const dotRad = Math.max(0, radius - 16); const dotR = 2.2; for (const tick of minors) { const v = (typeof tick === 'number') ? tick : tick?.value; if (!Number.isFinite(v)) continue; const isWarn = (typeof tick === 'object' && tick?.color === 'warn'); g.fillStyle = isWarn ? warnCol : 'rgba(207,233,255,0.65)'; const ang = mapValueToAngle(v); const x = cx + Math.cos(ang) * dotRad; const y = cy + Math.sin(ang) * dotRad; g.beginPath(); g.arc(x, y, dotR, 0, Math.PI * 2); g.fill(); } // Roter Bereich if (Number.isFinite(redStart)) { const redAng = mapValueToAngle(redStart); g.strokeStyle = warnCol; g.lineWidth = 4; g.beginPath(); g.arc(cx, cy, radius, redAng, ANGLE_END); g.stroke(); } // Unit label oben rechts if (scale.unitLabel) { g.fillStyle = '#cfe9ff'; g.font = `bold ${metrics?.unitFont ?? 12}px ui-monospace, monospace`; g.textBaseline = 'alphabetic'; const dbLabelAng = ANGLE_END - (metrics?.unitAngleOffset ?? 0.16); const unitOffset = metrics?.unitOffset ?? 34; g.fillText(scale.unitLabel, cx + Math.cos(dbLabelAng) * (radius + unitOffset), cy + Math.sin(dbLabelAng) * (radius + unitOffset)); } // VU-Percent-Scale (wie Foto) if (scale.vuPercentMarks) { g.font = `bold ${metrics?.pctFont ?? 16}px ui-monospace, monospace`; g.fillStyle = 'rgba(207,233,255,0.8)'; g.textBaseline = 'middle'; const pctR = Math.max(0, radius - (metrics?.pctInset ?? 48)); const posAtValue = (v) => { const a = mapValueToAngle(v); return { x: cx + Math.cos(a) * pctR, y: cy + Math.sin(a) * pctR }; }; const p10 = posAtValue(-20); // 10% ≙ -20 dB const p50 = posAtValue(-6); // 50% ≙ -6 dB const p100 = posAtValue(0); // 100% ≙ 0 dB g.fillText('10', p10.x, p10.y); g.fillText('50', p50.x, p50.y); g.fillText('100', p100.x, p100.y + (metrics?.pct100YOffset ?? 10)); g.font = `bold ${metrics?.pctSignFont ?? 18}px ui-monospace, monospace`; g.fillText('%', p100.x + (metrics?.pctSignOffsetX ?? 18), p100.y + (metrics?.pctSignOffsetY ?? 28)); } // Glaslinie g.strokeStyle = 'rgba(255,255,255,0.07)'; g.lineWidth = 8; g.beginPath(); g.arc(cx, cy, radius - 12, ANGLE_START + 0.04, ANGLE_END - 0.04); g.stroke(); g.restore(); } function drawNeedle(g, rect, norm, color) { const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC; const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC; const needleLen = radius * 0.9; if (!Number.isFinite(norm)) return; const t = clamp01(norm); const ang = lerp(ANGLE_START, ANGLE_END, t); const tipX = cx + Math.cos(ang) * needleLen; const tipY = cy + Math.sin(ang) * needleLen; const baseX = cx + Math.cos(ang + Math.PI) * (needleLen * 0.08); const baseY = cy + Math.sin(ang + Math.PI) * (needleLen * 0.08); g.save(); g.strokeStyle = color; g.lineWidth = 3; g.beginPath(); g.moveTo(baseX, baseY); g.lineTo(tipX, tipY); g.stroke(); g.restore(); } function drawNeedleCap(g, rect) { const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC; const capRadius = NEEDLE_CAP_RADIUS; g.save(); const grad = g.createRadialGradient(cx, cy, 2, cx, cy, capRadius * 1.5); grad.addColorStop(0, '#0b111c'); grad.addColorStop(1, 'rgba(0,231,255,0.35)'); g.fillStyle = grad; g.strokeStyle = '#00e7ff'; g.lineWidth = 2; g.beginPath(); g.arc(cx, cy, capRadius, 0, Math.PI * 2); g.fill(); g.stroke(); g.restore(); } function drawTick(g, cx, cy, radius, ang, len) { const x1 = cx + Math.cos(ang) * (radius - len); const y1 = cy + Math.sin(ang) * (radius - len); const x2 = cx + Math.cos(ang) * (radius + 2); const y2 = cy + Math.sin(ang) * (radius + 2); g.beginPath(); g.moveTo(x1, y1); g.lineTo(x2, y2); g.stroke(); } function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); } function clamp01(v) { return Math.min(1, Math.max(0, v)); } function lerp(a, b, t) { return a + (b - a) * t; } function roundLerp(a, b, t) { return Math.round(lerp(a, b, t)); } function degToRad(d) { return (d * Math.PI) / 180; }