Files
Phoenix/www/views/peak_history.js
T

965 lines
30 KiB
JavaScript

// 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';
import { TRUE_PEAK_SCALE } from '../meters/true_peak_scale.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 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, 3, 6];
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,
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;
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);
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 = TRUE_PEAK_SCALE[0].db;
const top = TRUE_PEAK_SCALE[TRUE_PEAK_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) {
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 offset = Number.isFinite(cfg?.RMS_OFFSET_DB) ? cfg.RMS_OFFSET_DB : 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) + offset;
const display = isDBU ? (base - refDbfs + refDbu) : base;
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, offset, cfg?.RMS_TC_MODE].join('|'),
hasValue: !!shared,
warnValue: warnVal,
});
}
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(TRUE_PEAK_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));
}