1011 lines
31 KiB
JavaScript
1011 lines
31 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';
|
|
|
|
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));
|
|
}
|