// meters/rms.js — True RMS (dBu/dBFS) L/R mit dualem Modus, Kalibrierung & Warnschwelle // Erwartet update(packet) mit packet.rmsL / packet.rmsR (in dBFS). // Offset via CONFIG.RMS_OFFSET_DB (wirkt vor der dBu-Umrechnung). // // Defaults jetzt DIGITAL: // - CONFIG.RMS_MODE bleibt auf 'dbfs', damit das Meter direkt dBFS ausgibt. // - CONFIG.RMS_REF_DBFS_FOR_REF_DBU: Referenz in dBFS RMS (default: -18 für 0 dBu) // - CONFIG.RMS_REF_DBU: Referenz in dBu RMS (default: 0) // -> Umrechnung: dBu = (dBFS_RMS - REF_DBFS) + REF_DBU, falls jemals benötigt. // - Titel passt sich an: "RMS (dBu)" oder "RMS (dBFS RMS)" je nach Modus. // - Skalenobergrenze: dBu: +24 (typisch Pro-Audio Headroom) / dBFS: +5 // - Standard-Warnschwelle: dBu: +20 / dBFS: 0 (überschreibbar via CONFIG.RMS_RED_START) import { drawCachedStaticLayer } from './static_layer.js'; import { METER_HEADER_FONT } from './scale_helpers.js'; import { HEADER_BG, LABEL_COLOR, OK_COLOR, WARN_COLOR } from '../core/theme.js'; export const id = 'rms'; export function initShared(CONFIG = {}) { const initVal = -60; return { values: { L: initVal, R: initVal }, // unveränderte dBFS-RMS-Werte aus dem Backend offset: 0, lastValidL: initVal, lastValidR: initVal, }; } export function update(packet, shared) { const floor = -60; if (!Number.isFinite(shared.lastValidL)) shared.lastValidL = floor; if (!Number.isFinite(shared.lastValidR)) shared.lastValidR = floor; if (Number.isFinite(packet?.rmsL)) { const vL = packet.rmsL; shared.values.L = vL; shared.lastValidL = vL; } else { shared.values.L = shared.lastValidL; } if (Number.isFinite(packet?.rmsR)) { const vR = packet.rmsR; shared.values.R = vR; shared.lastValidR = vR; } else { shared.values.R = shared.lastValidR; } } export function draw(g, rect, CONFIG = {}, shared) { const MODE = (CONFIG.RMS_MODE ?? 'dbfs').toLowerCase(); // Default jetzt 'dbfs' // Referenz: -18 dBFS RMS ≙ +4 dBu (übliches Studio-Line-Up). Anpassbar. const REF_DBFS = Number.isFinite(CONFIG.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18; const REF_DBU = Number.isFinite(CONFIG.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4; // Anzeige-Umschaltung & Skala const isDBU = (MODE === 'dbu'); const offset = Number.isFinite(CONFIG.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0; const LOG_MIN = -60; const LOG_TOP = isDBU ? +24 : 0; const RED_START = Number.isFinite(CONFIG.RMS_RED_START) ? CONFIG.RMS_RED_START : (isDBU ? +20 : 0); // Umrechnung auf Anzeigeeinheit const toDisplay = (dbfsRms) => { if (!Number.isFinite(dbfsRms)) return -Infinity; return isDBU ? (dbfsRms - REF_DBFS + REF_DBU) : dbfsRms; }; // Skalen-Mapping: unterhalb −20 stärker komprimiert, darüber nahezu linear. function scaleNorm(db) { if (!Number.isFinite(db)) return 0; if (db <= LOG_MIN) return 0; if (isDBU) { // dBu: −60…−20 (45%), −20…0 (+25%), 0…+20 (+25%), +20…+24 (+5%) if (db <= -20) { const t = (db + 60) / 40; return Math.pow(t, 1.6) * 0.45; } // → 0…0.45 if (db <= 0) { const t = (db + 20) / 20; return 0.45 + t * 0.25; } // → 0.70 if (db <= +20) { const t = (db ) / 20; return 0.70 + t * 0.25; } // → 0.95 if (db <= +24) { const t = (db - 20) / 4; return 0.95 + t * 0.05; } // → 1.00 return 1; } else { // dBFS: linear 0 … −60 if (db >= LOG_TOP) return 1; return (db - LOG_MIN) / (LOG_TOP - LOG_MIN); } } const mapY = (db) => rect.y + (1 - Math.max(0, Math.min(1, scaleNorm(db)))) * rect.h; // Layout const innerPad = 8, scaleW = 26, gap = 8; const avail = rect.w - innerPad * 2 - scaleW - gap * 2; let barW = Math.max(8, Math.floor(avail / 2)); barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55)); const used = 2 * barW + scaleW + 2 * gap; const extra = Math.max(0, (rect.w - innerPad * 2) - used); const leftX = rect.x + innerPad + extra / 2; const scaleX = leftX + barW + gap; const rightX = scaleX + scaleW + gap; const centerX = scaleX + scaleW / 2; const centerLeft = leftX + barW / 2; const centerRight = rightX + barW / 2; // Header-Hintergrund säubern g.save(); g.fillStyle = HEADER_BG; g.fillRect(rect.x, rect.y - 24, rect.w, 24); g.restore(); // Werte in Anzeigeeinheit clampen const rawDispL = toDisplay(shared.values.L + offset); const rawDispR = toDisplay(shared.values.R + offset); const vL = clamp(rawDispL, LOG_MIN, LOG_TOP); const vR = clamp(rawDispR, LOG_MIN, LOG_TOP); g.save(); const headerWarn = CONFIG.RMS_HEADER_SHOW_VALUE && (rawDispL > RED_START || rawDispR > RED_START); const prevFont = g.font; g.font = 'bold 12px ui-monospace, monospace'; g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.textAlign = 'center'; if (CONFIG.RMS_HEADER_SHOW_VALUE) { const yText = rect.y - 12; const fmt = (v) => { const sign = v >= 0 ? '+' : '-'; return `${sign}${Math.abs(v).toFixed(1)}`; }; g.textAlign = 'center'; const isRedL = rawDispL > RED_START; const isRedR = rawDispR > RED_START; g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.fillText(fmt(rawDispL), centerLeft, yText); g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR; g.fillText('|', centerX, yText); g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.fillText(fmt(rawDispR), centerRight, yText); } else { const title = isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)'; g.fillText(title, centerX, rect.y - 12); } g.font = prevFont; g.restore(); const yFloor = mapY(LOG_MIN); const redStart = clamp(RED_START, LOG_MIN, LOG_TOP); const yRed = mapY(redStart); const innerW = Math.max(2, barW - 2); const colNorm = CONFIG.RMS_COLOR_NORMAL || OK_COLOR; const colWarn = CONFIG.RMS_COLOR_WARN || WARN_COLOR; const drawBar = (x0, valDisp) => { const yVal = mapY(valDisp); if (valDisp > RED_START) { if (CONFIG.RMS_RED_BAR_ONLY) { // normaler Teil (bis Warnschwelle) const yNormTop = Math.min(yRed, yFloor); g.fillStyle = colNorm; g.fillRect(x0 + 1, yNormTop, innerW, Math.max(0, yFloor - yNormTop)); // warnender Teil const yWarnTop = Math.min(yVal, yRed); g.fillStyle = colWarn; g.fillRect(x0 + 1, yWarnTop, innerW, Math.max(0, yRed - yWarnTop)); } else { g.fillStyle = colWarn; g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal)); } } else { g.fillStyle = colNorm; g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal)); } // Glanzkante g.globalAlpha = .12; g.fillStyle = '#fff'; g.fillRect(x0 + 1, yVal, innerW, 2); g.globalAlpha = 1; }; drawBar(leftX, vL); drawBar(rightX, vR); const majors = isDBU ? [24, 20, 10, 0, -10, -20, -30, -40, -50, -60] : [0, -6, -12, -18, -24, -30, -40, -60]; const minorValues = isDBU ? null : buildDbfsMinorTicks(); const alignmentHighlight = (!isDBU) ? getRmsAlignmentHighlight(CONFIG) : null; drawRmsStaticOverlay(g, shared, rect, CONFIG, { leftX, rightX, barW, innerW, scaleX, scaleW, centerX, yRed, mapY, LOG_TOP, LOG_MIN, isDBU, majors, minorValues, alignmentHighlight, }); } function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) { const { leftX, rightX, barW, innerW, scaleX, scaleW, centerX, yRed, mapY, LOG_TOP, LOG_MIN, isDBU, majors, minorValues, alignmentHighlight, } = geom; const topPad = 10; const layerX = rect.x; const layerY = rect.y - topPad; const layerW = rect.w; const layerH = rect.h + 24 + topPad; const key = [ 'scale-font-header-v1-top-pad', Math.round(rect.w), Math.round(rect.h), topPad, CONFIG.METER_BAR_THIN || 0.55, CONFIG.RMS_MODE || 'dbfs', CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1, CONFIG.RMS_RED_START ?? '', METER_HEADER_FONT, ].join('|'); drawCachedStaticLayer(g, shared, 'rms-static', key, layerX, layerY, layerW, layerH, (cg) => { cg.save(); cg.translate(-layerX, -layerY); drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, LOG_TOP, LOG_MIN, majors, minorValues, alignmentHighlight); const yTop = mapY(LOG_TOP); drawRedStripeLR(cg, leftX, barW, rightX, centerX, mapY, yRed, yTop); const scaleRect = { x: scaleX, y: rect.y, w: scaleW, h: rect.h }; drawScale(cg, scaleRect, centerX, mapY, { isDBU, majors, minors: minorValues, hairStep: isDBU ? null : 1, topValue: LOG_TOP, bottomValue: LOG_MIN, highlightTick: null, }); const unitLabel = isDBU ? 'dBu (RMS)' : 'dBFS (RMS)'; cg.fillStyle = LABEL_COLOR; cg.textAlign = 'center'; const baseY = rect.y + rect.h + 16; cg.fillText('L', leftX + barW / 2, baseY); cg.fillText('R', rightX + barW / 2, baseY); const prevFontLabels = cg.font; cg.fillStyle = '#ffffff'; cg.font = 'bold 8.4px ui-monospace, monospace'; cg.fillText(unitLabel, centerX, baseY); cg.font = prevFontLabels; cg.restore(); }); } // ===== Helpers ===== function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function drawRedStripeLR(g, leftX, barW, rightX, centerX, mapY, yWarn, yTop) { const innerRight = leftX + barW; const gapL = Math.abs(centerX - innerRight); const insetL = Math.max(1, Math.floor(gapL * 0.35)); const xL = Math.round(innerRight + insetL) + 0.5; const innerLeft = rightX; const gapR = Math.abs(centerX - innerLeft); const insetR = Math.max(1, Math.floor(gapR * 0.35)); const xR = Math.round(innerLeft - insetR) + 0.5; const y1 = Math.min(yWarn, yTop), y2 = Math.max(yWarn, yTop); g.save(); g.strokeStyle = WARN_COLOR; g.lineWidth = 1; g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke(); g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke(); g.restore(); } function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, TOP, BOTTOM, majors, minorValues = null, highlightTick = null) { g.save(); const MAJOR_INSET = 2; const MINOR_FRAC = 0.45; const cxL = leftX + 1 + widthPx / 2; const cxR = rightX + 1 + widthPx / 2; const approx = (a, b) => Math.abs(a - b) < 0.01; const drawMajor = (value) => { const majorW = Math.max(1, widthPx - 4 * MAJOR_INSET); const yPix = Math.round(mapY(value)) + 0.5; const isHighlight = highlightTick && approx(value, highlightTick.value); const color = isHighlight ? highlightTick.color : 'rgb(0,0,255)'; const lineWidth = isHighlight ? 1.8 : 1; const x1L = Math.round(cxL - majorW / 2) + 0.5; const x2L = Math.round(cxL + majorW / 2) + 0.5; g.strokeStyle = color; g.lineWidth = lineWidth; g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke(); const x1R = Math.round(cxR - majorW / 2) + 0.5; const x2R = Math.round(cxR + majorW / 2) + 0.5; g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke(); }; const drawMinor = (value) => { const minorW = Math.max(1, Math.floor(widthPx * MINOR_FRAC)); const yPix = Math.round(mapY(value)) + 0.5; const isHighlight = highlightTick && approx(value, highlightTick.value); const color = isHighlight ? highlightTick.color : 'rgba(0,0,255,0.55)'; const lineWidth = isHighlight ? 1.4 : 1; const x1L = Math.round(cxL - minorW / 2) + 0.5; const x2L = Math.round(cxL + minorW / 2) + 0.5; g.strokeStyle = color; g.lineWidth = lineWidth; g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke(); const x1R = Math.round(cxR - minorW / 2) + 0.5; const x2R = Math.round(cxR + minorW / 2) + 0.5; g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke(); }; const top = TOP, bot = BOTTOM; const hi = Math.max(top, bot), lo = Math.min(top, bot); const inRange = (v) => v <= hi && v >= lo; if (Array.isArray(majors) && majors.length > 0) { const sorted = majors.slice().sort((a, b) => b - a); // desc for (let i = 0; i < sorted.length; i++) { const vMaj = sorted[i]; if (inRange(vMaj)) { drawMajor(vMaj); } if (minorValues == null && i < sorted.length - 1) { const vNext = sorted[i + 1]; const mid = (vMaj + vNext) / 2; if (inRange(mid)) { drawMinor(mid); } } } } if (Array.isArray(minorValues) && minorValues.length > 0) { const sortedMin = minorValues.slice().sort((a, b) => b - a); for (const v of sortedMin) { if (!inRange(v)) continue; drawMinor(v); } } g.restore(); } // Mittenskala & Raster function drawScale(g, colRect, centerX, mapY, { isDBU, majors = [], minors = [], hairStep = null, topValue = 0, bottomValue = -60, highlightTick = null, }) { if (isDBU) { g.save(); const prevFont = g.font; g.font = METER_HEADER_FONT; g.fillStyle = LABEL_COLOR; g.textAlign = 'center'; g.textBaseline = 'alphabetic'; const labels = [24, 20, 10, 4, 0, -10, -20, -30, -40, -50, -60]; for (const v of labels) { const y = mapY(v); if (y < colRect.y || y > colRect.y + colRect.h) continue; const lab = v > 0 ? ('+' + v) : (v === 0 ? '0' : String(v)); g.fillText(lab, centerX, y + 5); } g.font = prevFont; g.restore(); return; } const hi = Math.max(topValue, bottomValue); const lo = Math.min(topValue, bottomValue); const inRange = (v) => v <= hi && v >= lo; const approx = (a, b) => Math.abs(a - b) < 0.01; const isHighlightValue = (value) => !!highlightTick && approx(value, highlightTick.value); if (hairStep && hairStep > 0) { g.save(); g.beginPath(); g.rect(colRect.x, colRect.y, colRect.w, colRect.h); g.clip(); for (let v = topValue; v >= bottomValue; v -= hairStep) { if (!inRange(v)) continue; const y = Math.round(mapY(v)) + 0.5; g.strokeStyle = isHighlightValue(v) ? (highlightTick?.color || '#ffffff') : 'rgba(255,255,255,0.08)'; g.lineWidth = isHighlightValue(v) ? 1.2 : 0.6; g.beginPath(); g.moveTo(colRect.x, y); g.lineTo(colRect.x + colRect.w, y); g.stroke(); } g.restore(); } g.save(); const prevFont = g.font; g.font = METER_HEADER_FONT; g.textAlign = 'center'; g.textBaseline = 'alphabetic'; for (const v of majors || []) { if (!inRange(v)) continue; const y = mapY(v); g.fillStyle = isHighlightValue(v) ? (highlightTick?.color || LABEL_COLOR) : LABEL_COLOR; g.fillText(String(v), centerX, y + 5); } g.font = prevFont; g.restore(); } function buildDbfsMinorTicks() { return [-3, -9, -15, -21, -27, -33, -36, -42, -48, -54]; } function getRmsAlignmentHighlight(CONFIG) { if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null; const isArd = CONFIG.PPM_DIN_MODE === 'al_minus9'; return { value: isArd ? -15 : -18, color: WARN_COLOR, }; }