// meters/tp.js — True Peak (dBTP) L/R mit Übersteuerungsreserve bis +6 dBTP import { createPeakHoldState, stepPeakHold } from '../core/utils.js'; import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js'; import { drawCachedStaticLayer } from './static_layer.js'; import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from './true_peak_scale.js'; import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js'; export const id = 'tp'; export function initShared(CONFIG) { const now = performance.now(); return { values: { L: -60, R: -60 }, hold: { L: -60, R: -60 }, _holdState: { L: createPeakHoldState(-60, now, CONFIG?.TP_HOLD_MS ?? 1000), R: createPeakHoldState(-60, now, CONFIG?.TP_HOLD_MS ?? 1000), }, offset: CONFIG?.TP_OFFSET_DB || 0, }; } export function update(packet, shared) { if (!packet || !shared) return; const L = Number.isFinite(packet.tpL) ? packet.tpL : -60; const R = Number.isFinite(packet.tpR) ? packet.tpR : -60; shared.values.L = L + (shared.offset || 0); shared.values.R = R + (shared.offset || 0); } const LOG_MIN = TRUE_PEAK_SCALE[0].db; const LOG_TOP = TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db; function interpolateFrac(db) { if (db <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac; if (db >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac; for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) { const prev = TRUE_PEAK_SCALE[i - 1]; const curr = TRUE_PEAK_SCALE[i]; if (db <= curr.db) { const span = curr.db - prev.db || 1; const t = (db - prev.db) / span; return prev.frac + t * (curr.frac - prev.frac); } } return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac; } export function draw(g, rect, CONFIG, shared) { if (!g || !rect || !CONFIG || !shared) return; // Live-Offset berücksichtigen (TP_OFFSET_DB) const __effOff = Number(CONFIG && CONFIG.TP_OFFSET_DB) || 0; const __offCorr = __effOff - (shared.offset || 0); const mapY = (db) => { const clamped = Math.max(LOG_MIN, Math.min(LOG_TOP, db)); const norm = interpolateFrac(clamped); return rect.y + (1 - norm) * rect.h; }; // Layout: zwei Balken + mittige Skala (nur Labels) 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 rawL = shared.values.L + __offCorr; const rawR = shared.values.R + __offCorr; const tpL = Math.max(LOG_MIN, Math.min(LOG_TOP, rawL)); const tpR = Math.max(LOG_MIN, Math.min(LOG_TOP, rawR)); const smooth = smoothHeader(shared, rawL, rawR); const centerLeft = leftX + barW / 2; const centerRight = rightX + barW / 2; 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.TP_HEADER_SHOW_VALUE && (rawL > CONFIG.TP_RED_START || rawR > CONFIG.TP_RED_START); g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.textAlign = 'center'; if (CONFIG.TP_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 > CONFIG.TP_RED_START; const isRedR = rawR > CONFIG.TP_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 = 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 { g.fillText('True Peak', centerX, rect.y - 12); } g.font = prevFont; g.restore(); const yFloor = mapY(LOG_MIN); const configuredRedStart = Number(CONFIG.TP_RED_START); const redStart = Math.max(LOG_MIN, Math.min(LOG_TOP, Number.isFinite(configuredRedStart) ? configuredRedStart : -1)); const yRed = mapY(redStart); const innerW = Math.max(2, barW - 2); const colNorm = CONFIG.TP_COLOR_NORMAL || MID_COLOR; const colWarn = CONFIG.TP_COLOR_WARN || WARN_COLOR; const redOnly = CONFIG.TP_RED_BAR_ONLY !== false; const drawBar = (x0, val) => { const yVal = mapY(val); if (val > redStart) { if (redOnly) { g.fillStyle = colNorm; g.fillRect(x0 + 1, yRed, innerW, Math.max(0, yFloor - yRed)); g.fillStyle = colWarn; g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yRed - yVal)); } 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, tpL); drawBar(rightX, tpR); // Hold-Logik (weiße Linie) const now = performance.now(); const holdMs = CONFIG.TP_HOLD_MS ?? 1000; const decayDbPerS = CONFIG.TP_DECAY_DB_PER_S ?? 20; if (!shared._holdState) { shared._holdState = { L: createPeakHoldState(shared.hold?.L ?? LOG_MIN, now, holdMs), R: createPeakHoldState(shared.hold?.R ?? LOG_MIN, now, holdMs), }; } const holdOpts = { holdMs, decayDbPerS, floor: LOG_MIN, riseThreshold: 0.2 }; shared.hold.L = stepPeakHold(tpL, shared._holdState.L, now, holdOpts); shared.hold.R = stepPeakHold(tpR, shared._holdState.R, now, holdOpts); // Hold-Linien zeichnen g.save(); g.fillStyle = 'white'; const yHoldL = mapY(shared.hold.L); const yHoldR = mapY(shared.hold.R); g.fillRect(leftX + 1, yHoldL - 2, innerW, 3); g.fillRect(rightX + 1, yHoldR - 2, innerW, 3); g.restore(); drawTpStaticOverlay(g, shared, rect, CONFIG, { leftX, rightX, barW, innerW, scaleX, scaleW, centerX, }, mapY); } function drawTpStaticOverlay(g, shared, rect, CONFIG, geom, mapY) { const { leftX, rightX, barW, innerW, scaleX, scaleW, centerX } = 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.AL_MARKERS_ENABLED === false ? 0 : 1, METER_HEADER_FONT, ].join('|'); drawCachedStaticLayer(g, shared, 'tp-static', key, layerX, layerY, layerW, layerH, (cg) => { cg.save(); cg.translate(-layerX, -layerY); drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, getTpAlignmentTick(CONFIG)); const scaleRect = { x: scaleX, y: rect.y, w: scaleW, h: rect.h }; drawScale(cg, scaleRect, centerX, mapY, CONFIG); 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('dBTP', centerX, baseY); cg.font = prevFontLabels; cg.restore(); }); } function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, alignmentTick = null) { if (!g) return; g.save(); g.lineWidth = 1; const MAJOR_INSET = 2; const MINOR_FRAC = 0.45; const DEFAULT_COLOR = 'rgb(0,0,255)'; const highlightValue = Number.isFinite(alignmentTick?.value) ? alignmentTick.value : null; const highlightColor = alignmentTick?.color || WARN_COLOR; const approx = (a, b) => Math.abs(a - b) < 1e-3; let highlightMatched = false; const cxL = leftX + 1 + widthPx / 2; const cxR = rightX + 1 + widthPx / 2; const drawMajor = (yPix, highlight = false) => { g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR; const majorW = Math.max(1, widthPx - 4 * MAJOR_INSET); const x1L = Math.round(cxL - majorW / 2) + 0.5; const x2L = Math.round(cxL + majorW / 2) + 0.5; 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 = (yPix, highlight = false) => { g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR; const span = Math.max(1, Math.floor(widthPx * MINOR_FRAC)); 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(); }; for (const point of TRUE_PEAK_SCALE) { const y = Math.round(mapY(point.db)) + 0.5; const isHighlight = highlightValue !== null && approx(point.db, highlightValue); if (isHighlight) highlightMatched = true; drawMajor(y, isHighlight); } for (const db of TRUE_PEAK_MINOR_TICKS) { const y = Math.round(mapY(db)) + 0.5; const isHighlight = highlightValue !== null && approx(db, highlightValue); if (isHighlight) highlightMatched = true; drawMinor(y, isHighlight); } if (highlightValue !== null && !highlightMatched) { const y = Math.round(mapY(highlightValue)) + 0.5; drawMajor(y, true); } g.restore(); } // Skala mittig: nur dB-Labels function drawScale(g, colRect, centerX, mapY, CONFIG) { if (!g || !colRect) return; drawHairlineGrid(g, colRect, mapY, LOG_TOP, LOG_MIN, 1); g.save(); const prevFont = g.font; g.font = METER_HEADER_FONT; g.fillStyle = LABEL_COLOR; g.textAlign = 'center'; g.textBaseline = 'alphabetic'; for (const point of TRUE_PEAK_SCALE) { const y = mapY(point.db); if (y < colRect.y || y > colRect.y + colRect.h) continue; const dbLabel = point.db > 0 ? `+${point.db}` : String(point.db); g.fillText(dbLabel, centerX, y + 5); } g.font = prevFont; g.restore(); } function getTpAlignmentTick(CONFIG) { if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null; const isArd = CONFIG.PPM_DIN_MODE === 'al_minus9'; return { value: isArd ? -12 : -15, 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; }