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

157 lines
5.6 KiB
JavaScript

import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
// meters/lufs.js — vereinfachtes LUFS-Display (Integrated, Momentary, Short-term) + LRA & TP
// Hinweis: Der Audio-Worklet liefert aktuell keine echten LUFS-Filterausgänge.
// Wir nähern LUFS über dBFS (RMS) an und integrieren per EMA:
// - Momentary ≈ 400 ms
// - Short-term ≈ 3 s
// - Integrated = langsame EMA (ohne Gate)
// TruePeak nehmen wir aus packet.tpL/tpR (dBFS), LRA aus Short-term vs. Momentary.
import { drawCachedStaticLayer } from './static_layer.js';
import { LABEL_COLOR, OK_COLOR } from '../core/theme.js';
export const id = 'lufs';
export function initShared(CONFIG) {
const now = performance.now();
return {
integ: -23.0,
momentary: -23.0,
shortTerm: -23.0,
lra: 0.0,
truePeak: -60.0,
tpInstant: -60.0,
_tpHoldState: createPeakHoldState(-60, now, CONFIG?.TP_HOLD_MS ?? CONFIG?.VU_HOLD_MS ?? 1000),
_lastTs: now,
};
}
export function update(packet, shared) {
// Prefer true LUFS from packet (computed in audio.js via K-weighted analysers)
if (Number.isFinite(packet.lufsM)) shared.momentary = packet.lufsM;
if (Number.isFinite(packet.lufsS)) shared.shortTerm = packet.lufsS;
if (Number.isFinite(packet.lufsI)) shared.integ = packet.lufsI;
if (Number.isFinite(packet.lra)) shared.lra = packet.lra;
// True Peak hold from packet
if (Number.isFinite(packet.tpL) || Number.isFinite(packet.tpR)) {
const tpL = Number.isFinite(packet.tpL) ? packet.tpL : -60;
const tpR = Number.isFinite(packet.tpR) ? packet.tpR : -60;
shared.tpInstant = Math.max(tpL, tpR);
}
}
export function draw(g, rect, CONFIG, shared) {
// Skala: LUFS (negativ). Wir verwenden Bereich [-50 .. -5]
const LUFS_TOP = -5, LUFS_BOTTOM = -50;
const mapY = (lufs) => {
const v = Math.max(LUFS_BOTTOM, Math.min(LUFS_TOP, lufs));
const t = (v - LUFS_BOTTOM) / (LUFS_TOP - LUFS_BOTTOM);
return rect.y + (1 - t) * rect.h;
};
// 3 Spalten (Momentary, Short-term, Integrated)
const innerPad = 8, gap = 10;
const colW = Math.floor((rect.w - innerPad * 2 - gap * 2) / 3);
const xM = rect.x + innerPad;
const xS = xM + colW + gap;
const xI = xS + colW + gap;
// Bars zeichnen
drawLufsBar(g, { x: xM, y: rect.y, w: colW, h: rect.h }, shared.momentary, 'M', CONFIG, mapY);
drawLufsBar(g, { x: xS, y: rect.y, w: colW, h: rect.h }, shared.shortTerm, 'S', CONFIG, mapY);
drawLufsBar(g, { x: xI, y: rect.y, w: colW, h: rect.h }, shared.integ, 'I', CONFIG, mapY);
// True Peak hold smoothing
const now = performance.now();
const holdMs = Number.isFinite(CONFIG.TP_HOLD_MS) ? CONFIG.TP_HOLD_MS : (CONFIG.VU_HOLD_MS ?? 1000);
const decay = Number.isFinite(CONFIG.TP_DECAY_DB_PER_S) ? CONFIG.TP_DECAY_DB_PER_S : 20;
if (!shared._tpHoldState) {
shared._tpHoldState = createPeakHoldState(shared.truePeak ?? -60, now, holdMs);
}
const tpInstant = Number.isFinite(shared.tpInstant) ? shared.tpInstant : -60;
const tpValue = stepPeakHold(tpInstant, shared._tpHoldState, now, {
holdMs,
decayDbPerS: decay,
floor: -60,
riseThreshold: 0.1,
});
shared.truePeak = tpValue;
// Zusatz-Infos unterhalb
// g.save();
// g.fillStyle = '#8fd3d4';
// g.textAlign = 'left';
// g.fillText(`LRA: ${shared.lra.toFixed(1)} LU`, rect.x, rect.y + rect.h + 20);
// g.fillText(`TP: ${shared.truePeak.toFixed(1)} dBFS`, rect.x, rect.y + rect.h + 38);
// g.restore();
drawLufsStaticOverlay(g, shared, rect, CONFIG, { xM, xS, xI, colW }, mapY);
}
function drawLufsStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
const { xM, xS, xI, colW } = geom;
const topPad = 10;
const layerX = rect.x;
const layerY = rect.y - topPad;
const layerW = rect.w;
const layerH = rect.h + topPad;
const scaleCol = CONFIG?.LUFS_SCALE_LABEL_COLOR || LABEL_COLOR;
const key = [
'scale-font-header-v1-top-pad',
Math.round(rect.w),
Math.round(rect.h),
topPad,
scaleCol,
].join('|');
drawCachedStaticLayer(g, shared, 'lufs-static', key, layerX, layerY, layerW, layerH, (cg) => {
cg.save();
cg.translate(-layerX, -layerY);
const drawRefs = (xBase) => {
const col = { x: xBase, y: rect.y, w: colW, h: rect.h };
for (const v of [-23, -18, -10, -5]) {
const y = mapY(v);
const isMajor = (v === -23 || v === -18 || v === -10);
cg.save();
cg.strokeStyle = scaleCol;
cg.globalAlpha = isMajor ? 1 : 0.28;
cg.setLineDash(isMajor ? [] : [2,2]);
cg.beginPath(); cg.moveTo(col.x, y); cg.lineTo(col.x + col.w, y); cg.stroke();
if (isMajor) {
cg.globalAlpha = 1;
cg.fillStyle = scaleCol; cg.textAlign = 'center';
cg.fillText(`${v}`, col.x + col.w / 2, y - 4);
}
cg.restore();
}
};
drawRefs(xM); drawRefs(xS); drawRefs(xI);
cg.restore();
});
}
function drawLufsBar(g, rect, value, label, CONFIG, mapY) {
const yVal = mapY(value);
const yFloor = mapY(-50);
const color = getLufsBarColor(label, CONFIG);
g.save();
g.fillStyle = color;
g.fillRect(rect.x + 1, yVal, Math.max(2, rect.w - 2), Math.max(0, yFloor - yVal));
g.fillStyle = '#ffffff';
g.textAlign = 'center';
g.fillText(label, rect.x + rect.w / 2, rect.y - 10);
g.fillText(value.toFixed(1), rect.x + rect.w / 2, rect.y + rect.h + 18);
g.restore();
}
function getLufsBarColor(label, CONFIG = {}) {
const fallback = CONFIG.LUFS_COLOR_GREEN || OK_COLOR;
if (label === 'I') return CONFIG.LUFS_COLOR_I || CONFIG.LUFS_COLOR_I_GREEN || fallback;
if (label === 'M') return CONFIG.LUFS_COLOR_M || CONFIG.LUFS_COLOR_M_GREEN || fallback;
if (label === 'S') return CONFIG.LUFS_COLOR_S || CONFIG.LUFS_COLOR_S_GREEN || fallback;
return fallback;
}