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