Initial Phoenix analyzer

This commit is contained in:
Mikei386
2026-06-02 20:56:19 +02:00
commit e499bac928
62 changed files with 28893 additions and 0 deletions
+503
View File
@@ -0,0 +1,503 @@
// 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;
}