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_din.js — Peak Programme Meter, DIN Scale (IEC 60268-10 Type I) // Quasi-PPM mit optionalem Peak-Hold, DIN-Skala und bestehendem Farbschema. export const id = 'ppm-din'; const DEFAULT_DECAY_DB_PER_S = 20 / 1.5; // RTW/DIN: 20 dB in 1.5 s const DEFAULT_HOLD_MS = 1000; const DIN_SCALE = [ { db: -50, pos: 0.0205 }, { db: -40, pos: 0.0564 }, { db: -35, pos: 0.0974 }, { db: -30, pos: 0.1538 }, { db: -25, pos: 0.2308 }, { db: -20, pos: 0.3179 }, { db: -15, pos: 0.4359 }, { db: -10, pos: 0.5538 }, { db: -5, pos: 0.7026 }, { db: 0, pos: 0.8513 }, { db: +5, pos: 1.0000 }, ]; const PERCENT_MARKS = [ { label: '1', db: -40 }, { label: '10', db: -20 }, { label: '50', db: -6 }, { label: '100', db: 0 }, { label: '180', db: +5 }, ]; const MAJOR_TICKS_DB = [-50, -40, -30, -20, -10, -5, 0, +5]; const MINOR_TICKS = [ { db: -35 }, { db: -25 }, { db: -21, color: 'warn' }, { db: -15 }, { db: -9 }, { db: -8 }, { db: -7 }, { db: -6 }, { db: -4 }, { db: -3 }, { db: -2 }, { db: -1 }, { db: +1 }, { db: +2 }, { db: +3 }, { db: +4 }, ]; export function initShared(CONFIG = {}) { const now = performance.now(); const bottom = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50; const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS) ? CONFIG.PPM_DIN_HOLD_MS : DEFAULT_HOLD_MS; return { values: { L: bottom, R: bottom }, hold: { L: bottom, R: bottom }, loudnessDbfs: { L: null, R: null }, _loudSmooth: null, _env: { L_dbfs: -90, R_dbfs: -90, lastTs: now, }, _holdState: { L: createPeakHoldState(bottom, now, holdMs), R: createPeakHoldState(bottom, now, holdMs), }, _holdCfg: { holdMs, decayDbPerS: Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S : (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S), }, // Offset for DIN uses PPM_DIN_OFFSET so that 0 dB on the meter aligns // properly with the Permitted Maximum Level (PML). According to the // selected alignment profile maps 0 dBu to either -6 dB or legacy -9 dB. offset: Number(CONFIG.PPM_DIN_OFFSET) || 0, 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?.ppmDinL) ? packet.ppmDinL : (Number.isFinite(packet?.ppmL) ? packet.ppmL : -90); const inR = Number.isFinite(packet?.ppmDinR) ? packet.ppmDinR : (Number.isFinite(packet?.ppmR) ? packet.ppmR : -90); const ppmBoxL = Number.isFinite(packet?.ppmBoxL) ? packet.ppmBoxL : null; const ppmBoxR = Number.isFinite(packet?.ppmBoxR) ? packet.ppmBoxR : null; shared._loudIsBox = Number.isFinite(ppmBoxL) || Number.isFinite(ppmBoxR); const fallbackLoud = Number.isFinite(packet?.lufsM) ? packet.lufsM : Number.isFinite(packet?.lufsS) ? packet.lufsS : Number.isFinite(packet?.lufsI) ? packet.lufsI : null; const loudL = Number.isFinite(ppmBoxL) ? ppmBoxL : (Number.isFinite(packet?.lufsML) ? packet.lufsML : fallbackLoud); const loudR = Number.isFinite(ppmBoxR) ? ppmBoxR : (Number.isFinite(packet?.lufsMR) ? packet.lufsMR : fallbackLoud); const base = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15; const off = shared.offset || 0; // Verwende Worklet-Pegel direkt; Ballistik liegt bereits im AudioWorklet. shared._env.L_dbfs = inL; shared._env.R_dbfs = inR; const now = performance.now(); shared._env.lastTs = now; shared.values.L = (inL - base) + off; shared.values.R = (inR - base) + off; // Smoothe Loudness pro Kanal (LUFS M bevorzugt) für ruhige Overlay-Bewegung. if (!shared._loudSmooth) { shared._loudSmooth = { L: Number.isFinite(loudL) ? loudL : null, R: Number.isFinite(loudR) ? loudR : null, lastTs: now, }; } const dtL = Math.max(1e-3, (now - (shared._loudSmooth.lastTs || now)) / 1000); const tau = 0.18; // ~180 ms Gleitzeit const alphaL = 1 - Math.exp(-dtL / tau); const smoothVal = (prev, target) => { if (!Number.isFinite(target)) return { out: null, prev: prev }; const baseVal = Number.isFinite(prev) ? prev : target; const out = baseVal + alphaL * (target - baseVal); return { out, prev: out }; }; const resL = smoothVal(shared._loudSmooth.L, loudL); const resR = smoothVal(shared._loudSmooth.R, loudR); shared._loudSmooth.L = resL.prev; shared._loudSmooth.R = resR.prev; shared._loudSmooth.lastTs = now; shared.loudnessDbfs = { L: resL.out, R: resR.out }; } export function draw(g, rect, CONFIG = {}, shared) { // Use the DIN-specific offset for display. This ensures that DIN and EBU // scales do not influence each other. The effective offset is the user- // adjustable correction on top of the meter's built-in offset. const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9; const __effOff = baseMode + (Number(CONFIG.PPM_DIN_TRIM_DB) || 0); const __offCorr = __effOff - (shared.offset || 0); const PPM_TOP = Number.isFinite(CONFIG.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5; const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50; const EXT_BOTTOM = PPM_BOTTOM - 20; // Virtuelle Skalenverlängerung für Loudness-Boxen const RED_START = Number.isFinite(CONFIG.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0; const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false; const colNorm = CONFIG.PPM_DIN_COLOR_NORMAL || MID_COLOR; const colWarn = CONFIG.PPM_DIN_COLOR_WARN || WARN_COLOR; const mapNorm = (db) => { const clamped = Math.max(PPM_BOTTOM, Math.min(PPM_TOP, db)); const table = DIN_SCALE; let prev = table[0]; for (let i = 1; i < table.length; i++) { const curr = table[i]; if (clamped <= curr.db) { const span = curr.db - prev.db || 1; const t = (clamped - prev.db) / span; return prev.pos + t * (curr.pos - prev.pos); } prev = curr; } return table[table.length - 1].pos; }; // DIN-Skala auf volle Höhe normalisieren: -50 dB sitzt am Rect-Boden, Proportionen bleiben. const normBottom = mapNorm(PPM_BOTTOM); const normSpan = Math.max(1e-6, 1 - normBottom); const mapY = (db) => { const n = mapNorm(db); const t = Math.max(0, Math.min(1, (n - normBottom) / normSpan)); return rect.y + (1 - t) * rect.h; }; 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 bottomLoud = PPM_BOTTOM - 20; // Loudness-Boxen dürfen 20 dB tiefer fallen als die Skala const clampDb = (v) => Math.max(PPM_BOTTOM, Math.min(PPM_TOP, v)); const rawL = shared.values.L + __offCorr; const rawR = shared.values.R + __offCorr; const dbValL = clampDb(rawL); const dbValR = clampDb(rawR); 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_DIN_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_DIN_HEADER_SHOW_VALUE) { const yText = rect.y - 12; const fmt = (v) => { const sign = v >= 0 ? '+' : '-'; return `${sign}${Math.abs(v).toFixed(1)}`; }; const centerLeft = leftX + barW / 2; const centerRight = rightX + barW / 2; 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 (DIN)', centerX, rect.y - 12); } g.font = prevFont; g.restore(); const ppmL = mapClamp(dbValL, PPM_BOTTOM, PPM_TOP); const ppmR = mapClamp(dbValR, PPM_BOTTOM, PPM_TOP); const baseDbfs = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15; const loudOff = Number(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB) || 0; const loudnessDisplayL = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.L)) ? (shared.loudnessDbfs.L - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff : null; const loudnessDisplayR = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.R)) ? (shared.loudnessDbfs.R - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff : null; const yBottom = mapY(PPM_BOTTOM); const yRed = mapY(RED_START); const yTop = mapY(PPM_TOP); const innerW = Math.max(10, 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 (optional, rein visuell; eigentliche Ballistik kommt aus dem Worklet) const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS) ? Math.max(0, CONFIG.PPM_DIN_HOLD_MS) : (shared?._holdCfg?.holdMs ?? DEFAULT_HOLD_MS); const holdDecay = Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S : (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : 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(); } const drawLoudBox = (x0, loudVal) => { if (!Number.isFinite(loudVal)) return; const boxSize = barW; // exakt so breit wie der sichtbare Balken (keine 1px-Ränder) // Messwert sitzt an der oberen Kante; Skala wird nach unten verlängert. // Bei -50 dB (Bottom) liegt die Oberkante im sichtbaren Bereich, // bei -70 dB ist sie eine volle Meterhöhe weiter unten (außerhalb des Sichtfelds). const spanExt = Math.max(1e-6, PPM_BOTTOM - EXT_BOTTOM); const yBottom = mapY(PPM_BOTTOM); const yLoud = (loudVal < PPM_BOTTOM) ? yBottom + ((PPM_BOTTOM - loudVal) / spanExt) * rect.h : mapY(loudVal); // Zeichne nur den sichtbaren Teil: Box-Oberkante = Pegel, Unterkante nicht unter yBottom. const boxY = yLoud; const visibleHeight = Math.max(0, Math.min(boxSize, yBottom - boxY)); if (visibleHeight <= 0) return; const x = x0; g.save(); g.fillStyle = '#0b6ea8'; g.globalAlpha = 0.92; g.fillRect(x, boxY, boxSize, visibleHeight); g.globalAlpha = 1; g.strokeStyle = '#094b73'; g.lineWidth = 1; g.strokeRect(x + 0.5, boxY + 0.5, boxSize - 1, visibleHeight - 1); g.restore(); }; drawLoudBox(leftX, loudnessDisplayL); drawLoudBox(rightX, loudnessDisplayR); drawPpmDinStaticOverlay(g, shared, rect, CONFIG, { leftX, rightX, barW, innerW, scaleX, scaleW, centerX, yRed, yTop, colWarn, }, mapY, PPM_TOP, PPM_BOTTOM); } function drawPpmDinStaticOverlay(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 sidePad = 0; const layerX = rect.x - sidePad; const layerY = rect.y - topPad; const layerW = rect.w + sidePad * 2; const layerH = rect.h + 24 + topPad; const key = [ 'scale-font-header-v1-top-pad', Math.round(rect.w), Math.round(rect.h), topPad, sidePad, CONFIG.METER_BAR_THIN || 0.55, CONFIG.PPM_DIN_MODE || 'al_minus9', CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1, CONFIG.PPM_DIN_TOP ?? 5, CONFIG.PPM_DIN_BOTTOM ?? -50, CONFIG.PPM_DIN_RED_START ?? 0, METER_HEADER_FONT, colWarn, ].join('|'); drawCachedStaticLayer(g, shared, 'ppm-din-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, colWarn, CONFIG); drawCenterScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, PPM_TOP, PPM_BOTTOM); drawPercentScale(cg, leftX, rightX, barW, mapY, PPM_BOTTOM); 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 mapClamp(db, bottom, top) { return Math.max(bottom, Math.min(top, db)); } 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, warnColor, CONFIG) { g.save(); g.lineWidth = 1; const DEFAULT_COLOR = 'rgb(0,0,255)'; const MAJOR_INSET = 2; const MINOR_FRAC = 0.45; const cxL = leftX + 1 + widthPx / 2; const cxR = rightX + 1 + widthPx / 2; const drawPair = (yPix, width, color = DEFAULT_COLOR) => { g.save(); g.strokeStyle = color; const span = Math.max(1, width); const x1L = Math.round(cxL - span / 2) + 0.5; const x2L = Math.round(cxL + span / 2) + 0.5; g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke(); const x1R = Math.round(cxR - span / 2) + 0.5; const x2R = Math.round(cxR + span / 2) + 0.5; g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke(); g.restore(); }; const minorWidth = Math.max(1, Math.floor(widthPx * MINOR_FRAC)); const majorWidth = Math.max(1, widthPx - 4 * MAJOR_INSET); const majors = MAJOR_TICKS_DB .filter((db) => db >= bottom && db <= top) .slice() .sort((a, b) => a - b); for (const db of majors) { const y = Math.round(mapY(db)) + 0.5; drawPair(y, majorWidth); } const warn = warnColor || DEFAULT_COLOR; const showAl = CONFIG?.AL_MARKERS_ENABLED !== false; const minors = MINOR_TICKS .filter((tick) => tick.db >= bottom && tick.db <= top) .slice() .sort((a, b) => a.db - b.db); const dynWarnDb = showAl ? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9) : null; for (const tick of minors) { const y = Math.round(mapY(tick.db)) + 0.5; const isDynWarn = dynWarnDb !== null && tick.db === dynWarnDb; const color = (tick.color === 'warn' || isDynWarn) ? warn : DEFAULT_COLOR; drawPair(y, minorWidth, color); } 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'; const majors = MAJOR_TICKS_DB .filter((db) => db >= bottom && db <= top) .slice() .sort((a, b) => b - a); for (const db of majors) { const y = mapY(db); if (y < rect.y || y > rect.y + rect.h) continue; const label = db > 0 ? `+${db}` : `${db}`; g.fillText(label, centerX, y + 5); } g.font = prevFont; g.restore(); } function drawPercentScale(g, leftX, rightX, barW, mapY, bottomDb) { g.save(); g.fillStyle = '#ffb347'; const prevFont = g.font; g.font = 'bold 8px ui-monospace, monospace'; const marks = PERCENT_MARKS.slice().sort((a, b) => a.db - b.db); g.textAlign = 'right'; for (const mark of marks) { g.fillText(mark.label, leftX - 1, mapY(mark.db) + 4); } g.textAlign = 'left'; const rightTextX = rightX + barW + 1; for (const mark of marks) { g.fillText(mark.label, rightTextX, mapY(mark.db) + 4); } const percentYOffset = 12; g.textAlign = 'right'; g.fillText('%', leftX - 1, mapY(bottomDb) + percentYOffset); g.textAlign = 'left'; g.fillText('%', rightTextX, mapY(bottomDb) + percentYOffset); 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; }