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; }