// views/peak_history.js - Meter-Verlauf (rechter Slot) mit RTA-Layout import { FRAME_COLOR, GRID_MAJOR_COLOR, GRID_MINOR_COLOR, LABEL_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js'; const PLOT = { left: 43, top: 70, right: 0, bottom: 18 }; const METER_WIDTH = 140; const METER_GAP = 8; const METER_PAD_TOP = 15; const METER_PAD_BOTTOM = 5; const METER_SLOT_SHRINK = 24; const METER_EXTRA_BOTTOM_PAD = 5; const SAMPLE_PERIOD = 0.025; // 25 ms -> ~40 Hz; bei 800 px ~20 s const MIN_HISTORY_SEC = 10; const MAX_HISTORY_SEC = 25; const BELOW_BOTTOM_DB_SPAN = 20; const GRID_COLOR_MAJOR = GRID_MAJOR_COLOR; const GRID_COLOR_MINOR = GRID_MINOR_COLOR; const STATIC_LABEL_PAD_LEFT = 40; const STATIC_LABEL_PAD_BOTTOM = 28; const STATIC_LABEL_PAD_RIGHT = 18; const VU_DEFLECTION = [ { db: -20, frac: 0.000 }, { db: -10, frac: 0.165 }, { db: -7, frac: 0.264 }, { db: -5, frac: 0.352 }, { db: -3, frac: 0.463 }, { db: 0, frac: 0.686 }, { db: +1, frac: 0.779 }, { db: +2, frac: 0.883 }, { db: +3, frac: 1.000 }, ]; 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 TP_PERCENT_SCALE = [ { db: -60, frac: 0.0000 }, { db: -50, frac: 0.0373 }, { db: -40, frac: 0.1429 }, { db: -35, frac: 0.2112 }, { db: -30, frac: 0.2857 }, { db: -25, frac: 0.3851 }, { db: -20, frac: 0.4783 }, { db: -15, frac: 0.6087 }, { db: -10, frac: 0.7391 }, { db: -5, frac: 0.8696 }, { db: 0, frac: 1.0000 }, ]; const VU_TICKS = [-20, -10, -7, -5, -3, 0, 1, 2, 3]; const PPM_DIN_TICKS = [-50, -40, -30, -20, -10, -5, 0, 5]; const PPM_EBU_TICKS = [-12, -8, -4, 0, 4, 8, 12]; const TP_TICKS = [-60, -50, -40, -30, -20, -10, 0]; const RMS_DBFS_TICKS = [0, -6, -12, -18, -24, -30, -40, -60]; const RMS_DBU_TICKS = [24, 20, 10, 0, -10, -20, -30, -40, -50, -60]; const LUFS_TICKS = [-50, -40, -30, -23, -18, -10, -5]; const METER_LABELS = { 'vu': 'VU', 'ppm-ebu': 'PPM (EBU)', 'ppm-din': 'PPM (DIN)', 'tp': 'True Peak', 'rms': 'RMS', 'lufs': 'LUFS', }; export const id = 'peak-history'; export function init() { return { history: new Float32Array(0), samplePeriod: SAMPLE_PERIOD, write: 0, accumDt: 0, lastTs: 0, meterSig: '', meterLabel: '', neutralNorm: 0, neutralValue: 0, rmsSmooth: null, staticLayer: null, }; } export function resize() {} export function destroy(state) { state.staticLayer = null; } export async function render(env, state) { const { ctx: g, rect, config: CONFIG } = env; const plotX = PLOT.left; const plotY = Number.isFinite(env?.topInset) ? Number(env.topInset) : PLOT.top; const slotList = env.slots ? env.slots(id) : null; const selected = (slotList && slotList[0]) || 'vu'; const showMeter = selected !== 'none'; // Meter als Datenquelle (History) const embedded = !!env?.embedded; const showMeterPanel = showMeter && !embedded; // im Split/Quad keinen extra Meter rechts zeichnen const plotW = Math.max(0, (rect.w - PLOT.right) - (showMeterPanel ? (METER_WIDTH + METER_GAP) : 0) - plotX); const plotH = rect.h - plotY - PLOT.bottom; const meterRects = showMeterPanel ? computeMeterRects(plotX, plotY, plotW, plotH) : null; if (!showMeter) { g.save(); g.fillStyle = PANEL_BG; g.fillRect(rect.x, rect.y, rect.w, rect.h); g.font = '14px ui-monospace, monospace'; g.fillStyle = '#9aa'; g.textAlign = 'left'; const y = plotY >= 40 ? (plotY - 20) : (plotY + 22); g.fillText('Benötigt einen belegten Slot — bitte wähle ein Meter im HUD.', plotX + 10, y); redrawPlotEdges(g, plotX, plotY, plotW, plotH, CONFIG, { y: plotY, h: plotH }); g.restore(); return; } const meterId = selected || 'vu'; const meterInfo = resolveMeterInfo(env, state, meterId); const samplePeriod = resolveHistorySamplePeriod(CONFIG); state.samplePeriod = samplePeriod; const desiredLen = clampHistorySamples(plotW); if (!state.history || state.history.length !== desiredLen) { resizeHistory(state, desiredLen, meterInfo.neutralValue); } if (state.meterSig !== meterInfo.signature) { resetHistory(state, meterInfo.neutralValue); state.meterSig = meterInfo.signature; } state.neutralNorm = meterInfo.neutralNorm; state.neutralValue = meterInfo.neutralValue; state.meterLabel = meterInfo.label; const now = performance.now(); const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0; if (wakeTs && state?._lastWakeTs !== wakeTs) { // Nach Screensaver-Wake: keine "Backfill"-Samples über die gesamte History. state._lastWakeTs = wakeTs; state.lastTs = now; state.accumDt = 0; } const rawDt = state.lastTs ? Math.max(0, (now - state.lastTs) / 1000) : 0; // Wenn Render eine Weile pausiert war (z.B. Screensaver), nicht die komplette Zeit in einem Frame nachsamplen. const dt = rawDt > 0.5 ? 0 : rawDt; state.lastTs = now; state.accumDt += dt; const lastSampleTs = Number.isFinite(env?.audio?.lastSampleTs) ? Number(env.audio.lastSampleTs) : 0; const audioAgeMs = lastSampleTs ? (now - lastSampleTs) : Infinity; const audioFresh = Number.isFinite(audioAgeMs) && audioAgeMs >= 0 && audioAgeMs <= 250; const sampleValue = (env.audio?.alive && audioFresh && Number.isFinite(meterInfo.value)) ? meterInfo.value : meterInfo.neutralValue; while (state.accumDt >= samplePeriod) { pushSample(state, sampleValue); state.accumDt -= samplePeriod; } const valueRect = meterRects?.innerRect || computeValueRect(plotX, plotY, plotW, plotH); const frameRect = { y: plotY, h: plotH }; drawCachedHistoryStaticLayer( g, state, plotX, plotY, plotW, plotH, CONFIG, meterInfo, samplePeriod, state.history.length, valueRect, frameRect, ); drawHistoryLine( g, state.history, meterInfo, meterInfo.warnValue, CONFIG, plotX, plotY, plotW, plotH, valueRect, frameRect, !!CONFIG?.PEAK_HISTORY_FILL_ENABLED, !!CONFIG?.PEAK_HISTORY_FILL_INVERT, state.write | 0, ); if (!env.audio?.alive || !meterInfo.hasValue) { drawWaiting(g, plotX, plotY, !env.audio?.alive ? 'Warte auf Audio ...' : 'Slot ohne Daten ...'); } if (showMeterPanel) await drawMeter(env, plotX, plotY, plotW, plotH, meterRects); } function drawCachedHistoryStaticLayer( g, state, x0, y0, w, h, CONFIG, meterInfo, samplePeriod, historyLen, valueRect = null, frameRect = null, ) { const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; const padLeft = gutterL + STATIC_LABEL_PAD_LEFT; const padRight = STATIC_LABEL_PAD_RIGHT; const padBottom = STATIC_LABEL_PAD_BOTTOM; const key = [ Math.round(w), Math.round(h), Math.round(x0), Math.round(y0), padLeft, padRight, padBottom, meterInfo?.signature || '', samplePeriod, historyLen, valueRect?.y ?? y0, valueRect?.h ?? h, frameRect?.y ?? y0, frameRect?.h ?? h, ].join('|'); const needsRebuild = !state.staticLayer || state.staticLayer.key !== key || state.staticLayer.width !== Math.round(w + padLeft + padRight) || state.staticLayer.height !== Math.round((frameRect?.h ?? h) + padBottom); if (needsRebuild) { const canvas = document.createElement('canvas'); const cw = Math.max(1, Math.round(w + padLeft + padRight)); const ch = Math.max(1, Math.round((frameRect?.h ?? h) + padBottom)); canvas.width = cw; canvas.height = ch; const cg = canvas.getContext('2d'); if (cg) { drawHistoryGrid( cg, padLeft, 0, w, h, CONFIG, meterInfo, samplePeriod, historyLen, valueRect ? { ...valueRect, x: padLeft, y: (valueRect.y ?? y0) - y0 } : null, frameRect ? { ...frameRect, y: (frameRect.y ?? y0) - y0 } : null, ); redrawPlotEdges( cg, padLeft, 0, w, h, CONFIG, frameRect ? { ...frameRect, y: (frameRect.y ?? y0) - y0 } : null, ); } state.staticLayer = { key, canvas, width: cw, height: ch }; } g.drawImage(state.staticLayer.canvas, x0 - padLeft, y0); } function drawWaiting(g, plotX, plotY, msg = 'Warte auf Audio ...') { g.save(); g.fillStyle = '#9aa'; g.textAlign = 'left'; const y = plotY >= 40 ? (plotY - 26) : (plotY + 22); g.fillText(msg, plotX + 10, y); g.restore(); } function drawHistoryGrid( g, x0, y0, w, h, CONFIG, meterInfo, samplePeriod, historyLen, valueRect = null, frameRect = null, ) { const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; const gridY = valueRect?.y ?? y0; const gridH = valueRect?.h ?? h; const boxY = frameRect?.y ?? y0; const boxH = frameRect?.h ?? h; const totalSec = Math.max(samplePeriod * Math.max(1, historyLen - 1), samplePeriod); const timeStep = pickTimeStep(totalSec); const toNorm = meterInfo.toNorm || ((v) => { const bottom = meterInfo.range?.bottom ?? -60; const top = meterInfo.range?.top ?? 0; return clamp01((clamp(v, bottom, top) - bottom) / (top - bottom || 1)); }); const yTicks = (Array.isArray(meterInfo.ticks) && meterInfo.ticks.length) ? meterInfo.ticks : buildDefaultTicks(meterInfo.range); g.save(); g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x0 - gutterL, boxY, w + gutterL, boxH); g.font = 'bold 14px ui-monospace, monospace'; g.fillStyle = LABEL_COLOR; g.textAlign = 'right'; let lastLabelY = Number.NaN; const minLabelGap = 14; for (const v of yTicks) { const y = gridY + (1 - toNorm(v)) * gridH; if (!Number.isFinite(y)) continue; g.strokeStyle = GRID_COLOR_MAJOR; g.lineWidth = 1.2; g.beginPath(); g.moveTo(x0, y); g.lineTo(x0 + w, y); g.stroke(); const labelY = y + 4; if (!Number.isFinite(lastLabelY) || Math.abs(labelY - lastLabelY) >= minLabelGap) { g.fillText(String(v), (x0 - gutterL) - 6, labelY); lastLabelY = labelY; } } g.textAlign = 'center'; g.fillStyle = LABEL_COLOR; const labelY = boxY + boxH + 18; const drawnTimeLabels = new Set(); const drawTimeLabel = (t, forceEdge = false) => { const key = Math.round(t * 1000); if (drawnTimeLabels.has(key)) return; drawnTimeLabels.add(key); const x = x0 + w - (t / totalSec) * w; if (x > x0 + 0.75 && x < x0 + w - 0.75) { g.strokeStyle = GRID_COLOR_MINOR; g.lineWidth = 1; g.beginPath(); g.moveTo(x, boxY); g.lineTo(x, boxY + boxH); g.stroke(); } const isNow = t < 1e-3; let label = isNow ? 'now' : `-${formatSeconds(t)}`; if (forceEdge && w <= 320 && !isNow) { label = label.replace(/s$/, ''); } if (isNow) { g.textAlign = 'right'; g.fillText(label, x - 2, labelY); } else if (forceEdge) { g.textAlign = 'left'; g.fillText(label, x + 2, labelY); } else { g.textAlign = 'center'; g.fillText(label, x, labelY); } }; for (let t = 0; t <= totalSec + 1e-6; t += timeStep) { drawTimeLabel(t, false); } drawTimeLabel(totalSec, true); g.restore(); } function drawHistoryLine( g, history, meterInfo, warnValue, CONFIG, x0, y0, w, h, valueRect = null, frameRect = null, fillEnabled = false, fillInvert = false, writeIndex = 0, ) { if (!history || history.length < 2) return; const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; const leftEdgeX = x0 - gutterL; const totalWidth = w + gutterL; const step = totalWidth / Math.max(1, history.length - 1); const lineY = valueRect?.y ?? y0; const lineH = valueRect?.h ?? h; const frameY = frameRect?.y ?? y0; const frameH = frameRect?.h ?? h; const frameBottomY = frameY + frameH; const baseY = fillInvert ? frameY : frameBottomY; const warnRaw = Number.isFinite(warnValue) ? warnValue : null; const rangeBottom = Number.isFinite(meterInfo?.range?.bottom) ? meterInfo.range.bottom : -60; const rangeTop = Number.isFinite(meterInfo?.range?.top) ? meterInfo.range.top : 0; const tickVals = Array.isArray(meterInfo?.ticks) && meterInfo.ticks.length ? meterInfo.ticks.slice() : buildDefaultTicks(meterInfo?.range); const belowBottomSpan = Math.max(1, BELOW_BOTTOM_DB_SPAN); const normFn = typeof meterInfo?.toNorm === 'function' ? (v) => clamp01(meterInfo.toNorm(v)) : (v) => clamp01((v - rangeBottom) / ((rangeTop - rangeBottom) || 1)); const valueToY = (v) => { if (!Number.isFinite(v)) return lineY + lineH; if (v >= rangeBottom) { return lineY + (1 - normFn(v)) * lineH; } const t = clamp01((rangeBottom - v) / belowBottomSpan); return (lineY + lineH) + t * Math.max(0, frameBottomY - (lineY + lineH)); }; const warnNorm = warnRaw !== null ? clamp01(normFn(warnRaw)) : null; const warnY = warnNorm !== null ? lineY + (1 - warnNorm) * lineH : null; const len = history.length | 0; const wi = ((writeIndex | 0) % len + len) % len; const valueAt = (i) => history[(wi + (i | 0)) % len]; const iterateSegments = (cb) => { for (let i = 0; i < len - 1; i++) { const v1 = valueAt(i); const v2 = valueAt(i + 1); const x1 = leftEdgeX + i * step; const x2 = leftEdgeX + (i + 1) * step; const y1 = valueToY(v1); const y2 = valueToY(v2); const over1 = warnRaw !== null && v1 > warnRaw; const over2 = warnRaw !== null && v2 > warnRaw; if (warnRaw === null || warnY === null || over1 === over2) { cb(x1, y1, x2, y2, over1); continue; } const denom = v2 - v1; if (!Number.isFinite(denom) || Math.abs(denom) < 1e-9) { cb(x1, y1, x2, y2, over1); continue; } const t = (warnRaw - v1) / denom; const clampedT = Math.min(1, Math.max(0, t)); const xCross = x1 + clampedT * (x2 - x1); cb(x1, y1, xCross, warnY, over1); cb(xCross, warnY, x2, y2, over2); } }; const strokeSegments = (lineWidth, colorNorm, colorWarn) => { g.lineWidth = lineWidth; let lastColor = null; let pathOpen = false; iterateSegments((x1, y1, x2, y2, red) => { const color = red ? colorWarn : colorNorm; if (color !== lastColor) { if (pathOpen && lastColor !== null) { g.strokeStyle = lastColor; g.stroke(); } g.beginPath(); g.moveTo(x1, y1); pathOpen = true; } g.lineTo(x2, y2); lastColor = color; }); if (pathOpen && lastColor !== null) { g.strokeStyle = lastColor; g.stroke(); } }; g.save(); if (fillEnabled) { const fillNorm = 'rgba(255,224,102,0.18)'; const fillWarn = 'rgba(255,59,59,0.18)'; iterateSegments((x1, y1, x2, y2, red) => { g.beginPath(); g.moveTo(x1, y1); g.lineTo(x2, y2); g.lineTo(x2, baseY); g.lineTo(x1, baseY); g.closePath(); g.fillStyle = red ? fillWarn : fillNorm; g.fill(); }); } strokeSegments(3.4, 'rgba(255,224,102,0.35)', 'rgba(255,59,59,0.35)'); strokeSegments(1.6, MID_COLOR, WARN_COLOR); g.restore(); } function redrawPlotEdges(g, x, y, w, h, CONFIG, frameRect = null) { const edgeY = frameRect?.y ?? y; const edgeH = frameRect?.h ?? h; const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; g.save(); g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.beginPath(); g.moveTo(x - gutterL, edgeY); g.lineTo(x + w, edgeY); g.stroke(); g.beginPath(); g.moveTo(x - gutterL, edgeY + edgeH); g.lineTo(x + w, edgeY + edgeH); g.stroke(); g.restore(); } function computeMeterRects(plotX, plotY, plotW, plotH) { const meterRect = { x: plotX + plotW + METER_GAP, y: plotY, w: METER_WIDTH, h: plotH }; const shrink = Math.max(0, METER_SLOT_SHRINK); const innerHeight = Math.max( 40, meterRect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD, ); const innerOffset = shrink / 2; const innerRect = { x: meterRect.x, y: meterRect.y + METER_PAD_TOP + innerOffset, w: meterRect.w, h: innerHeight, }; return { meterRect, innerRect }; } function computeValueRect(plotX, plotY, plotW, plotH) { const shrink = Math.max(0, METER_SLOT_SHRINK); const innerHeight = Math.max( 40, plotH - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD, ); const innerOffset = shrink / 2; return { x: plotX, y: plotY + METER_PAD_TOP + innerOffset, w: plotW, h: innerHeight, }; } async function drawMeter(env, plotX, plotY, plotW, plotH, meterRects = null) { const { meterRect, innerRect } = meterRects || computeMeterRects(plotX, plotY, plotW, plotH); const { ctx: g, config: CONFIG, meters } = env; g.save(); g.fillStyle = PANEL_BG; g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h); g.restore(); g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; const slotList = env.slots ? env.slots(id) : null; const active = (slotList && slotList[0]) || 'vu'; if (active === 'none') return; g.save(); g.beginPath(); g.rect( meterRect.x + g.lineWidth / 2, meterRect.y + g.lineWidth / 2, meterRect.w - g.lineWidth, meterRect.h - g.lineWidth, ); g.clip(); await meters.draw(g, innerRect, active, CONFIG); g.restore(); g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h); } function resolveMeterInfo(env, state, meterId) { const cfg = env.config || {}; const shared = env.meters?.getState?.(meterId) || null; if (meterId !== 'rms') state.rmsSmooth = null; switch (meterId) { case 'ppm-din': return samplePpmDin(cfg, shared); case 'ppm-ebu': return samplePpmEbu(cfg, shared); case 'tp': return sampleTp(cfg, shared); case 'rms': return sampleRms(cfg, shared, state); case 'lufs': return sampleLufs(cfg, shared); case 'vu': default: return sampleVu(cfg, shared, meterId); } } function sampleVu(cfg, shared) { const bottom = -20; const top = 3; const effOff = Number(cfg?.VU_OFFSET_DB) || 0; const offCorr = effOff - (shared?.offset || 0); const warnVal = Number.isFinite(cfg?.VU_RED_START) ? cfg.VU_RED_START : 0; const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; const val = clamp(Math.max(vL, vR) + offCorr, bottom, top); const toNorm = (db) => deflectionFrac(clamp(db, bottom, top)); const norm = toNorm(val); return finalizeMeterInfo({ id: 'vu', label: METER_LABELS['vu'], value: val, unit: 'VU', norm, toNorm, range: { bottom, top }, ticks: VU_TICKS, signature: ['vu', bottom, top, effOff, shared?.offset || 0].join('|'), hasValue: !!shared, warnValue: warnVal, }); } function samplePpmDin(cfg, shared) { const bottom = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : -50; const top = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : 5; const neutralValue = bottom - 12; const baseMode = (cfg?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9; const effOff = baseMode + (Number(cfg?.PPM_DIN_TRIM_DB) || 0); const offCorr = effOff - (shared?.offset || 0); const warnVal = Number.isFinite(cfg?.PPM_DIN_RED_START) ? cfg.PPM_DIN_RED_START : 0; const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; const val = Math.min(Math.max(vL, vR) + offCorr, top); const toNorm = (db) => { const clamped = clamp(db, bottom, top); const pos = interpolateScale(DIN_SCALE, clamped); const normBottom = interpolateScale(DIN_SCALE, bottom); const span = Math.max(1e-6, 1 - normBottom); return clamp01((pos - normBottom) / span); }; const norm = toNorm(val); return finalizeMeterInfo({ id: 'ppm-din', label: METER_LABELS['ppm-din'], value: val, unit: 'dB', norm, toNorm, range: { bottom, top }, ticks: PPM_DIN_TICKS, signature: ['ppm-din', bottom, top, effOff, shared?.offset || 0].join('|'), hasValue: !!shared, warnValue: warnVal, neutralValue, }); } function samplePpmEbu(cfg, shared) { const bottom = Number.isFinite(cfg?.PPM_EBU_BOTTOM) ? cfg.PPM_EBU_BOTTOM : -12; const top = Number.isFinite(cfg?.PPM_EBU_TOP) ? cfg.PPM_EBU_TOP : +14; const effOff = Number(cfg?.PPM_EBU_OFFSET) || 0; const offCorr = effOff - (shared?.offset || 0); const warnVal = Number.isFinite(cfg?.PPM_EBU_RED_START) ? cfg.PPM_EBU_RED_START : 9; const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; const val = clamp(Math.max(vL, vR) + offCorr, bottom, top); const toNorm = (db) => { const c = clamp(db, bottom, top); return clamp01((c - bottom) / (top - bottom || 1)); }; const norm = toNorm(val); return finalizeMeterInfo({ id: 'ppm-ebu', label: METER_LABELS['ppm-ebu'], value: val, unit: 'dB', norm, toNorm, range: { bottom, top }, ticks: PPM_EBU_TICKS, signature: ['ppm-ebu', bottom, top, effOff, shared?.offset || 0].join('|'), hasValue: !!shared, warnValue: warnVal, }); } function sampleTp(cfg, shared) { const bottom = TP_PERCENT_SCALE[0].db; const top = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db; const effOff = Number(cfg?.TP_OFFSET_DB) || 0; const offCorr = effOff - (shared?.offset || 0); const warnVal = Number.isFinite(cfg?.TP_RED_START) ? cfg.TP_RED_START : -1; const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; const val = clamp(Math.max(vL, vR) + offCorr, bottom, top); const toNorm = (db) => interpolateTp(clamp(db, bottom, top)); const norm = toNorm(val); return finalizeMeterInfo({ id: 'tp', label: METER_LABELS['tp'], value: val, unit: 'dBTP', norm, toNorm, range: { bottom, top }, ticks: TP_TICKS, signature: ['tp', bottom, top, effOff, shared?.offset || 0].join('|'), hasValue: !!shared, warnValue: warnVal, }); } function sampleRms(cfg, shared, state) { const mode = String(cfg?.RMS_MODE || 'dbfs').toLowerCase(); const isDBU = mode === 'dbu'; const bottom = -60; const top = isDBU ? 24 : 0; const refDbfs = Number.isFinite(cfg?.RMS_REF_DBFS_FOR_REF_DBU) ? cfg.RMS_REF_DBFS_FOR_REF_DBU : -18; const refDbu = Number.isFinite(cfg?.RMS_REF_DBU) ? cfg.RMS_REF_DBU : 0; const warnVal = Number.isFinite(cfg?.RMS_RED_START) ? cfg.RMS_RED_START : (isDBU ? 20 : 0); const vL = Number.isFinite(shared?.values?.L) ? shared.values.L : bottom; const vR = Number.isFinite(shared?.values?.R) ? shared.values.R : bottom; const base = Math.max(vL, vR); const smoothed = applyRmsSmoothing(state, base, cfg); const display = isDBU ? (smoothed - refDbfs + refDbu) : smoothed; const val = clamp(display, bottom, top); const toNorm = (db) => { const c = clamp(db, bottom, top); if (isDBU) { if (c <= bottom) return 0; if (c <= -20) { const t = (c + 60) / 40; return Math.pow(t, 1.6) * 0.45; } if (c <= 0) { const t = (c + 20) / 20; return 0.45 + t * 0.25; } if (c <= 20) { const t = (c) / 20; return 0.70 + t * 0.25; } if (c <= 24) { const t = (c - 20) / 4; return 0.95 + t * 0.05; } return 1; } return clamp01((c - bottom) / (top - bottom || 1)); }; const norm = toNorm(val); return finalizeMeterInfo({ id: 'rms', label: METER_LABELS['rms'], value: val, unit: isDBU ? 'dBu' : 'dBFS', norm, toNorm, range: { bottom, top }, ticks: isDBU ? RMS_DBU_TICKS : RMS_DBFS_TICKS, signature: ['rms', mode, refDbfs, refDbu, cfg?.RMS_TC_MODE, cfg?.RMS_TC_MS].join('|'), hasValue: !!shared, warnValue: warnVal, }); } function applyRmsSmoothing(state, value, cfg) { const tauMs = resolveRmsTau(cfg); if (!Number.isFinite(tauMs) || tauMs <= 0) { state.rmsSmooth = null; return value; } const now = performance.now(); if (!state.rmsSmooth || !Number.isFinite(state.rmsSmooth.value)) { state.rmsSmooth = { value, lastTs: now }; return value; } const dt = Math.max(1 / 240, (now - (state.rmsSmooth.lastTs || now)) / 1000); state.rmsSmooth.lastTs = now; const alpha = 1 - Math.exp(-dt / (tauMs / 1000)); state.rmsSmooth.value += alpha * (value - state.rmsSmooth.value); return state.rmsSmooth.value; } function resolveRmsTau(cfg = {}) { const mode = String(cfg.RMS_TC_MODE || 'fast').toLowerCase(); const custom = Number(cfg.RMS_TC_MS); if (Number.isFinite(custom) && custom > 0) return custom; switch (mode) { case 'impulse': return 35; case 'slow': return 1000; case 'none': return 0; case 'fast': default: return 125; } } function sampleLufs(cfg, shared) { const bottom = -50; const top = -5; const raw = Number.isFinite(shared?.shortTerm) ? shared.shortTerm : Number.isFinite(shared?.momentary) ? shared.momentary : Number.isFinite(shared?.integ) ? shared.integ : bottom; const warnVal = Number.isFinite(cfg?.LUFS_RED_START) ? cfg.LUFS_RED_START : -1; const val = clamp(raw, bottom, top); const toNorm = (v) => { const c = clamp(v, bottom, top); return clamp01((c - bottom) / (top - bottom || 1)); }; const norm = toNorm(val); return finalizeMeterInfo({ id: 'lufs', label: METER_LABELS['lufs'], value: val, unit: 'LUFS', norm, toNorm, range: { bottom, top }, ticks: LUFS_TICKS, signature: ['lufs', bottom, top].join('|'), hasValue: !!shared, warnValue: warnVal, }); } function finalizeMeterInfo(info) { const neutral = clamp01(info.toNorm ? info.toNorm(info.range?.bottom ?? 0) : 0); const warnNorm = (info.toNorm && Number.isFinite(info.warnValue)) ? clamp01(info.toNorm(info.warnValue)) : null; return Object.assign( { hasValue: typeof info.hasValue === 'boolean' ? info.hasValue : Number.isFinite(info?.norm), neutralNorm: neutral, neutralValue: Number.isFinite(info.neutralValue) ? info.neutralValue : (info.range?.bottom ?? 0), signature: info.signature || info.id || 'meter', label: info.label || 'Meter', unit: info.unit || 'dB', ticks: info.ticks || [], range: info.range || { bottom: 0, top: 1 }, warnValue: Number.isFinite(info.warnValue) ? info.warnValue : null, warnNorm, }, info, ); } function interpolateScale(table, db) { if (!Array.isArray(table) || !table.length) return 0; if (db <= table[0].db) return table[0].pos ?? table[0].frac ?? 0; if (db >= table[table.length - 1].db) return table[table.length - 1].pos ?? table[table.length - 1].frac ?? 1; for (let i = 1; i < table.length; i++) { const prev = table[i - 1]; const curr = table[i]; if (db <= curr.db) { const span = curr.db - prev.db || 1; const t = (db - prev.db) / span; const start = prev.pos ?? prev.frac ?? 0; const end = curr.pos ?? curr.frac ?? 1; return start + t * (end - start); } } return table[table.length - 1].pos ?? table[table.length - 1].frac ?? 1; } function interpolateTp(db) { return interpolateScale(TP_PERCENT_SCALE, db); } function deflectionFrac(db) { return interpolateScale(VU_DEFLECTION, db); } function resolveHistoryScrollFactor(CONFIG) { const val = Number(CONFIG?.PEAK_HISTORY_SCROLL_MODE ?? 1); const allowed = [0.5, 1, 2, 4, 6]; return allowed.includes(val) ? val : 1; } function resolveHistorySamplePeriod(CONFIG) { const factor = resolveHistoryScrollFactor(CONFIG); const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1; return SAMPLE_PERIOD / safeFactor; } function clampHistorySamples(plotW) { // Halte den sichtbaren Zeitbereich konsistent: MIN/MAX Sekunden bleiben // an der Basis-Sample-Periode ausgerichtet, während die Scroll-Geschwindigkeit // über die effektive Sample-Periode (SAMPLE_PERIOD / factor) die Zeitachse // streckt oder staucht. So differenziert sich die Zeitskala bei langsam/normal/schnell. const basePeriod = SAMPLE_PERIOD; const minSamples = Math.round(MIN_HISTORY_SEC / basePeriod); const maxSamples = Math.round(MAX_HISTORY_SEC / basePeriod); const target = Math.max(minSamples, Math.round(plotW)); return Math.max(minSamples, Math.min(maxSamples, target)); } function resizeHistory(state, length, fillValue) { const safeFill = Number.isFinite(fillValue) ? fillValue : 0; if (!state.history || state.history.length === 0) { state.history = new Float32Array(length); state.history.fill(safeFill); state.write = 0; return; } if (state.history.length === length) return; const old = state.history; const oldLen = old.length | 0; const copyCount = Math.min(oldLen, length); const newArr = new Float32Array(length); newArr.fill(safeFill); // Keep newest values (old array is a ring; state.write points to oldest/next overwrite). const oldWrite = ((state.write | 0) % oldLen + oldLen) % oldLen; const valueAtChrono = (i) => old[(oldWrite + (i | 0)) % oldLen]; const start = Math.max(0, oldLen - copyCount); for (let i = 0; i < copyCount; i++) { newArr[length - copyCount + i] = valueAtChrono(start + i); } state.history = newArr; state.write = 0; } function resetHistory(state, fillValue) { const safeFill = Number.isFinite(fillValue) ? fillValue : 0; if (!state.history || state.history.length === 0) return; state.history.fill(safeFill); state.accumDt = 0; state.write = 0; } function pushSample(state, value) { if (!state.history || state.history.length === 0) return; const v = Number.isFinite(value) ? value : state.neutralValue || 0; const len = state.history.length | 0; const wi = ((state.write | 0) % len + len) % len; state.history[wi] = v; state.write = (wi + 1) % len; } function buildDefaultTicks(range = { bottom: -60, top: 0 }) { const top = Number.isFinite(range.top) ? range.top : 0; const bottom = Number.isFinite(range.bottom) ? range.bottom : -60; const span = top - bottom; const step = span > 40 ? 10 : 6; const ticks = []; for (let v = bottom; v <= top + 1e-6; v += step) ticks.push(Math.round(v)); if (!ticks.includes(top)) ticks.push(top); return ticks; } function pickTimeStep(totalSec) { const preferred = 6; const rough = totalSec / preferred; const pow = Math.pow(10, Math.floor(Math.log10(Math.max(rough, 0.001)))); const candidates = [1, 2, 5, 10, 20].map((c) => c * pow); let best = candidates[0]; let bestDiff = Infinity; for (const step of candidates) { if (step <= 0) continue; const lines = totalSec / step; const diff = Math.abs(lines - preferred); if (diff < bestDiff && lines >= 2) { best = step; bestDiff = diff; } } return best || rough || totalSec || 1; } function formatSeconds(sec) { if (!Number.isFinite(sec)) return '0s'; if (sec >= 9.95) return `${Math.round(sec)}s`; if (Math.abs(sec - Math.round(sec)) < 1e-3) return `${Math.round(sec)}s`; if (sec >= 10) return `${Math.round(sec)}s`; if (sec >= 1) return `${sec.toFixed(1)}s`; return `${Math.round(sec * 1000)}ms`; } function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); } function clamp01(v) { if (!Number.isFinite(v)) return 0; return Math.min(1, Math.max(0, v)); }