import { createPeakHoldState, stepPeakHold } from '../core/utils.js'; import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js'; import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js'; export const id = 'hifi-peak'; const HIFI_BOTTOM = -20; const HIFI_TOP = 6; const HIFI_RED_START = 3; const DEFAULT_ATTACK_MS = 8; const DEFAULT_RELEASE_DB_PER_S = 26; const DEFAULT_HOLD_MS = 320; const DEFAULT_HOLD_DECAY_DB_PER_S = 40; const MAJOR_TICKS = [-20, -10, -5, 0, 3, 6]; const MINOR_TICKS = [-15, -8, -7, -6, -4, -3, -2, -1, 1, 2, 4, 5]; export function initShared(CONFIG = {}) { const now = performance.now(); const refDbfsFor0 = resolveHifiRefDbfsFor0(CONFIG); return { target: { L: HIFI_BOTTOM, R: HIFI_BOTTOM }, values: { L: HIFI_BOTTOM, R: HIFI_BOTTOM }, hold: { L: HIFI_BOTTOM, R: HIFI_BOTTOM }, refDbfsFor0, lastTs: now, _holdState: { L: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS), R: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS), }, }; } export function update(packet, shared) { if (!packet || !shared) return; const now = performance.now(); const rawL = Number.isFinite(packet.tpL) ? packet.tpL : -120; const rawR = Number.isFinite(packet.tpR) ? packet.tpR : -120; const refDbfsFor0 = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -14; const deckL = rawL - refDbfsFor0; const deckR = rawR - refDbfsFor0; const targetL = clamp(deckL, HIFI_BOTTOM, HIFI_TOP); const targetR = clamp(deckR, HIFI_BOTTOM, HIFI_TOP); const dt = Math.max(1 / 240, (now - (shared.lastTs || now)) / 1000); shared.lastTs = now; const attackTau = Math.max(0.001, DEFAULT_ATTACK_MS / 1000); const attackAlpha = 1 - Math.exp(-dt / attackTau); const releaseStep = DEFAULT_RELEASE_DB_PER_S * dt; shared.target.L = targetL; shared.target.R = targetR; shared.values.L = applyBallistics(shared.values.L, targetL, attackAlpha, releaseStep); shared.values.R = applyBallistics(shared.values.R, targetR, attackAlpha, releaseStep); } export function draw(g, rect, CONFIG = {}, shared) { if (!g || !rect || !shared) return; syncAlignment(shared, CONFIG); 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 mapY = (db) => rect.y + (1 - norm(db)) * 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 centerLeft = leftX + barW / 2; const centerRight = rightX + barW / 2; const innerW = Math.max(2, barW - 2); const rawL = clamp(shared.values.L, HIFI_BOTTOM, HIFI_TOP); const rawR = clamp(shared.values.R, HIFI_BOTTOM, HIFI_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); g.textAlign = 'center'; g.fillStyle = (rawL > HIFI_RED_START || rawR > HIFI_RED_START) ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); const yText = rect.y - 12; g.fillStyle = rawL > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.fillText(formatHeaderValue(smooth.L), centerLeft, yText); g.fillStyle = colNorm; g.fillText('|', centerX, yText); g.fillStyle = rawR > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR); g.fillText(formatHeaderValue(smooth.R), centerRight, yText); g.font = prevFont; g.restore(); drawBar(g, leftX, innerW, rawL, mapY, colNorm, colWarn, redOnly); drawBar(g, rightX, innerW, rawR, mapY, colNorm, colWarn, redOnly); const now = performance.now(); const holdOpts = { holdMs: DEFAULT_HOLD_MS, decayDbPerS: DEFAULT_HOLD_DECAY_DB_PER_S, floor: HIFI_BOTTOM, riseThreshold: 0.15, }; shared.hold.L = stepPeakHold(shared.values.L, shared._holdState.L, now, holdOpts); shared.hold.R = stepPeakHold(shared.values.R, shared._holdState.R, now, 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 yTop = mapY(HIFI_TOP); const yRed = mapY(HIFI_RED_START); drawWarningEdges(g, leftX, barW, rightX, centerX, yRed, yTop, colWarn); drawBarTicks(g, leftX, rightX, innerW, mapY, colWarn); drawScale(g, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY); g.save(); g.fillStyle = LABEL_COLOR; g.textAlign = 'center'; const baseY = rect.y + rect.h + 16; g.fillText('L', leftX + barW / 2, baseY); g.fillText('R', rightX + barW / 2, baseY); const prevFooterFont = g.font; g.fillStyle = '#ffffff'; g.font = 'bold 8.4px ui-monospace, monospace'; g.fillText('dB', centerX, baseY); g.font = prevFooterFont; g.restore(); } function applyBallistics(current, target, attackAlpha, releaseStep) { const prev = Number.isFinite(current) ? current : HIFI_BOTTOM; if (target >= prev) { return clamp(prev + (target - prev) * attackAlpha, HIFI_BOTTOM, HIFI_TOP); } return clamp(Math.max(target, prev - releaseStep), HIFI_BOTTOM, HIFI_TOP); } function drawBar(g, x, width, value, mapY, colNorm, colWarn, redOnly) { const yVal = mapY(value); const yFloor = mapY(HIFI_BOTTOM); const yRed = mapY(HIFI_RED_START); if (value > HIFI_RED_START) { if (redOnly) { const normTop = Math.min(yRed, yFloor); if (yFloor - normTop > 0) { g.fillStyle = colNorm; g.fillRect(x + 1, normTop, width, yFloor - normTop); } const warnTop = Math.min(yVal, yRed); if (yRed - warnTop > 0) { g.fillStyle = colWarn; g.fillRect(x + 1, warnTop, width, yRed - warnTop); } } else { g.fillStyle = colWarn; g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal)); } } else { g.fillStyle = colNorm; g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal)); } g.globalAlpha = 0.12; g.fillStyle = '#ffffff'; g.fillRect(x + 1, yVal, width, 2); g.globalAlpha = 1; } function drawScale(g, rect, centerX, mapY) { drawHairlineGrid(g, rect, mapY, HIFI_TOP, HIFI_BOTTOM, 1); g.save(); const prevFont = g.font; g.font = METER_HEADER_FONT; g.strokeStyle = 'rgba(143, 211, 212, 0.22)'; g.lineWidth = 1; for (const db of MINOR_TICKS) { const y = Math.round(mapY(db)) + 0.5; g.beginPath(); g.moveTo(centerX - 4, y); g.lineTo(centerX + 4, y); g.stroke(); } for (const db of MAJOR_TICKS) { const y = Math.round(mapY(db)) + 0.5; g.beginPath(); g.moveTo(centerX - 8, y); g.lineTo(centerX + 8, y); g.stroke(); } g.fillStyle = LABEL_COLOR; g.textAlign = 'center'; g.textBaseline = 'alphabetic'; for (const db of MAJOR_TICKS) { const y = mapY(db); const label = db > 0 ? `+${db}` : `${db}`; g.fillText(label, centerX, y + 5); } g.font = prevFont; g.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, warnColor) { if (!g) return; g.save(); g.lineWidth = 1; const defaultColor = 'rgb(0,0,255)'; const majorInset = 2; const minorFrac = 0.45; const majorWidth = Math.max(1, widthPx - 4 * majorInset); const minorWidth = Math.max(1, Math.floor(widthPx * minorFrac)); const cxL = leftX + 1 + widthPx / 2; const cxR = rightX + 1 + widthPx / 2; const drawPair = (yPix, width, color = defaultColor) => { g.strokeStyle = color; 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(); }; for (const db of MAJOR_TICKS) { const y = Math.round(mapY(db)) + 0.5; const color = db === HIFI_RED_START ? warnColor : defaultColor; drawPair(y, majorWidth, color); } for (const db of MINOR_TICKS) { const y = Math.round(mapY(db)) + 0.5; drawPair(y, minorWidth, defaultColor); } g.restore(); } function formatHeaderValue(v) { const n = Number.isFinite(v) ? v : HIFI_BOTTOM; const sign = n >= 0 ? '+' : '-'; return `${sign}${Math.abs(n).toFixed(1)}`; } function resolveHifiRefDbfsFor0(CONFIG = {}) { const base = Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15; const modeOffset = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9; const trim = Number(CONFIG.PPM_DIN_TRIM_DB) || 0; const effOff = modeOffset + trim; const ppmDin0Dbfs = base - effOff; return (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero') ? ppmDin0Dbfs : (ppmDin0Dbfs - 5); } function syncAlignment(shared, CONFIG = {}) { const nextRef = resolveHifiRefDbfsFor0(CONFIG); const prevRef = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : nextRef; const shift = prevRef - nextRef; if (!Number.isFinite(shift) || Math.abs(shift) < 1e-6) { shared.refDbfsFor0 = nextRef; return; } shared.refDbfsFor0 = nextRef; shared.target.L = clamp((shared.target.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); shared.target.R = clamp((shared.target.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); shared.values.L = clamp((shared.values.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); shared.values.R = clamp((shared.values.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); shared.hold.L = clamp((shared.hold.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); shared.hold.R = clamp((shared.hold.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); if (shared._holdState?.L) shared._holdState.L.value = clamp((shared._holdState.L.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); if (shared._holdState?.R) shared._holdState.R.value = clamp((shared._holdState.R.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); if (shared._header) { shared._header.L = clamp((shared._header.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); shared._header.R = clamp((shared._header.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP); } } 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; } function norm(db) { const clamped = clamp(db, HIFI_BOTTOM, HIFI_TOP); return (clamped - HIFI_BOTTOM) / (HIFI_TOP - HIFI_BOTTOM); } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }