Files
Phoenix/www/meters/rms.js
T
2026-06-02 20:56:19 +02:00

504 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
const RMS_FRAME_MS_NOMINAL = 16; // ~60 Hz UI-Refresh
const RMS_FRAME_MS_MAX = 40; // Obergrenze für dt in der Glättung
const RMS_FRAME_MS_SKIP = 250; // Heuristischer Schutz: riesige Gaps komplett skippen
export const id = 'rms';
export function initShared(CONFIG = {}) {
const offset = CONFIG.RMS_OFFSET_DB || 0;
const initVal = -60 + offset;
return {
values: { L: initVal, R: initVal }, // intern immer dBFS RMS aus der Messkette
offset,
lastValidL: initVal,
lastValidR: initVal,
_smooth: null,
};
}
export function update(packet, shared) {
const offset = shared.offset || 0;
const floor = -60 + offset;
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 + offset;
shared.values.L = vL;
shared.lastValidL = vL;
} else {
shared.values.L = shared.lastValidL;
}
if (Number.isFinite(packet?.rmsR)) {
const vR = packet.rmsR + offset;
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 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();
// IEC-ähnliche Zeitkonstanten (Impulse/Fast/Slow) für die Anzeige
const tauMs = resolveRmsTau(CONFIG);
if (tauMs > 0) {
const now = performance.now();
if (!shared._smooth) {
shared._smooth = {
L: shared.values.L,
R: shared.values.R,
lastTs: now - RMS_FRAME_MS_NOMINAL,
};
}
const lastTs = shared._smooth.lastTs ?? (now - RMS_FRAME_MS_NOMINAL);
const dtRawMs = Math.max(0, now - lastTs);
shared._smooth.lastTs = now;
if (dtRawMs <= RMS_FRAME_MS_SKIP) {
const dtUsedMs = Math.min(dtRawMs, RMS_FRAME_MS_MAX); // clamp dt to ignore occasional large gaps
const alpha = 1 - Math.exp(-dtUsedMs / tauMs);
shared._smooth.L += alpha * (shared.values.L - shared._smooth.L);
shared._smooth.R += alpha * (shared.values.R - shared._smooth.R);
}
} else {
shared._smooth = null;
}
// Werte in Anzeigeeinheit clampen
const rawDispL = toDisplay(shared._smooth?.L ?? shared.values.L);
const rawDispR = toDisplay(shared._smooth?.R ?? shared.values.R);
const smooth = smoothHeader(shared, rawDispL, rawDispR);
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(smooth.L), 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(smooth.R), 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 = '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 resolveRmsTau(CONFIG = {}) {
const mode = String(CONFIG.RMS_TC_MODE || 'fast').toLowerCase();
const custom = Number(CONFIG.RMS_TC_MS);
if (Number.isFinite(custom) && custom > 0) return custom;
switch (mode) {
case 'impulse': return 35;
case 'slow': return 1000;
case 'none': return 0;
case 'fast':
default: return 125;
}
}
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,
};
}
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
if (!shared._header) {
shared._header = { L: rawL, R: rawR };
return shared._header;
}
shared._header.L += alpha * (rawL - shared._header.L);
shared._header.R += alpha * (rawR - shared._header.R);
return shared._header;
}