Files
Phoenix/www/meters/rms.js
T

449 lines
15 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';
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,
};
}