import { createPeakHoldState, stepPeakHold } from '../core/utils.js'; import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js'; import { drawCachedStaticLayer } from './static_layer.js'; import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js'; // meters/ppm_ebu.js — PPM (EBU) mit linearer Skala // - Messbereich: -12 … +12 dB // - Rote Zone ab +9 dB // - Ticks: große Marken bei -12/-8/-4/0/+4/+8/+12, kleine u. a. bei -10/-6/…/+10 // - Labels: nur für die großen Ticks; bei 0 statt "0" → "TEST" export const id = 'ppm-ebu'; const DEFAULT_ATTACK_MS = 10; // Type IIb typisch ~10 ms const DEFAULT_DECAY_DB_PER_S = 24 / 2.8; const DEFAULT_HOLD_MS = 750; const EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12]; const EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10]; export function initShared(CONFIG = {}) { const now = performance.now(); // Use EBU-specific configuration values. PPM_EBU_BOTTOM defines the // bottom of the EBU scale (default -12 dB) and PPM_EBU_HOLD_MS defines // the peak-hold duration. This ensures the EBU meter aligns with // the Type IIb specification (0 dB at AL, +9 dB red start). const bottom = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -12; const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS) ? CONFIG.PPM_EBU_HOLD_MS : DEFAULT_HOLD_MS; const decayCfg = Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S; return { values: { L: bottom, R: bottom }, hold: { L: bottom, R: bottom }, _env: { L_dbfs: -90, R_dbfs: -90, lastTs: now, }, _ballistics: { // The EBU (Type IIb) PPM uses a ~10 ms attack and a decay of 24 dB // in 2.8 s (≈8.57 dB/s). attackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : DEFAULT_ATTACK_MS, decayDbPerS: decayCfg, }, _holdState: { L: createPeakHoldState(bottom, now, holdMs), R: createPeakHoldState(bottom, now, holdMs), }, _holdCfg: { holdMs, decayDbPerS: Number.isFinite(CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S : decayCfg, }, // Use the EBU-specific offset so that 0 dBu (alignment level) reads 0 dB // on the EBU meter. This avoids the global PPM_OFFSET interfering. offset: Number(CONFIG.PPM_EBU_OFFSET) || 0, // Referenz: 0 dB Anzeige entspricht typ. ~-15 dBFS Peak refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15, }; } export function update(packet, shared) { const inL = Number.isFinite(packet?.ppmEbuL) ? packet.ppmEbuL : (Number.isFinite(packet?.ppmL) ? packet.ppmL : -90); const inR = Number.isFinite(packet?.ppmEbuR) ? packet.ppmEbuR : (Number.isFinite(packet?.ppmR) ? packet.ppmR : -90); const base = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15; const off = shared.offset || 0; // Ballistik liegt im Worklet; hier nur Offset anwenden, um Timer-Jitter im UI zu vermeiden. shared._env.L_dbfs = inL; shared._env.R_dbfs = inR; shared._env.lastTs = performance.now(); shared.values.L = (inL - base) + off; shared.values.R = (inR - base) + off; } export function draw(g, rect, CONFIG = {}, shared) { // Effective user-correction offset for EBU: use PPM_EBU_OFFSET instead of global PPM_OFFSET. const __effOff = Number(CONFIG.PPM_EBU_OFFSET) || 0; const __offCorr = __effOff - (shared.offset || 0); const PPM_TOP = Number.isFinite(CONFIG.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +12; const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -12; const RED_START = Number.isFinite(CONFIG.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9; const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false; const colNorm = CONFIG.PPM_EBU_COLOR_NORMAL || MID_COLOR; const colWarn = CONFIG.PPM_EBU_COLOR_WARN || WARN_COLOR; // LINEARE Skalenabbildung const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const mapNorm = (db) => { const d = clamp(db, PPM_BOTTOM, PPM_TOP); return (d - PPM_BOTTOM) / (PPM_TOP - PPM_BOTTOM); // 0..1 }; const mapY = (db) => rect.y + (1 - mapNorm(db)) * rect.h; // Layout const innerPad = 8; const scaleW = 28; const gap = 8; const avail = rect.w - innerPad * 2 - scaleW - gap * 2; let barW = Math.max(10, 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; // Werte + Clamping const rawL = shared.values.L + __offCorr; const rawR = shared.values.R + __offCorr; const ppmL = clamp(rawL, PPM_BOTTOM, PPM_TOP); const ppmR = clamp(rawR, PPM_BOTTOM, PPM_TOP); const smooth = smoothHeader(shared, rawL, rawR); g.save(); const prevFont = g.font; g.font = 'bold 12px ui-monospace, monospace'; g.fillStyle = HEADER_BG; g.fillRect(rect.x, rect.y - 24, rect.w, 24); const headerWarn = CONFIG.PPM_EBU_HEADER_SHOW_VALUE && (rawL > RED_START || rawR > RED_START); g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.textAlign = 'center'; if (CONFIG.PPM_EBU_HEADER_SHOW_VALUE) { const yText = rect.y - 12; const fmt = (v) => { const sign = v >= 0 ? '+' : '-'; return `${sign}${Math.abs(v).toFixed(1)}`; }; const isRedL = rawL > RED_START; const isRedR = rawR > RED_START; g.textAlign = 'center'; g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.fillText(fmt(smooth.L), centerLeft, yText); g.fillStyle = colNorm; g.fillText('|', centerX, yText); g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.fillText(fmt(smooth.R), centerRight, yText); } else { g.fillText('PPM (EBU)', centerX, rect.y - 12); } g.font = prevFont; g.restore(); const yBottom = mapY(PPM_BOTTOM); const yRed = mapY(RED_START); const yTop = mapY(PPM_TOP); const innerW = Math.max(2, barW - 2); const drawBar = (x0, dbVal) => { const yVal = mapY(dbVal); if (dbVal > RED_START) { if (redOnly) { const yNormTop = Math.min(yRed, yBottom); if (yBottom - yNormTop > 0) { g.fillStyle = colNorm; g.fillRect(x0 + 1, yNormTop, innerW, yBottom - yNormTop); } const yWarnTop = Math.min(yVal, yRed); if (yRed - yWarnTop > 0) { g.fillStyle = colWarn; g.fillRect(x0 + 1, yWarnTop, innerW, yRed - yWarnTop); } } else { if (yBottom - yVal > 0) { g.fillStyle = colWarn; g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal); } } } else { if (yBottom - yVal > 0) { g.fillStyle = colNorm; g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal); } } g.globalAlpha = 0.12; g.fillStyle = '#ffffff'; g.fillRect(x0 + 1, yVal, innerW, 2); g.globalAlpha = 1; }; drawBar(leftX, ppmL); drawBar(rightX, ppmR); // Peak-Hold const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS) ? Math.max(0, CONFIG.PPM_EBU_HOLD_MS) : (shared._holdCfg?.holdMs ?? DEFAULT_HOLD_MS); const holdDecay = Number.isFinite(CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S) ? CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S : (shared._holdCfg?.decayDbPerS ?? shared._ballistics.decayDbPerS ?? DEFAULT_DECAY_DB_PER_S); if (holdMs > 0) { if (!shared.hold) shared.hold = { L: PPM_BOTTOM, R: PPM_BOTTOM }; const cfgChanged = !shared._holdState || !shared._holdCfg || shared._holdCfg.holdMs !== holdMs || shared._holdCfg.decayDbPerS !== holdDecay; if (cfgChanged) { const resetNow = performance.now(); shared._holdState = { L: createPeakHoldState(shared.hold.L ?? PPM_BOTTOM, resetNow, holdMs), R: createPeakHoldState(shared.hold.R ?? PPM_BOTTOM, resetNow, holdMs), }; shared._holdCfg = { holdMs, decayDbPerS: holdDecay }; } const nowHold = performance.now(); const holdOpts = { holdMs, decayDbPerS: holdDecay, floor: PPM_BOTTOM, riseThreshold: 0.2, }; shared.hold.L = stepPeakHold(ppmL, shared._holdState.L, nowHold, holdOpts); shared.hold.R = stepPeakHold(ppmR, shared._holdState.R, nowHold, holdOpts); g.save(); g.fillStyle = '#ffffff'; g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2); g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2); g.restore(); } drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, { leftX, rightX, barW, innerW, scaleX, scaleW, centerX, yRed, yTop, colWarn, }, mapY, PPM_TOP, PPM_BOTTOM); } function drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, PPM_BOTTOM) { const { leftX, rightX, barW, innerW, scaleX, scaleW, centerX, yRed, yTop, colWarn, } = 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.PPM_EBU_TOP ?? 14, CONFIG.PPM_EBU_BOTTOM ?? -12, CONFIG.PPM_EBU_RED_START ?? 9, CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1, METER_HEADER_FONT, colWarn, ].join('|'); drawCachedStaticLayer(g, shared, 'ppm-ebu-static', key, layerX, layerY, layerW, layerH, (cg) => { cg.save(); cg.translate(-layerX, -layerY); drawWarningEdges(cg, leftX, barW, rightX, centerX, yRed, yTop, colWarn); drawBarTicks(cg, leftX, rightX, innerW, mapY, PPM_TOP, PPM_BOTTOM, CONFIG); drawCenterScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, +12, -12); 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('dB', centerX, baseY); cg.font = prevFontLabels; cg.restore(); }); } function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) { 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); const y2 = Math.max(yWarn, yTop); g.save(); g.strokeStyle = 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 drawBarTicks(g, leftX, rightX, widthPx, mapY, top, bottom, CONFIG) { g.save(); g.lineWidth = 1; const DEFAULT_COLOR = 'rgb(0,0,255)'; const AL_COLOR = WARN_COLOR; const showAL = CONFIG?.AL_MARKERS_ENABLED !== false; const highlightDb = showAL ? 0 : null; const MAJOR_INSET = 2; const MINOR_FRAC = 0.45; const cxL = leftX + 1 + widthPx / 2; const cxR = rightX + 1 + widthPx / 2; const drawPair = (yPix, span, highlight = false) => { g.strokeStyle = highlight ? AL_COLOR : DEFAULT_COLOR; const width = Math.max(1, span); const x1L = Math.round(cxL - width / 2) + 0.5; const x2L = Math.round(cxL + width / 2) + 0.5; g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke(); const x1R = Math.round(cxR - width / 2) + 0.5; const x2R = Math.round(cxR + width / 2) + 0.5; g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke(); }; const majorWidth = Math.max(1, widthPx - 4 * MAJOR_INSET); const minorWidth = Math.max(1, Math.floor(widthPx * MINOR_FRAC)); let highlightMatched = false; for (const db of EBU_MAJOR_TICKS) { if (db < bottom || db > top) continue; const y = Math.round(mapY(db)) + 0.5; const isHighlight = highlightDb !== null && Math.abs(db - highlightDb) < 1e-3; if (isHighlight) highlightMatched = true; drawPair(y, majorWidth, isHighlight); } for (const db of EBU_MINOR_TICKS) { if (db < bottom || db > top) continue; const y = Math.round(mapY(db)) + 0.5; const isHighlight = highlightDb !== null && Math.abs(db - highlightDb) < 1e-3; if (isHighlight) highlightMatched = true; drawPair(y, minorWidth, isHighlight); } if (highlightDb !== null && !highlightMatched && highlightDb >= bottom && highlightDb <= top) { const y = Math.round(mapY(highlightDb)) + 0.5; drawPair(y, majorWidth, true); } g.restore(); } function drawCenterScale(g, rect, centerX, mapY, top, bottom) { drawHairlineGrid(g, rect, mapY, top, bottom, 1); g.save(); const prevFont = g.font; g.font = METER_HEADER_FONT; g.fillStyle = LABEL_COLOR; g.textAlign = 'center'; g.textBaseline = 'alphabetic'; for (const db of EBU_MAJOR_TICKS) { if (db < bottom || db > top) continue; const y = mapY(db); if (y < rect.y || y > rect.y + rect.h) continue; const label = db === 0 ? 'TEST' : (db > 0 ? `+${db}` : `${db}`); g.fillText(label, centerX, y + 5); } g.font = prevFont; g.restore(); } 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; }