Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'hifi-peak';
|
||||
|
||||
const HIFI_BOTTOM = -20;
|
||||
const HIFI_TOP = 6;
|
||||
const HIFI_RED_START = 3;
|
||||
const DEFAULT_ATTACK_MS = 8;
|
||||
const DEFAULT_RELEASE_DB_PER_S = 26;
|
||||
const DEFAULT_HOLD_MS = 320;
|
||||
const DEFAULT_HOLD_DECAY_DB_PER_S = 40;
|
||||
const MAJOR_TICKS = [-20, -10, -5, 0, 3, 6];
|
||||
const MINOR_TICKS = [-15, -8, -7, -6, -4, -3, -2, -1, 1, 2, 4, 5];
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const now = performance.now();
|
||||
const refDbfsFor0 = resolveHifiRefDbfsFor0(CONFIG);
|
||||
return {
|
||||
target: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
||||
values: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
||||
hold: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
||||
refDbfsFor0,
|
||||
lastTs: now,
|
||||
_holdState: {
|
||||
L: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS),
|
||||
R: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
if (!packet || !shared) return;
|
||||
const now = performance.now();
|
||||
const rawL = Number.isFinite(packet.tpL) ? packet.tpL : -120;
|
||||
const rawR = Number.isFinite(packet.tpR) ? packet.tpR : -120;
|
||||
const refDbfsFor0 = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -14;
|
||||
const deckL = rawL - refDbfsFor0;
|
||||
const deckR = rawR - refDbfsFor0;
|
||||
const targetL = clamp(deckL, HIFI_BOTTOM, HIFI_TOP);
|
||||
const targetR = clamp(deckR, HIFI_BOTTOM, HIFI_TOP);
|
||||
const dt = Math.max(1 / 240, (now - (shared.lastTs || now)) / 1000);
|
||||
shared.lastTs = now;
|
||||
|
||||
const attackTau = Math.max(0.001, DEFAULT_ATTACK_MS / 1000);
|
||||
const attackAlpha = 1 - Math.exp(-dt / attackTau);
|
||||
const releaseStep = DEFAULT_RELEASE_DB_PER_S * dt;
|
||||
|
||||
shared.target.L = targetL;
|
||||
shared.target.R = targetR;
|
||||
shared.values.L = applyBallistics(shared.values.L, targetL, attackAlpha, releaseStep);
|
||||
shared.values.R = applyBallistics(shared.values.R, targetR, attackAlpha, releaseStep);
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
if (!g || !rect || !shared) return;
|
||||
|
||||
syncAlignment(shared, CONFIG);
|
||||
|
||||
const colNorm = CONFIG.TP_COLOR_NORMAL || MID_COLOR;
|
||||
const colWarn = CONFIG.TP_COLOR_WARN || WARN_COLOR;
|
||||
const redOnly = CONFIG.TP_RED_BAR_ONLY !== false;
|
||||
const mapY = (db) => rect.y + (1 - norm(db)) * rect.h;
|
||||
|
||||
const innerPad = 8;
|
||||
const scaleW = 28;
|
||||
const gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(10, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
const rawL = clamp(shared.values.L, HIFI_BOTTOM, HIFI_TOP);
|
||||
const rawR = clamp(shared.values.R, HIFI_BOTTOM, HIFI_TOP);
|
||||
const smooth = smoothHeader(shared, rawL, rawR);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = (rawL > HIFI_RED_START || rawR > HIFI_RED_START) ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
const yText = rect.y - 12;
|
||||
g.fillStyle = rawL > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(formatHeaderValue(smooth.L), centerLeft, yText);
|
||||
g.fillStyle = colNorm;
|
||||
g.fillText('|', centerX, yText);
|
||||
g.fillStyle = rawR > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.fillText(formatHeaderValue(smooth.R), centerRight, yText);
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
drawBar(g, leftX, innerW, rawL, mapY, colNorm, colWarn, redOnly);
|
||||
drawBar(g, rightX, innerW, rawR, mapY, colNorm, colWarn, redOnly);
|
||||
|
||||
const now = performance.now();
|
||||
const holdOpts = {
|
||||
holdMs: DEFAULT_HOLD_MS,
|
||||
decayDbPerS: DEFAULT_HOLD_DECAY_DB_PER_S,
|
||||
floor: HIFI_BOTTOM,
|
||||
riseThreshold: 0.15,
|
||||
};
|
||||
shared.hold.L = stepPeakHold(shared.values.L, shared._holdState.L, now, holdOpts);
|
||||
shared.hold.R = stepPeakHold(shared.values.R, shared._holdState.R, now, holdOpts);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
|
||||
const yTop = mapY(HIFI_TOP);
|
||||
const yRed = mapY(HIFI_RED_START);
|
||||
drawWarningEdges(g, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
||||
drawBarTicks(g, leftX, rightX, innerW, mapY, colWarn);
|
||||
drawScale(g, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
const baseY = rect.y + rect.h + 16;
|
||||
g.fillText('L', leftX + barW / 2, baseY);
|
||||
g.fillText('R', rightX + barW / 2, baseY);
|
||||
const prevFooterFont = g.font;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.font = 'bold 8.4px ui-monospace, monospace';
|
||||
g.fillText('dB', centerX, baseY);
|
||||
g.font = prevFooterFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function applyBallistics(current, target, attackAlpha, releaseStep) {
|
||||
const prev = Number.isFinite(current) ? current : HIFI_BOTTOM;
|
||||
if (target >= prev) {
|
||||
return clamp(prev + (target - prev) * attackAlpha, HIFI_BOTTOM, HIFI_TOP);
|
||||
}
|
||||
return clamp(Math.max(target, prev - releaseStep), HIFI_BOTTOM, HIFI_TOP);
|
||||
}
|
||||
|
||||
function drawBar(g, x, width, value, mapY, colNorm, colWarn, redOnly) {
|
||||
const yVal = mapY(value);
|
||||
const yFloor = mapY(HIFI_BOTTOM);
|
||||
const yRed = mapY(HIFI_RED_START);
|
||||
|
||||
if (value > HIFI_RED_START) {
|
||||
if (redOnly) {
|
||||
const normTop = Math.min(yRed, yFloor);
|
||||
if (yFloor - normTop > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x + 1, normTop, width, yFloor - normTop);
|
||||
}
|
||||
const warnTop = Math.min(yVal, yRed);
|
||||
if (yRed - warnTop > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x + 1, warnTop, width, yRed - warnTop);
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
|
||||
g.globalAlpha = 0.12;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x + 1, yVal, width, 2);
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawScale(g, rect, centerX, mapY) {
|
||||
drawHairlineGrid(g, rect, mapY, HIFI_TOP, HIFI_BOTTOM, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.strokeStyle = 'rgba(143, 211, 212, 0.22)';
|
||||
g.lineWidth = 1;
|
||||
for (const db of MINOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
g.beginPath();
|
||||
g.moveTo(centerX - 4, y);
|
||||
g.lineTo(centerX + 4, y);
|
||||
g.stroke();
|
||||
}
|
||||
for (const db of MAJOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
g.beginPath();
|
||||
g.moveTo(centerX - 8, y);
|
||||
g.lineTo(centerX + 8, y);
|
||||
g.stroke();
|
||||
}
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
for (const db of MAJOR_TICKS) {
|
||||
const y = mapY(db);
|
||||
const label = db > 0 ? `+${db}` : `${db}`;
|
||||
g.fillText(label, centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop);
|
||||
const y2 = Math.max(yWarn, yTop);
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawBarTicks(g, leftX, rightX, widthPx, mapY, warnColor) {
|
||||
if (!g) return;
|
||||
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
const defaultColor = 'rgb(0,0,255)';
|
||||
const majorInset = 2;
|
||||
const minorFrac = 0.45;
|
||||
const majorWidth = Math.max(1, widthPx - 4 * majorInset);
|
||||
const minorWidth = Math.max(1, Math.floor(widthPx * minorFrac));
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawPair = (yPix, width, color = defaultColor) => {
|
||||
g.strokeStyle = color;
|
||||
const x1L = Math.round(cxL - width / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
const x1R = Math.round(cxR - width / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
for (const db of MAJOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
const color = db === HIFI_RED_START ? warnColor : defaultColor;
|
||||
drawPair(y, majorWidth, color);
|
||||
}
|
||||
for (const db of MINOR_TICKS) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
drawPair(y, minorWidth, defaultColor);
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function formatHeaderValue(v) {
|
||||
const n = Number.isFinite(v) ? v : HIFI_BOTTOM;
|
||||
const sign = n >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(n).toFixed(1)}`;
|
||||
}
|
||||
|
||||
function resolveHifiRefDbfsFor0(CONFIG = {}) {
|
||||
const base = Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||||
: -15;
|
||||
const modeOffset = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
||||
const trim = Number(CONFIG.PPM_DIN_TRIM_DB) || 0;
|
||||
const effOff = modeOffset + trim;
|
||||
const ppmDin0Dbfs = base - effOff;
|
||||
return (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero')
|
||||
? ppmDin0Dbfs
|
||||
: (ppmDin0Dbfs - 5);
|
||||
}
|
||||
|
||||
function syncAlignment(shared, CONFIG = {}) {
|
||||
const nextRef = resolveHifiRefDbfsFor0(CONFIG);
|
||||
const prevRef = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : nextRef;
|
||||
const shift = prevRef - nextRef;
|
||||
if (!Number.isFinite(shift) || Math.abs(shift) < 1e-6) {
|
||||
shared.refDbfsFor0 = nextRef;
|
||||
return;
|
||||
}
|
||||
|
||||
shared.refDbfsFor0 = nextRef;
|
||||
shared.target.L = clamp((shared.target.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.target.R = clamp((shared.target.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.values.L = clamp((shared.values.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.values.R = clamp((shared.values.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.hold.L = clamp((shared.hold.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared.hold.R = clamp((shared.hold.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
if (shared._holdState?.L) shared._holdState.L.value = clamp((shared._holdState.L.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
if (shared._holdState?.R) shared._holdState.R.value = clamp((shared._holdState.R.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
if (shared._header) {
|
||||
shared._header.L = clamp((shared._header.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
shared._header.R = clamp((shared._header.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
||||
}
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
|
||||
function norm(db) {
|
||||
const clamped = clamp(db, HIFI_BOTTOM, HIFI_TOP);
|
||||
return (clamped - HIFI_BOTTOM) / (HIFI_TOP - HIFI_BOTTOM);
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
|
||||
// meters/lufs.js — vereinfachtes LUFS-Display (Integrated, Momentary, Short-term) + LRA & TP
|
||||
// Hinweis: Der Audio-Worklet liefert aktuell keine echten LUFS-Filterausgänge.
|
||||
// Wir nähern LUFS über dBFS (RMS) an und integrieren per EMA:
|
||||
// - Momentary ≈ 400 ms
|
||||
// - Short-term ≈ 3 s
|
||||
// - Integrated = langsame EMA (ohne Gate)
|
||||
// TruePeak nehmen wir aus packet.tpL/tpR (dBFS), LRA aus Short-term vs. Momentary.
|
||||
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { LABEL_COLOR, OK_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'lufs';
|
||||
|
||||
export function initShared(CONFIG) {
|
||||
const now = performance.now();
|
||||
return {
|
||||
integ: -23.0,
|
||||
momentary: -23.0,
|
||||
shortTerm: -23.0,
|
||||
lra: 0.0,
|
||||
truePeak: -60.0,
|
||||
tpInstant: -60.0,
|
||||
_tpHoldState: createPeakHoldState(-60, now, CONFIG?.TP_HOLD_MS ?? CONFIG?.VU_HOLD_MS ?? 1000),
|
||||
_lastTs: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
// Prefer true LUFS from packet (computed in audio.js via K-weighted analysers)
|
||||
if (Number.isFinite(packet.lufsM)) shared.momentary = packet.lufsM;
|
||||
if (Number.isFinite(packet.lufsS)) shared.shortTerm = packet.lufsS;
|
||||
if (Number.isFinite(packet.lufsI)) shared.integ = packet.lufsI;
|
||||
if (Number.isFinite(packet.lra)) shared.lra = packet.lra;
|
||||
|
||||
// True Peak hold from packet
|
||||
if (Number.isFinite(packet.tpL) || Number.isFinite(packet.tpR)) {
|
||||
const tpL = Number.isFinite(packet.tpL) ? packet.tpL : -60;
|
||||
const tpR = Number.isFinite(packet.tpR) ? packet.tpR : -60;
|
||||
shared.tpInstant = Math.max(tpL, tpR);
|
||||
}
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG, shared) {
|
||||
// Skala: LUFS (negativ). Wir verwenden Bereich [-50 .. -5]
|
||||
const LUFS_TOP = -5, LUFS_BOTTOM = -50;
|
||||
const mapY = (lufs) => {
|
||||
const v = Math.max(LUFS_BOTTOM, Math.min(LUFS_TOP, lufs));
|
||||
const t = (v - LUFS_BOTTOM) / (LUFS_TOP - LUFS_BOTTOM);
|
||||
return rect.y + (1 - t) * rect.h;
|
||||
};
|
||||
|
||||
// 3 Spalten (Momentary, Short-term, Integrated)
|
||||
const innerPad = 8, gap = 10;
|
||||
const colW = Math.floor((rect.w - innerPad * 2 - gap * 2) / 3);
|
||||
const xM = rect.x + innerPad;
|
||||
const xS = xM + colW + gap;
|
||||
const xI = xS + colW + gap;
|
||||
|
||||
// Bars zeichnen
|
||||
drawLufsBar(g, { x: xM, y: rect.y, w: colW, h: rect.h }, shared.momentary, 'M', CONFIG, mapY);
|
||||
drawLufsBar(g, { x: xS, y: rect.y, w: colW, h: rect.h }, shared.shortTerm, 'S', CONFIG, mapY);
|
||||
drawLufsBar(g, { x: xI, y: rect.y, w: colW, h: rect.h }, shared.integ, 'I', CONFIG, mapY);
|
||||
|
||||
// True Peak hold smoothing
|
||||
const now = performance.now();
|
||||
const holdMs = Number.isFinite(CONFIG.TP_HOLD_MS) ? CONFIG.TP_HOLD_MS : (CONFIG.VU_HOLD_MS ?? 1000);
|
||||
const decay = Number.isFinite(CONFIG.TP_DECAY_DB_PER_S) ? CONFIG.TP_DECAY_DB_PER_S : 20;
|
||||
if (!shared._tpHoldState) {
|
||||
shared._tpHoldState = createPeakHoldState(shared.truePeak ?? -60, now, holdMs);
|
||||
}
|
||||
const tpInstant = Number.isFinite(shared.tpInstant) ? shared.tpInstant : -60;
|
||||
const tpValue = stepPeakHold(tpInstant, shared._tpHoldState, now, {
|
||||
holdMs,
|
||||
decayDbPerS: decay,
|
||||
floor: -60,
|
||||
riseThreshold: 0.1,
|
||||
});
|
||||
shared.truePeak = tpValue;
|
||||
|
||||
// Zusatz-Infos unterhalb
|
||||
// g.save();
|
||||
// g.fillStyle = '#8fd3d4';
|
||||
// g.textAlign = 'left';
|
||||
// g.fillText(`LRA: ${shared.lra.toFixed(1)} LU`, rect.x, rect.y + rect.h + 20);
|
||||
// g.fillText(`TP: ${shared.truePeak.toFixed(1)} dBFS`, rect.x, rect.y + rect.h + 38);
|
||||
// g.restore();
|
||||
|
||||
drawLufsStaticOverlay(g, shared, rect, CONFIG, { xM, xS, xI, colW }, mapY);
|
||||
}
|
||||
|
||||
function drawLufsStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
|
||||
const { xM, xS, xI, colW } = geom;
|
||||
const topPad = 10;
|
||||
const layerX = rect.x;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w;
|
||||
const layerH = rect.h + topPad;
|
||||
const scaleCol = CONFIG?.LUFS_SCALE_LABEL_COLOR || LABEL_COLOR;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
scaleCol,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'lufs-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
const drawRefs = (xBase) => {
|
||||
const col = { x: xBase, y: rect.y, w: colW, h: rect.h };
|
||||
for (const v of [-23, -18, -10, -5]) {
|
||||
const y = mapY(v);
|
||||
const isMajor = (v === -23 || v === -18 || v === -10);
|
||||
cg.save();
|
||||
cg.strokeStyle = scaleCol;
|
||||
cg.globalAlpha = isMajor ? 1 : 0.28;
|
||||
cg.setLineDash(isMajor ? [] : [2,2]);
|
||||
cg.beginPath(); cg.moveTo(col.x, y); cg.lineTo(col.x + col.w, y); cg.stroke();
|
||||
if (isMajor) {
|
||||
cg.globalAlpha = 1;
|
||||
cg.fillStyle = scaleCol; cg.textAlign = 'center';
|
||||
cg.fillText(`${v}`, col.x + col.w / 2, y - 4);
|
||||
}
|
||||
cg.restore();
|
||||
}
|
||||
};
|
||||
drawRefs(xM); drawRefs(xS); drawRefs(xI);
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function drawLufsBar(g, rect, value, label, CONFIG, mapY) {
|
||||
const yVal = mapY(value);
|
||||
const yFloor = mapY(-50);
|
||||
const color = getLufsBarColor(label, CONFIG);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.fillRect(rect.x + 1, yVal, Math.max(2, rect.w - 2), Math.max(0, yFloor - yVal));
|
||||
|
||||
g.fillStyle = '#ffffff';
|
||||
g.textAlign = 'center';
|
||||
g.fillText(label, rect.x + rect.w / 2, rect.y - 10);
|
||||
g.fillText(value.toFixed(1), rect.x + rect.w / 2, rect.y + rect.h + 18);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function getLufsBarColor(label, CONFIG = {}) {
|
||||
const fallback = CONFIG.LUFS_COLOR_GREEN || OK_COLOR;
|
||||
if (label === 'I') return CONFIG.LUFS_COLOR_I || CONFIG.LUFS_COLOR_I_GREEN || fallback;
|
||||
if (label === 'M') return CONFIG.LUFS_COLOR_M || CONFIG.LUFS_COLOR_M_GREEN || fallback;
|
||||
if (label === 'S') return CONFIG.LUFS_COLOR_S || CONFIG.LUFS_COLOR_S_GREEN || fallback;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
// meters/ppm_din.js — Peak Programme Meter, DIN Scale (IEC 60268-10 Type I)
|
||||
// Quasi-PPM mit optionalem Peak-Hold, DIN-Skala und bestehendem Farbschema.
|
||||
|
||||
export const id = 'ppm-din';
|
||||
|
||||
const DEFAULT_DECAY_DB_PER_S = 20 / 1.7; // 20 dB in ca. 1.7 s → ~11.76 dB/s
|
||||
const DEFAULT_HOLD_MS = 1000;
|
||||
|
||||
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 PERCENT_MARKS = [
|
||||
{ label: '1', db: -40 },
|
||||
{ label: '10', db: -20 },
|
||||
{ label: '50', db: -6 },
|
||||
{ label: '100', db: 0 },
|
||||
{ label: '180', db: +5 },
|
||||
];
|
||||
|
||||
const MAJOR_TICKS_DB = [-50, -40, -30, -20, -10, -5, 0, +5];
|
||||
const MINOR_TICKS = [
|
||||
{ db: -35 },
|
||||
{ db: -25 },
|
||||
{ db: -21, color: 'warn' },
|
||||
{ db: -15 },
|
||||
{ db: -9 },
|
||||
{ db: -8 },
|
||||
{ db: -7 },
|
||||
{ db: -6 },
|
||||
{ db: -4 },
|
||||
{ db: -3 },
|
||||
{ db: -2 },
|
||||
{ db: -1 },
|
||||
{ db: +1 },
|
||||
{ db: +2 },
|
||||
{ db: +3 },
|
||||
{ db: +4 },
|
||||
];
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const now = performance.now();
|
||||
const bottom = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS) ? CONFIG.PPM_DIN_HOLD_MS : DEFAULT_HOLD_MS;
|
||||
return {
|
||||
values: { L: bottom, R: bottom },
|
||||
hold: { L: bottom, R: bottom },
|
||||
loudnessDbfs: { L: null, R: null },
|
||||
_loudSmooth: null,
|
||||
_env: {
|
||||
L_dbfs: -90,
|
||||
R_dbfs: -90,
|
||||
lastTs: now,
|
||||
},
|
||||
_holdState: {
|
||||
L: createPeakHoldState(bottom, now, holdMs),
|
||||
R: createPeakHoldState(bottom, now, holdMs),
|
||||
},
|
||||
_holdCfg: {
|
||||
holdMs,
|
||||
decayDbPerS: Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S
|
||||
: (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S),
|
||||
},
|
||||
// Offset for DIN uses PPM_DIN_OFFSET so that 0 dB on the meter aligns
|
||||
// properly with the Permitted Maximum Level (PML). According to the
|
||||
// DIN Type I specification, 0 dBu (≈−15 dBFS peak) should read −9 dB.
|
||||
offset: Number(CONFIG.PPM_DIN_OFFSET) || 0,
|
||||
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||||
: -15,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const inL = Number.isFinite(packet?.ppmDinL) ? packet.ppmDinL : (Number.isFinite(packet?.ppmL) ? packet.ppmL : -90);
|
||||
const inR = Number.isFinite(packet?.ppmDinR) ? packet.ppmDinR : (Number.isFinite(packet?.ppmR) ? packet.ppmR : -90);
|
||||
const ppmBoxL = Number.isFinite(packet?.ppmBoxL) ? packet.ppmBoxL : null;
|
||||
const ppmBoxR = Number.isFinite(packet?.ppmBoxR) ? packet.ppmBoxR : null;
|
||||
shared._loudIsBox = Number.isFinite(ppmBoxL) || Number.isFinite(ppmBoxR);
|
||||
const fallbackLoud = Number.isFinite(packet?.lufsM)
|
||||
? packet.lufsM
|
||||
: Number.isFinite(packet?.lufsS)
|
||||
? packet.lufsS
|
||||
: Number.isFinite(packet?.lufsI)
|
||||
? packet.lufsI
|
||||
: null;
|
||||
const loudL = Number.isFinite(ppmBoxL)
|
||||
? ppmBoxL
|
||||
: (Number.isFinite(packet?.lufsML) ? packet.lufsML : fallbackLoud);
|
||||
const loudR = Number.isFinite(ppmBoxR)
|
||||
? ppmBoxR
|
||||
: (Number.isFinite(packet?.lufsMR) ? packet.lufsMR : fallbackLoud);
|
||||
|
||||
const base = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||||
const off = shared.offset || 0;
|
||||
// Verwende Worklet-Pegel direkt; Ballistik liegt bereits im AudioWorklet.
|
||||
shared._env.L_dbfs = inL;
|
||||
shared._env.R_dbfs = inR;
|
||||
const now = performance.now();
|
||||
shared._env.lastTs = now;
|
||||
shared.values.L = (inL - base) + off;
|
||||
shared.values.R = (inR - base) + off;
|
||||
|
||||
// Smoothe Loudness pro Kanal (LUFS M bevorzugt) für ruhige Overlay-Bewegung.
|
||||
if (!shared._loudSmooth) {
|
||||
shared._loudSmooth = {
|
||||
L: Number.isFinite(loudL) ? loudL : null,
|
||||
R: Number.isFinite(loudR) ? loudR : null,
|
||||
lastTs: now,
|
||||
};
|
||||
}
|
||||
const dtL = Math.max(1e-3, (now - (shared._loudSmooth.lastTs || now)) / 1000);
|
||||
const tau = 0.18; // ~180 ms Gleitzeit
|
||||
const alphaL = 1 - Math.exp(-dtL / tau);
|
||||
|
||||
const smoothVal = (prev, target) => {
|
||||
if (!Number.isFinite(target)) return { out: null, prev: prev };
|
||||
const baseVal = Number.isFinite(prev) ? prev : target;
|
||||
const out = baseVal + alphaL * (target - baseVal);
|
||||
return { out, prev: out };
|
||||
};
|
||||
|
||||
const resL = smoothVal(shared._loudSmooth.L, loudL);
|
||||
const resR = smoothVal(shared._loudSmooth.R, loudR);
|
||||
shared._loudSmooth.L = resL.prev;
|
||||
shared._loudSmooth.R = resR.prev;
|
||||
shared._loudSmooth.lastTs = now;
|
||||
shared.loudnessDbfs = { L: resL.out, R: resR.out };
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
// Use the DIN-specific offset for display. This ensures that DIN and EBU
|
||||
// scales do not influence each other. The effective offset is the user-
|
||||
// adjustable correction on top of the meter's built-in offset.
|
||||
const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
||||
const __effOff = baseMode + (Number(CONFIG.PPM_DIN_TRIM_DB) || 0);
|
||||
const __offCorr = __effOff - (shared.offset || 0);
|
||||
|
||||
const PPM_TOP = Number.isFinite(CONFIG.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5;
|
||||
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
|
||||
const EXT_BOTTOM = PPM_BOTTOM - 20; // Virtuelle Skalenverlängerung für Loudness-Boxen
|
||||
const RED_START = Number.isFinite(CONFIG.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0;
|
||||
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
|
||||
|
||||
const colNorm = CONFIG.PPM_DIN_COLOR_NORMAL || MID_COLOR;
|
||||
const colWarn = CONFIG.PPM_DIN_COLOR_WARN || WARN_COLOR;
|
||||
|
||||
const mapNorm = (db) => {
|
||||
const clamped = Math.max(PPM_BOTTOM, Math.min(PPM_TOP, db));
|
||||
const table = DIN_SCALE;
|
||||
let prev = table[0];
|
||||
for (let i = 1; i < table.length; i++) {
|
||||
const curr = table[i];
|
||||
if (clamped <= curr.db) {
|
||||
const span = curr.db - prev.db || 1;
|
||||
const t = (clamped - prev.db) / span;
|
||||
return prev.pos + t * (curr.pos - prev.pos);
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return table[table.length - 1].pos;
|
||||
};
|
||||
// DIN-Skala auf volle Höhe normalisieren: -50 dB sitzt am Rect-Boden, Proportionen bleiben.
|
||||
const normBottom = mapNorm(PPM_BOTTOM);
|
||||
const normSpan = Math.max(1e-6, 1 - normBottom);
|
||||
const mapY = (db) => {
|
||||
const n = mapNorm(db);
|
||||
const t = Math.max(0, Math.min(1, (n - normBottom) / normSpan));
|
||||
return rect.y + (1 - t) * rect.h;
|
||||
};
|
||||
|
||||
const innerPad = 8;
|
||||
const scaleW = 28;
|
||||
const gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(10, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
|
||||
const bottomLoud = PPM_BOTTOM - 20; // Loudness-Boxen dürfen 20 dB tiefer fallen als die Skala
|
||||
const clampDb = (v) => Math.max(PPM_BOTTOM, Math.min(PPM_TOP, v));
|
||||
const rawL = shared.values.L + __offCorr;
|
||||
const rawR = shared.values.R + __offCorr;
|
||||
const dbValL = clampDb(rawL);
|
||||
const dbValR = clampDb(rawR);
|
||||
const smooth = smoothHeader(shared, rawL, rawR);
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.PPM_DIN_HEADER_SHOW_VALUE && (rawL > RED_START || rawR > RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.PPM_DIN_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
const isRedL = rawL > RED_START;
|
||||
const isRedR = rawR > 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 = colNorm;
|
||||
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('PPM (DIN)', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
const ppmL = mapClamp(dbValL, PPM_BOTTOM, PPM_TOP);
|
||||
const ppmR = mapClamp(dbValR, PPM_BOTTOM, PPM_TOP);
|
||||
const baseDbfs = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||||
const loudOff = Number(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB) || 0;
|
||||
const loudnessDisplayL = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.L))
|
||||
? (shared.loudnessDbfs.L - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff
|
||||
: null;
|
||||
const loudnessDisplayR = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.R))
|
||||
? (shared.loudnessDbfs.R - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff
|
||||
: null;
|
||||
|
||||
const yBottom = mapY(PPM_BOTTOM);
|
||||
const yRed = mapY(RED_START);
|
||||
const yTop = mapY(PPM_TOP);
|
||||
const innerW = Math.max(10, barW - 2);
|
||||
const drawBar = (x0, dbVal) => {
|
||||
const yVal = mapY(dbVal);
|
||||
|
||||
if (dbVal > RED_START) {
|
||||
if (redOnly) {
|
||||
const yNormTop = Math.min(yRed, yBottom);
|
||||
if (yBottom - yNormTop > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yNormTop, innerW, yBottom - yNormTop);
|
||||
}
|
||||
const yWarnTop = Math.min(yVal, yRed);
|
||||
if (yRed - yWarnTop > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yWarnTop, innerW, yRed - yWarnTop);
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
g.globalAlpha = 0.12;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x0 + 1, yVal, innerW, 2);
|
||||
g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
drawBar(leftX, ppmL);
|
||||
drawBar(rightX, ppmR);
|
||||
|
||||
// Peak-Hold (optional, rein visuell; eigentliche Ballistik kommt aus dem Worklet)
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS)
|
||||
? Math.max(0, CONFIG.PPM_DIN_HOLD_MS)
|
||||
: (shared?._holdCfg?.holdMs ?? DEFAULT_HOLD_MS);
|
||||
const holdDecay = Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S
|
||||
: (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S);
|
||||
if (holdMs > 0) {
|
||||
if (!shared.hold) shared.hold = { L: PPM_BOTTOM, R: PPM_BOTTOM };
|
||||
const cfgChanged = !shared._holdState
|
||||
|| !shared._holdCfg
|
||||
|| shared._holdCfg.holdMs !== holdMs
|
||||
|| shared._holdCfg.decayDbPerS !== holdDecay;
|
||||
if (cfgChanged) {
|
||||
const resetNow = performance.now();
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold.L ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
R: createPeakHoldState(shared.hold.R ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
};
|
||||
shared._holdCfg = { holdMs, decayDbPerS: holdDecay };
|
||||
}
|
||||
const nowHold = performance.now();
|
||||
const holdOpts = {
|
||||
holdMs,
|
||||
decayDbPerS: holdDecay,
|
||||
floor: PPM_BOTTOM,
|
||||
riseThreshold: 0.2,
|
||||
};
|
||||
shared.hold.L = stepPeakHold(ppmL, shared._holdState.L, nowHold, holdOpts);
|
||||
shared.hold.R = stepPeakHold(ppmR, shared._holdState.R, nowHold, holdOpts);
|
||||
g.save();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
const drawLoudBox = (x0, loudVal) => {
|
||||
if (!Number.isFinite(loudVal)) return;
|
||||
const boxSize = barW; // exakt so breit wie der sichtbare Balken (keine 1px-Ränder)
|
||||
// Messwert sitzt an der oberen Kante; Skala wird nach unten verlängert.
|
||||
// Bei -50 dB (Bottom) liegt die Oberkante im sichtbaren Bereich,
|
||||
// bei -70 dB ist sie eine volle Meterhöhe weiter unten (außerhalb des Sichtfelds).
|
||||
const spanExt = Math.max(1e-6, PPM_BOTTOM - EXT_BOTTOM);
|
||||
const yBottom = mapY(PPM_BOTTOM);
|
||||
const yLoud = (loudVal < PPM_BOTTOM)
|
||||
? yBottom + ((PPM_BOTTOM - loudVal) / spanExt) * rect.h
|
||||
: mapY(loudVal);
|
||||
// Zeichne nur den sichtbaren Teil: Box-Oberkante = Pegel, Unterkante nicht unter yBottom.
|
||||
const boxY = yLoud;
|
||||
const visibleHeight = Math.max(0, Math.min(boxSize, yBottom - boxY));
|
||||
if (visibleHeight <= 0) return;
|
||||
const x = x0;
|
||||
g.save();
|
||||
g.fillStyle = '#0b6ea8';
|
||||
g.globalAlpha = 0.92;
|
||||
g.fillRect(x, boxY, boxSize, visibleHeight);
|
||||
g.globalAlpha = 1;
|
||||
g.strokeStyle = '#094b73';
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(x + 0.5, boxY + 0.5, boxSize - 1, visibleHeight - 1);
|
||||
g.restore();
|
||||
};
|
||||
|
||||
drawLoudBox(leftX, loudnessDisplayL);
|
||||
drawLoudBox(rightX, loudnessDisplayR);
|
||||
|
||||
drawPpmDinStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
}, mapY, PPM_TOP, PPM_BOTTOM);
|
||||
}
|
||||
|
||||
function drawPpmDinStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, PPM_BOTTOM) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
} = geom;
|
||||
const topPad = 10;
|
||||
const sidePad = 0;
|
||||
const layerX = rect.x - sidePad;
|
||||
const layerY = rect.y - topPad;
|
||||
const layerW = rect.w + sidePad * 2;
|
||||
const layerH = rect.h + 24 + topPad;
|
||||
const key = [
|
||||
'scale-font-header-v1-top-pad',
|
||||
Math.round(rect.w),
|
||||
Math.round(rect.h),
|
||||
topPad,
|
||||
sidePad,
|
||||
CONFIG.METER_BAR_THIN || 0.55,
|
||||
CONFIG.PPM_DIN_MODE || 'al_minus9',
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
CONFIG.PPM_DIN_TOP ?? 5,
|
||||
CONFIG.PPM_DIN_BOTTOM ?? -50,
|
||||
CONFIG.PPM_DIN_RED_START ?? 0,
|
||||
METER_HEADER_FONT,
|
||||
colWarn,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'ppm-din-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawWarningEdges(cg, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
||||
drawBarTicks(cg, leftX, rightX, innerW, mapY, PPM_TOP, PPM_BOTTOM, colWarn, CONFIG);
|
||||
drawCenterScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, PPM_TOP, PPM_BOTTOM);
|
||||
drawPercentScale(cg, leftX, rightX, barW, mapY, PPM_BOTTOM);
|
||||
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('dB', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function mapClamp(db, bottom, top) {
|
||||
return Math.max(bottom, Math.min(top, db));
|
||||
}
|
||||
|
||||
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop);
|
||||
const y2 = Math.max(yWarn, yTop);
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawBarTicks(g, leftX, rightX, widthPx, mapY, top, bottom, warnColor, CONFIG) {
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
|
||||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawPair = (yPix, width, color = DEFAULT_COLOR) => {
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
const span = Math.max(1, width);
|
||||
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();
|
||||
g.restore();
|
||||
};
|
||||
|
||||
const minorWidth = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const majorWidth = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
|
||||
const majors = MAJOR_TICKS_DB
|
||||
.filter((db) => db >= bottom && db <= top)
|
||||
.slice()
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const db of majors) {
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
drawPair(y, majorWidth);
|
||||
}
|
||||
|
||||
const warn = warnColor || DEFAULT_COLOR;
|
||||
const showAl = CONFIG?.AL_MARKERS_ENABLED !== false;
|
||||
const minors = MINOR_TICKS
|
||||
.filter((tick) => tick.db >= bottom && tick.db <= top)
|
||||
.slice()
|
||||
.sort((a, b) => a.db - b.db);
|
||||
|
||||
const dynWarnDb = showAl
|
||||
? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9)
|
||||
: null;
|
||||
|
||||
for (const tick of minors) {
|
||||
const y = Math.round(mapY(tick.db)) + 0.5;
|
||||
const isDynWarn = dynWarnDb !== null && tick.db === dynWarnDb;
|
||||
const color = (tick.color === 'warn' || isDynWarn) ? warn : DEFAULT_COLOR;
|
||||
drawPair(y, minorWidth, color);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawCenterScale(g, rect, centerX, mapY, top, bottom) {
|
||||
drawHairlineGrid(g, rect, mapY, top, bottom, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
const majors = MAJOR_TICKS_DB
|
||||
.filter((db) => db >= bottom && db <= top)
|
||||
.slice()
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
for (const db of majors) {
|
||||
const y = mapY(db);
|
||||
if (y < rect.y || y > rect.y + rect.h) continue;
|
||||
const label = db > 0 ? `+${db}` : `${db}`;
|
||||
g.fillText(label, centerX, y + 5);
|
||||
}
|
||||
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawPercentScale(g, leftX, rightX, barW, mapY, bottomDb) {
|
||||
g.save();
|
||||
g.fillStyle = '#ffb347';
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 8px ui-monospace, monospace';
|
||||
const marks = PERCENT_MARKS.slice().sort((a, b) => a.db - b.db);
|
||||
|
||||
g.textAlign = 'right';
|
||||
for (const mark of marks) {
|
||||
g.fillText(mark.label, leftX - 1, mapY(mark.db) + 4);
|
||||
}
|
||||
|
||||
g.textAlign = 'left';
|
||||
const rightTextX = rightX + barW + 1;
|
||||
for (const mark of marks) {
|
||||
g.fillText(mark.label, rightTextX, mapY(mark.db) + 4);
|
||||
}
|
||||
|
||||
const percentYOffset = 12;
|
||||
g.textAlign = 'right';
|
||||
g.fillText('%', leftX - 1, mapY(bottomDb) + percentYOffset);
|
||||
g.textAlign = 'left';
|
||||
g.fillText('%', rightTextX, mapY(bottomDb) + percentYOffset);
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
// meters/ppm_ebu.js — PPM (EBU) mit linearer Skala
|
||||
// - Messbereich: -14 … +14 dB (Clamping/Anzeigegrenzen)
|
||||
// - Rote Zone ab +9 dB
|
||||
// - Ticks: große Marken bei -12/-8/-4/0/+4/+8/+12, kleine u. a. bei -10/-6/…/+10
|
||||
// - Labels: nur für die großen Ticks; bei 0 statt "0" → "TEST"
|
||||
|
||||
export const id = 'ppm-ebu';
|
||||
|
||||
const DEFAULT_ATTACK_MS = 10; // Type IIb typisch ~10 ms
|
||||
const DEFAULT_DECAY_DB_PER_S = 8.6; // ~24 dB in 2.8 s
|
||||
const DEFAULT_HOLD_MS = 750;
|
||||
|
||||
const EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
|
||||
const EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const now = performance.now();
|
||||
// Use EBU-specific configuration values. PPM_EBU_BOTTOM defines the
|
||||
// bottom of the EBU scale (default -14 dB) and PPM_EBU_HOLD_MS defines
|
||||
// the peak-hold duration. This ensures the EBU meter aligns with
|
||||
// the Type IIb specification (0 dB at AL, +9 dB red start).
|
||||
const bottom = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS) ? CONFIG.PPM_EBU_HOLD_MS : DEFAULT_HOLD_MS;
|
||||
const decayCfg = Number.isFinite(CONFIG.PPM_EBU_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_EBU_DECAY_DB_PER_S
|
||||
: DEFAULT_DECAY_DB_PER_S;
|
||||
|
||||
return {
|
||||
values: { L: bottom, R: bottom },
|
||||
hold: { L: bottom, R: bottom },
|
||||
_env: {
|
||||
L_dbfs: -90,
|
||||
R_dbfs: -90,
|
||||
lastTs: now,
|
||||
},
|
||||
_ballistics: {
|
||||
// The EBU (Type IIb) PPM uses a ~10 ms attack and a decay of 24 dB
|
||||
// in 2.8 s (≈8.6 dB/s).
|
||||
attackMs: Number.isFinite(CONFIG.PPM_EBU_ATTACK_MS) ? CONFIG.PPM_EBU_ATTACK_MS : DEFAULT_ATTACK_MS,
|
||||
decayDbPerS: decayCfg,
|
||||
},
|
||||
_holdState: {
|
||||
L: createPeakHoldState(bottom, now, holdMs),
|
||||
R: createPeakHoldState(bottom, now, holdMs),
|
||||
},
|
||||
_holdCfg: {
|
||||
holdMs,
|
||||
decayDbPerS: Number.isFinite(CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S
|
||||
: decayCfg,
|
||||
},
|
||||
// Use the EBU-specific offset so that 0 dBu (alignment level) reads 0 dB
|
||||
// on the EBU meter. This avoids the global PPM_OFFSET interfering.
|
||||
offset: Number(CONFIG.PPM_EBU_OFFSET) || 0,
|
||||
// Referenz: 0 dB Anzeige entspricht typ. ~-15 dBFS Peak
|
||||
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||||
: -15,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const inL = Number.isFinite(packet?.ppmEbuL) ? packet.ppmEbuL : (Number.isFinite(packet?.ppmL) ? packet.ppmL : -90);
|
||||
const inR = Number.isFinite(packet?.ppmEbuR) ? packet.ppmEbuR : (Number.isFinite(packet?.ppmR) ? packet.ppmR : -90);
|
||||
|
||||
const base = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||||
const off = shared.offset || 0;
|
||||
// Ballistik liegt im Worklet; hier nur Offset anwenden, um Timer-Jitter im UI zu vermeiden.
|
||||
shared._env.L_dbfs = inL;
|
||||
shared._env.R_dbfs = inR;
|
||||
shared._env.lastTs = performance.now();
|
||||
shared.values.L = (inL - base) + off;
|
||||
shared.values.R = (inR - base) + off;
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
// Effective user-correction offset for EBU: use PPM_EBU_OFFSET instead of global PPM_OFFSET.
|
||||
const __effOff = Number(CONFIG.PPM_EBU_OFFSET) || 0;
|
||||
const __offCorr = __effOff - (shared.offset || 0);
|
||||
|
||||
const PPM_TOP = Number.isFinite(CONFIG.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14;
|
||||
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
|
||||
const RED_START = Number.isFinite(CONFIG.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
|
||||
|
||||
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
|
||||
const colNorm = CONFIG.PPM_EBU_COLOR_NORMAL || MID_COLOR;
|
||||
const colWarn = CONFIG.PPM_EBU_COLOR_WARN || WARN_COLOR;
|
||||
|
||||
// LINEARE Skalenabbildung
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const mapNorm = (db) => {
|
||||
const d = clamp(db, PPM_BOTTOM, PPM_TOP);
|
||||
return (d - PPM_BOTTOM) / (PPM_TOP - PPM_BOTTOM); // 0..1
|
||||
};
|
||||
const mapY = (db) => rect.y + (1 - mapNorm(db)) * rect.h;
|
||||
|
||||
// Layout
|
||||
const innerPad = 8;
|
||||
const scaleW = 28;
|
||||
const gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(10, Math.floor(avail / 2));
|
||||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||||
|
||||
const used = 2 * barW + scaleW + 2 * gap;
|
||||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||||
const leftX = rect.x + innerPad + extra / 2;
|
||||
const scaleX = leftX + barW + gap;
|
||||
const rightX = scaleX + scaleW + gap;
|
||||
const centerX = scaleX + scaleW / 2;
|
||||
const centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
|
||||
// Werte + Clamping
|
||||
const rawL = shared.values.L + __offCorr;
|
||||
const rawR = shared.values.R + __offCorr;
|
||||
const ppmL = clamp(rawL, PPM_BOTTOM, PPM_TOP);
|
||||
const ppmR = clamp(rawR, PPM_BOTTOM, PPM_TOP);
|
||||
const smooth = smoothHeader(shared, rawL, rawR);
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.PPM_EBU_HEADER_SHOW_VALUE && (rawL > RED_START || rawR > RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.PPM_EBU_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 > RED_START;
|
||||
const isRedR = rawR > 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 = colNorm;
|
||||
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('PPM (EBU)', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
const yBottom = mapY(PPM_BOTTOM);
|
||||
const yRed = mapY(RED_START);
|
||||
const yTop = mapY(PPM_TOP);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const drawBar = (x0, dbVal) => {
|
||||
const yVal = mapY(dbVal);
|
||||
if (dbVal > RED_START) {
|
||||
if (redOnly) {
|
||||
const yNormTop = Math.min(yRed, yBottom);
|
||||
if (yBottom - yNormTop > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yNormTop, innerW, yBottom - yNormTop);
|
||||
}
|
||||
const yWarnTop = Math.min(yVal, yRed);
|
||||
if (yRed - yWarnTop > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yWarnTop, innerW, yRed - yWarnTop);
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (yBottom - yVal > 0) {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||||
}
|
||||
}
|
||||
g.globalAlpha = 0.12;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x0 + 1, yVal, innerW, 2);
|
||||
g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
drawBar(leftX, ppmL);
|
||||
drawBar(rightX, ppmR);
|
||||
|
||||
// Peak-Hold
|
||||
const holdMs = Number.isFinite(CONFIG.PPM_EBU_HOLD_MS)
|
||||
? Math.max(0, CONFIG.PPM_EBU_HOLD_MS)
|
||||
: (shared._holdCfg?.holdMs ?? DEFAULT_HOLD_MS);
|
||||
const holdDecay = Number.isFinite(CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S)
|
||||
? CONFIG.PPM_EBU_HOLD_DECAY_DB_PER_S
|
||||
: (shared._holdCfg?.decayDbPerS ?? shared._ballistics.decayDbPerS ?? DEFAULT_DECAY_DB_PER_S);
|
||||
|
||||
if (holdMs > 0) {
|
||||
if (!shared.hold) shared.hold = { L: PPM_BOTTOM, R: PPM_BOTTOM };
|
||||
const cfgChanged = !shared._holdState
|
||||
|| !shared._holdCfg
|
||||
|| shared._holdCfg.holdMs !== holdMs
|
||||
|| shared._holdCfg.decayDbPerS !== holdDecay;
|
||||
|
||||
if (cfgChanged) {
|
||||
const resetNow = performance.now();
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold.L ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
R: createPeakHoldState(shared.hold.R ?? PPM_BOTTOM, resetNow, holdMs),
|
||||
};
|
||||
shared._holdCfg = { holdMs, decayDbPerS: holdDecay };
|
||||
}
|
||||
|
||||
const nowHold = performance.now();
|
||||
const holdOpts = {
|
||||
holdMs,
|
||||
decayDbPerS: holdDecay,
|
||||
floor: PPM_BOTTOM,
|
||||
riseThreshold: 0.2,
|
||||
};
|
||||
shared.hold.L = stepPeakHold(ppmL, shared._holdState.L, nowHold, holdOpts);
|
||||
shared.hold.R = stepPeakHold(ppmR, shared._holdState.R, nowHold, holdOpts);
|
||||
|
||||
g.save();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
}, mapY, PPM_TOP, PPM_BOTTOM);
|
||||
}
|
||||
|
||||
function drawPpmEbuStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, PPM_BOTTOM) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
yTop,
|
||||
colWarn,
|
||||
} = 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.PPM_EBU_TOP ?? 14,
|
||||
CONFIG.PPM_EBU_BOTTOM ?? -14,
|
||||
CONFIG.PPM_EBU_RED_START ?? 9,
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
METER_HEADER_FONT,
|
||||
colWarn,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'ppm-ebu-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawWarningEdges(cg, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
||||
drawBarTicks(cg, leftX, rightX, innerW, mapY, PPM_TOP, PPM_BOTTOM, CONFIG);
|
||||
drawCenterScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, +12, -12);
|
||||
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('dB', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop);
|
||||
const y2 = Math.max(yWarn, yTop);
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawBarTicks(g, leftX, rightX, widthPx, mapY, top, bottom, CONFIG) {
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
|
||||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||||
const AL_COLOR = WARN_COLOR;
|
||||
const showAL = CONFIG?.AL_MARKERS_ENABLED !== false;
|
||||
const highlightDb = showAL ? 0 : null;
|
||||
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawPair = (yPix, span, highlight = false) => {
|
||||
g.strokeStyle = highlight ? AL_COLOR : DEFAULT_COLOR;
|
||||
const width = Math.max(1, span);
|
||||
const x1L = Math.round(cxL - width / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
const x1R = Math.round(cxR - width / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + width / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const majorWidth = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
const minorWidth = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
let highlightMatched = false;
|
||||
|
||||
for (const db of EBU_MAJOR_TICKS) {
|
||||
if (db < bottom || db > top) continue;
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
const isHighlight = highlightDb !== null && Math.abs(db - highlightDb) < 1e-3;
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawPair(y, majorWidth, isHighlight);
|
||||
}
|
||||
|
||||
for (const db of EBU_MINOR_TICKS) {
|
||||
if (db < bottom || db > top) continue;
|
||||
const y = Math.round(mapY(db)) + 0.5;
|
||||
const isHighlight = highlightDb !== null && Math.abs(db - highlightDb) < 1e-3;
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawPair(y, minorWidth, isHighlight);
|
||||
}
|
||||
|
||||
if (highlightDb !== null && !highlightMatched && highlightDb >= bottom && highlightDb <= top) {
|
||||
const y = Math.round(mapY(highlightDb)) + 0.5;
|
||||
drawPair(y, majorWidth, true);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawCenterScale(g, rect, centerX, mapY, top, bottom) {
|
||||
drawHairlineGrid(g, rect, mapY, top, bottom, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
for (const db of EBU_MAJOR_TICKS) {
|
||||
if (db < bottom || db > top) continue;
|
||||
const y = mapY(db);
|
||||
if (y < rect.y || y > rect.y + rect.h) continue;
|
||||
const label = db === 0 ? 'TEST' : (db > 0 ? `+${db}` : `${db}`);
|
||||
g.fillText(label, centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// meters/rms.js — True RMS (dBu/dBFS) L/R mit dualem Modus, Kalibrierung & Warnschwelle
|
||||
// Erwartet update(packet) mit packet.rmsL / packet.rmsR (in dBFS).
|
||||
// Offset via CONFIG.RMS_OFFSET_DB (wirkt vor der dBu-Umrechnung).
|
||||
//
|
||||
// Defaults jetzt DIGITAL:
|
||||
// - CONFIG.RMS_MODE bleibt auf 'dbfs', damit das Meter direkt dBFS ausgibt.
|
||||
// - CONFIG.RMS_REF_DBFS_FOR_REF_DBU: Referenz in dBFS RMS (default: -18 für 0 dBu)
|
||||
// - CONFIG.RMS_REF_DBU: Referenz in dBu RMS (default: 0)
|
||||
// -> Umrechnung: dBu = (dBFS_RMS - REF_DBFS) + REF_DBU, falls jemals benötigt.
|
||||
// - Titel passt sich an: "RMS (dBu)" oder "RMS (dBFS RMS)" je nach Modus.
|
||||
// - Skalenobergrenze: dBu: +24 (typisch Pro-Audio Headroom) / dBFS: +5
|
||||
// - Standard-Warnschwelle: dBu: +20 / dBFS: 0 (überschreibbar via CONFIG.RMS_RED_START)
|
||||
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { HEADER_BG, LABEL_COLOR, OK_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
const RMS_FRAME_MS_NOMINAL = 16; // ~60 Hz UI-Refresh
|
||||
const RMS_FRAME_MS_MAX = 40; // Obergrenze für dt in der Glättung
|
||||
const RMS_FRAME_MS_SKIP = 250; // Heuristischer Schutz: riesige Gaps komplett skippen
|
||||
|
||||
export const id = 'rms';
|
||||
|
||||
export function initShared(CONFIG = {}) {
|
||||
const offset = CONFIG.RMS_OFFSET_DB || 0;
|
||||
const initVal = -60 + offset;
|
||||
return {
|
||||
values: { L: initVal, R: initVal }, // intern immer dBFS RMS aus der Messkette
|
||||
offset,
|
||||
lastValidL: initVal,
|
||||
lastValidR: initVal,
|
||||
_smooth: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const offset = shared.offset || 0;
|
||||
const floor = -60 + offset;
|
||||
if (!Number.isFinite(shared.lastValidL)) shared.lastValidL = floor;
|
||||
if (!Number.isFinite(shared.lastValidR)) shared.lastValidR = floor;
|
||||
|
||||
if (Number.isFinite(packet?.rmsL)) {
|
||||
const vL = packet.rmsL + offset;
|
||||
shared.values.L = vL;
|
||||
shared.lastValidL = vL;
|
||||
} else {
|
||||
shared.values.L = shared.lastValidL;
|
||||
}
|
||||
|
||||
if (Number.isFinite(packet?.rmsR)) {
|
||||
const vR = packet.rmsR + offset;
|
||||
shared.values.R = vR;
|
||||
shared.lastValidR = vR;
|
||||
} else {
|
||||
shared.values.R = shared.lastValidR;
|
||||
}
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG = {}, shared) {
|
||||
const MODE = (CONFIG.RMS_MODE ?? 'dbfs').toLowerCase(); // Default jetzt 'dbfs'
|
||||
|
||||
// Referenz: -18 dBFS RMS ≙ +4 dBu (übliches Studio-Line-Up). Anpassbar.
|
||||
const REF_DBFS = Number.isFinite(CONFIG.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
|
||||
const REF_DBU = Number.isFinite(CONFIG.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4;
|
||||
|
||||
// Anzeige-Umschaltung & Skala
|
||||
const isDBU = (MODE === 'dbu');
|
||||
const LOG_MIN = -60;
|
||||
const LOG_TOP = isDBU ? +24 : 0;
|
||||
|
||||
const RED_START =
|
||||
Number.isFinite(CONFIG.RMS_RED_START)
|
||||
? CONFIG.RMS_RED_START
|
||||
: (isDBU ? +20 : 0);
|
||||
|
||||
// Umrechnung auf Anzeigeeinheit
|
||||
const toDisplay = (dbfsRms) => {
|
||||
if (!Number.isFinite(dbfsRms)) return -Infinity;
|
||||
return isDBU ? (dbfsRms - REF_DBFS + REF_DBU) : dbfsRms;
|
||||
};
|
||||
|
||||
// Skalen-Mapping: unterhalb −20 stärker komprimiert, darüber nahezu linear.
|
||||
function scaleNorm(db) {
|
||||
if (!Number.isFinite(db)) return 0;
|
||||
if (db <= LOG_MIN) return 0;
|
||||
|
||||
if (isDBU) {
|
||||
// dBu: −60…−20 (45%), −20…0 (+25%), 0…+20 (+25%), +20…+24 (+5%)
|
||||
if (db <= -20) { const t = (db + 60) / 40; return Math.pow(t, 1.6) * 0.45; } // → 0…0.45
|
||||
if (db <= 0) { const t = (db + 20) / 20; return 0.45 + t * 0.25; } // → 0.70
|
||||
if (db <= +20) { const t = (db ) / 20; return 0.70 + t * 0.25; } // → 0.95
|
||||
if (db <= +24) { const t = (db - 20) / 4; return 0.95 + t * 0.05; } // → 1.00
|
||||
return 1;
|
||||
} else {
|
||||
// dBFS: linear 0 … −60
|
||||
if (db >= LOG_TOP) return 1;
|
||||
return (db - LOG_MIN) / (LOG_TOP - LOG_MIN);
|
||||
}
|
||||
}
|
||||
const mapY = (db) => rect.y + (1 - Math.max(0, Math.min(1, scaleNorm(db)))) * rect.h;
|
||||
|
||||
// Layout
|
||||
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 centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
|
||||
// Header-Hintergrund säubern
|
||||
g.save();
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
g.restore();
|
||||
|
||||
// IEC-ähnliche Zeitkonstanten (Impulse/Fast/Slow) für die Anzeige
|
||||
const tauMs = resolveRmsTau(CONFIG);
|
||||
if (tauMs > 0) {
|
||||
const now = performance.now();
|
||||
if (!shared._smooth) {
|
||||
shared._smooth = {
|
||||
L: shared.values.L,
|
||||
R: shared.values.R,
|
||||
lastTs: now - RMS_FRAME_MS_NOMINAL,
|
||||
};
|
||||
}
|
||||
const lastTs = shared._smooth.lastTs ?? (now - RMS_FRAME_MS_NOMINAL);
|
||||
const dtRawMs = Math.max(0, now - lastTs);
|
||||
shared._smooth.lastTs = now;
|
||||
|
||||
if (dtRawMs <= RMS_FRAME_MS_SKIP) {
|
||||
const dtUsedMs = Math.min(dtRawMs, RMS_FRAME_MS_MAX); // clamp dt to ignore occasional large gaps
|
||||
const alpha = 1 - Math.exp(-dtUsedMs / tauMs);
|
||||
shared._smooth.L += alpha * (shared.values.L - shared._smooth.L);
|
||||
shared._smooth.R += alpha * (shared.values.R - shared._smooth.R);
|
||||
}
|
||||
} else {
|
||||
shared._smooth = null;
|
||||
}
|
||||
|
||||
// Werte in Anzeigeeinheit clampen
|
||||
const rawDispL = toDisplay(shared._smooth?.L ?? shared.values.L);
|
||||
const rawDispR = toDisplay(shared._smooth?.R ?? shared.values.R);
|
||||
const smooth = smoothHeader(shared, rawDispL, rawDispR);
|
||||
const vL = clamp(rawDispL, LOG_MIN, LOG_TOP);
|
||||
const vR = clamp(rawDispR, LOG_MIN, LOG_TOP);
|
||||
g.save();
|
||||
const headerWarn = CONFIG.RMS_HEADER_SHOW_VALUE && (rawDispL > RED_START || rawDispR > RED_START);
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.RMS_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
g.textAlign = 'center';
|
||||
const isRedL = rawDispL > RED_START;
|
||||
const isRedR = rawDispR > RED_START;
|
||||
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 {
|
||||
const title = isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
|
||||
g.fillText(title, centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
|
||||
const yFloor = mapY(LOG_MIN);
|
||||
const redStart = clamp(RED_START, LOG_MIN, LOG_TOP);
|
||||
const yRed = mapY(redStart);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const colNorm = CONFIG.RMS_COLOR_NORMAL || OK_COLOR;
|
||||
const colWarn = CONFIG.RMS_COLOR_WARN || WARN_COLOR;
|
||||
|
||||
const drawBar = (x0, valDisp) => {
|
||||
const yVal = mapY(valDisp);
|
||||
|
||||
if (valDisp > RED_START) {
|
||||
if (CONFIG.RMS_RED_BAR_ONLY) {
|
||||
// normaler Teil (bis Warnschwelle)
|
||||
const yNormTop = Math.min(yRed, yFloor);
|
||||
g.fillStyle = colNorm; g.fillRect(x0 + 1, yNormTop, innerW, Math.max(0, yFloor - yNormTop));
|
||||
// warnender Teil
|
||||
const yWarnTop = Math.min(yVal, yRed);
|
||||
g.fillStyle = colWarn; g.fillRect(x0 + 1, yWarnTop, innerW, Math.max(0, yRed - yWarnTop));
|
||||
} 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, vL);
|
||||
drawBar(rightX, vR);
|
||||
|
||||
const majors = isDBU ? [24, 20, 10, 0, -10, -20, -30, -40, -50, -60]
|
||||
: [0, -6, -12, -18, -24, -30, -40, -60];
|
||||
const minorValues = isDBU ? null : buildDbfsMinorTicks();
|
||||
const alignmentHighlight = (!isDBU) ? getRmsAlignmentHighlight(CONFIG) : null;
|
||||
drawRmsStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
mapY,
|
||||
LOG_TOP,
|
||||
LOG_MIN,
|
||||
isDBU,
|
||||
majors,
|
||||
minorValues,
|
||||
alignmentHighlight,
|
||||
});
|
||||
}
|
||||
|
||||
function drawRmsStaticOverlay(g, shared, rect, CONFIG, geom) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
yRed,
|
||||
mapY,
|
||||
LOG_TOP,
|
||||
LOG_MIN,
|
||||
isDBU,
|
||||
majors,
|
||||
minorValues,
|
||||
alignmentHighlight,
|
||||
} = 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.RMS_MODE || 'dbfs',
|
||||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
CONFIG.RMS_RED_START ?? '',
|
||||
METER_HEADER_FONT,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'rms-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, LOG_TOP, LOG_MIN, majors, minorValues, alignmentHighlight);
|
||||
const yTop = mapY(LOG_TOP);
|
||||
drawRedStripeLR(cg, leftX, barW, rightX, centerX, mapY, yRed, yTop);
|
||||
const scaleRect = { x: scaleX, y: rect.y, w: scaleW, h: rect.h };
|
||||
drawScale(cg, scaleRect, centerX, mapY, {
|
||||
isDBU,
|
||||
majors,
|
||||
minors: minorValues,
|
||||
hairStep: isDBU ? null : 1,
|
||||
topValue: LOG_TOP,
|
||||
bottomValue: LOG_MIN,
|
||||
highlightTick: null,
|
||||
});
|
||||
const unitLabel = 'dBFS (RMS)';
|
||||
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(unitLabel, centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
|
||||
function resolveRmsTau(CONFIG = {}) {
|
||||
const mode = String(CONFIG.RMS_TC_MODE || 'fast').toLowerCase();
|
||||
const custom = Number(CONFIG.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 drawRedStripeLR(g, leftX, barW, rightX, centerX, mapY, yWarn, yTop) {
|
||||
const innerRight = leftX + barW;
|
||||
const gapL = Math.abs(centerX - innerRight);
|
||||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||||
|
||||
const innerLeft = rightX;
|
||||
const gapR = Math.abs(centerX - innerLeft);
|
||||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||||
|
||||
const y1 = Math.min(yWarn, yTop), y2 = Math.max(yWarn, yTop);
|
||||
g.save(); g.strokeStyle = WARN_COLOR; g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, TOP, BOTTOM, majors, minorValues = null, highlightTick = null) {
|
||||
g.save();
|
||||
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const approx = (a, b) => Math.abs(a - b) < 0.01;
|
||||
|
||||
const drawMajor = (value) => {
|
||||
const majorW = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
const yPix = Math.round(mapY(value)) + 0.5;
|
||||
const isHighlight = highlightTick && approx(value, highlightTick.value);
|
||||
const color = isHighlight ? highlightTick.color : 'rgb(0,0,255)';
|
||||
const lineWidth = isHighlight ? 1.8 : 1;
|
||||
|
||||
const x1L = Math.round(cxL - majorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + majorW / 2) + 0.5;
|
||||
g.strokeStyle = color; g.lineWidth = lineWidth;
|
||||
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 = (value) => {
|
||||
const minorW = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const yPix = Math.round(mapY(value)) + 0.5;
|
||||
const isHighlight = highlightTick && approx(value, highlightTick.value);
|
||||
const color = isHighlight ? highlightTick.color : 'rgba(0,0,255,0.55)';
|
||||
const lineWidth = isHighlight ? 1.4 : 1;
|
||||
|
||||
const x1L = Math.round(cxL - minorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + minorW / 2) + 0.5;
|
||||
g.strokeStyle = color; g.lineWidth = lineWidth;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - minorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const top = TOP, bot = BOTTOM;
|
||||
const hi = Math.max(top, bot), lo = Math.min(top, bot);
|
||||
const inRange = (v) => v <= hi && v >= lo;
|
||||
|
||||
if (Array.isArray(majors) && majors.length > 0) {
|
||||
const sorted = majors.slice().sort((a, b) => b - a); // desc
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const vMaj = sorted[i];
|
||||
if (inRange(vMaj)) {
|
||||
drawMajor(vMaj);
|
||||
}
|
||||
if (minorValues == null && i < sorted.length - 1) {
|
||||
const vNext = sorted[i + 1];
|
||||
const mid = (vMaj + vNext) / 2;
|
||||
if (inRange(mid)) {
|
||||
drawMinor(mid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(minorValues) && minorValues.length > 0) {
|
||||
const sortedMin = minorValues.slice().sort((a, b) => b - a);
|
||||
for (const v of sortedMin) {
|
||||
if (!inRange(v)) continue;
|
||||
drawMinor(v);
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// Mittenskala & Raster
|
||||
function drawScale(g, colRect, centerX, mapY, {
|
||||
isDBU,
|
||||
majors = [],
|
||||
minors = [],
|
||||
hairStep = null,
|
||||
topValue = 0,
|
||||
bottomValue = -60,
|
||||
highlightTick = null,
|
||||
}) {
|
||||
if (isDBU) {
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
const labels = [24, 20, 10, 4, 0, -10, -20, -30, -40, -50, -60];
|
||||
for (const v of labels) {
|
||||
const y = mapY(v);
|
||||
if (y < colRect.y || y > colRect.y + colRect.h) continue;
|
||||
const lab = v > 0 ? ('+' + v) : (v === 0 ? '0' : String(v));
|
||||
g.fillText(lab, centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const hi = Math.max(topValue, bottomValue);
|
||||
const lo = Math.min(topValue, bottomValue);
|
||||
const inRange = (v) => v <= hi && v >= lo;
|
||||
const approx = (a, b) => Math.abs(a - b) < 0.01;
|
||||
const isHighlightValue = (value) => !!highlightTick && approx(value, highlightTick.value);
|
||||
|
||||
if (hairStep && hairStep > 0) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(colRect.x, colRect.y, colRect.w, colRect.h);
|
||||
g.clip();
|
||||
for (let v = topValue; v >= bottomValue; v -= hairStep) {
|
||||
if (!inRange(v)) continue;
|
||||
const y = Math.round(mapY(v)) + 0.5;
|
||||
g.strokeStyle = isHighlightValue(v) ? (highlightTick?.color || '#ffffff') : 'rgba(255,255,255,0.08)';
|
||||
g.lineWidth = isHighlightValue(v) ? 1.2 : 0.6;
|
||||
g.beginPath();
|
||||
g.moveTo(colRect.x, y);
|
||||
g.lineTo(colRect.x + colRect.w, y);
|
||||
g.stroke();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
for (const v of majors || []) {
|
||||
if (!inRange(v)) continue;
|
||||
const y = mapY(v);
|
||||
g.fillStyle = isHighlightValue(v) ? (highlightTick?.color || LABEL_COLOR) : LABEL_COLOR;
|
||||
g.fillText(String(v), centerX, y + 5);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function buildDbfsMinorTicks() {
|
||||
return [-3, -9, -15, -21, -27, -33, -36, -42, -48, -54];
|
||||
}
|
||||
|
||||
function getRmsAlignmentHighlight(CONFIG) {
|
||||
if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null;
|
||||
const isArd = CONFIG.PPM_DIN_MODE === 'al_minus9';
|
||||
return {
|
||||
value: isArd ? -15 : -18,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// meters/scale_helpers.js — shared helpers for drawing center-scale grid lines
|
||||
|
||||
export const METER_HEADER_FONT = 'bold 12px ui-monospace, monospace';
|
||||
|
||||
/**
|
||||
* Draws a hairline-style grid (like the RMS meter) across a center column.
|
||||
* The caller provides the existing mapY function so non-linear scales are supported.
|
||||
*/
|
||||
export function drawHairlineGrid(
|
||||
g,
|
||||
rect,
|
||||
mapY,
|
||||
topValue,
|
||||
bottomValue,
|
||||
step = 1,
|
||||
color = 'rgba(255,255,255,0.08)',
|
||||
lineWidth = 0.6
|
||||
) {
|
||||
if (!g || !rect || typeof mapY !== 'function') return;
|
||||
if (!Number.isFinite(step) || step <= 0) return;
|
||||
|
||||
const hi = Math.max(topValue, bottomValue);
|
||||
const lo = Math.min(topValue, bottomValue);
|
||||
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(rect.x, rect.y, rect.w, rect.h);
|
||||
g.clip();
|
||||
|
||||
for (let v = hi; v >= lo - 1e-6; v -= step) {
|
||||
const y = Math.round(mapY(v)) + 0.5;
|
||||
if (!Number.isFinite(y)) continue;
|
||||
if (y < rect.y - 2 || y > rect.y + rect.h + 2) continue;
|
||||
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = lineWidth;
|
||||
g.beginPath();
|
||||
g.moveTo(rect.x, y);
|
||||
g.lineTo(rect.x + rect.w, y);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
g.restore();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const CAN_USE_OFFSCREEN = typeof OffscreenCanvas === 'function';
|
||||
|
||||
function createSurface(width, height) {
|
||||
const w = Math.max(1, Math.ceil(width));
|
||||
const h = Math.max(1, Math.ceil(height));
|
||||
if (CAN_USE_OFFSCREEN) {
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h };
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) return { canvas, ctx, width: w, height: h };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function drawCachedStaticLayer(targetCtx, shared, layerId, key, x, y, width, height, build) {
|
||||
if (!targetCtx || !shared || typeof build !== 'function') return false;
|
||||
if (!shared._staticLayers) shared._staticLayers = new Map();
|
||||
let layer = shared._staticLayers.get(layerId);
|
||||
const w = Math.max(1, Math.ceil(width));
|
||||
const h = Math.max(1, Math.ceil(height));
|
||||
const needsRebuild = !layer
|
||||
|| layer.key !== key
|
||||
|| layer.width !== w
|
||||
|| layer.height !== h;
|
||||
|
||||
if (needsRebuild) {
|
||||
layer = createSurface(w, h);
|
||||
if (!layer) return false;
|
||||
layer.key = key;
|
||||
layer.ctx.save();
|
||||
try {
|
||||
layer.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
build(layer.ctx);
|
||||
} finally {
|
||||
layer.ctx.restore();
|
||||
}
|
||||
shared._staticLayers.set(layerId, layer);
|
||||
}
|
||||
|
||||
targetCtx.drawImage(layer.canvas, x, y);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
// meters/stopwatch.js — Stoppuhr als Slot-Meter (Start/Stop, Reset, Auto)
|
||||
// Auto: startet bei Signal > -70 dBFS und stoppt, wenn >=1s lang <= -70 dBFS.
|
||||
|
||||
import { CONFIG } from '../core/config.js';
|
||||
import { HEADER_BG, MID_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'stopwatch';
|
||||
export const disableCache = true;
|
||||
|
||||
const AUTO_STOP_AFTER_MS = 1000;
|
||||
|
||||
function isExternalClient() {
|
||||
const host = String(globalThis?.location?.hostname || '').trim().toLowerCase();
|
||||
return !!host && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]';
|
||||
}
|
||||
|
||||
function getSilenceDb() {
|
||||
const raw = Number(CONFIG?.STOPWATCH_AUTO_THRESHOLD_DBFS);
|
||||
return Math.max(-120, Math.min(20, Number.isFinite(raw) ? raw : -70));
|
||||
}
|
||||
|
||||
export function initShared() {
|
||||
const now = performance.now();
|
||||
return {
|
||||
auto: false,
|
||||
entries: [], // { elapsedMs, startTs, running }
|
||||
scrollOffset: 0, // 0 = neueste; größer = weiter zurück
|
||||
_dragScroll: null, // { startY, startOffset }
|
||||
_lastNow: now,
|
||||
_silenceStartTs: null,
|
||||
_lastLevelDb: -120,
|
||||
_buttonRects: null,
|
||||
_deleteVisible: false,
|
||||
_deleteRects: [],
|
||||
_scrollInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
const now = performance.now();
|
||||
shared._lastNow = now;
|
||||
|
||||
const rmsL = Number.isFinite(packet?.rmsL) ? packet.rmsL : -Infinity;
|
||||
const rmsR = Number.isFinite(packet?.rmsR) ? packet.rmsR : -Infinity;
|
||||
const levelDb = Number.isFinite(rmsL) || Number.isFinite(rmsR) ? Math.max(rmsL, rmsR) : -Infinity;
|
||||
shared._lastLevelDb = Number.isFinite(levelDb) ? levelDb : -120;
|
||||
|
||||
if (!shared.auto) return;
|
||||
|
||||
const silenceDb = getSilenceDb();
|
||||
const hasSignal = Number.isFinite(levelDb) && levelDb > silenceDb;
|
||||
if (hasSignal) {
|
||||
shared._silenceStartTs = null;
|
||||
if (!isRunning(shared)) startNew(shared, now);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shared._silenceStartTs === null) shared._silenceStartTs = now;
|
||||
if (isRunning(shared) && (now - shared._silenceStartTs) >= AUTO_STOP_AFTER_MS) {
|
||||
stop(shared, now);
|
||||
}
|
||||
}
|
||||
|
||||
function stop(shared, now) {
|
||||
const entry = getActiveEntry(shared);
|
||||
if (!entry || !entry.running) return;
|
||||
const startTs = Number.isFinite(entry.startTs) ? entry.startTs : now;
|
||||
entry.elapsedMs = (entry.elapsedMs || 0) + Math.max(0, now - startTs);
|
||||
entry.running = false;
|
||||
entry.startTs = 0;
|
||||
}
|
||||
|
||||
function startNew(shared, now) {
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
shared.entries = entries;
|
||||
if (isRunning(shared)) return;
|
||||
// Neueste Einträge immer sichtbar halten: Liste "scrollt" automatisch nach oben.
|
||||
shared.scrollOffset = 0;
|
||||
entries.push({ elapsedMs: 0, startTs: now, running: true });
|
||||
if (entries.length > 50) entries.shift();
|
||||
shared._silenceStartTs = null;
|
||||
}
|
||||
|
||||
function reset(shared, now) {
|
||||
shared.entries = [];
|
||||
shared.scrollOffset = 0;
|
||||
shared._dragScroll = null;
|
||||
shared._deleteRects = [];
|
||||
shared._silenceStartTs = null;
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
const clamped = Math.max(0, ms | 0);
|
||||
const totalSeconds = Math.floor(clamped / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const tenths = Math.floor((clamped % 1000) / 100);
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
if (hours > 0) return `${hours}:${pad2(minutes)}:${pad2(seconds)}`;
|
||||
return `${pad2(minutes)}:${pad2(seconds)}.${tenths}`;
|
||||
}
|
||||
|
||||
// Orientiert am Layout-Ansatz der anderen Meter (z.B. PPM DIN):
|
||||
// innerhalb eines festen innerPad wird "extra/2" genutzt, sodass bei ungerader Restbreite
|
||||
// eine stabile .5-Zentrierung entsteht (wirkt über Browser/Displays konsistenter).
|
||||
function computeContentRect(rect, padX = 8) {
|
||||
const innerPad = padX;
|
||||
const avail = Math.max(1, rect.w - innerPad * 2);
|
||||
const w = (avail > 2) ? (avail - 1) : avail; // erzwingt i.d.R. extra=1 → +0.5 Zentrierung
|
||||
const extra = Math.max(0, avail - w);
|
||||
const x = rect.x + innerPad + extra / 2;
|
||||
return { x, w, cx: x + w / 2 };
|
||||
}
|
||||
|
||||
function fitFontSizeToWidth(g, texts, maxWidth, {
|
||||
min = 10,
|
||||
max = 96,
|
||||
family = 'ui-monospace, monospace',
|
||||
weight = 'bold',
|
||||
} = {}) {
|
||||
const safeTexts = (Array.isArray(texts) ? texts : []).filter((t) => typeof t === 'string' && t.length);
|
||||
const samples = safeTexts.length ? safeTexts : ['00:00.0'];
|
||||
const width = Math.max(1, maxWidth);
|
||||
|
||||
let lo = Math.max(1, min | 0);
|
||||
let hi = Math.max(lo, max | 0);
|
||||
let best = lo;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
g.font = `${weight} ${mid}px ${family}`;
|
||||
let widest = 0;
|
||||
for (const t of samples) widest = Math.max(widest, g.measureText(t).width);
|
||||
if (widest <= width) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function sevenSegCharRatio(ch) {
|
||||
if (ch >= '0' && ch <= '9') return 0.64;
|
||||
if (ch === ':') return 0.26;
|
||||
if (ch === '.') return 0.24;
|
||||
if (ch === '-') return 0.45;
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
function sevenSegTotalRatio(text, gapRatio = 0.14) {
|
||||
const s = String(text ?? '');
|
||||
if (!s.length) return 1;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < s.length; i++) sum += sevenSegCharRatio(s[i]);
|
||||
sum += gapRatio * (s.length - 1);
|
||||
return Math.max(1e-6, sum);
|
||||
}
|
||||
|
||||
function drawSevenSegString(g, text, xCenter, yTop, maxWidth, height, color, {
|
||||
gapRatio = 0.14,
|
||||
dimAlpha = 0.25,
|
||||
} = {}) {
|
||||
const s = String(text ?? '');
|
||||
const h = Math.max(6, height);
|
||||
const ratio = sevenSegTotalRatio(s, gapRatio);
|
||||
const wTotal = Math.min(maxWidth, ratio * h);
|
||||
const scale = wTotal / (ratio * h);
|
||||
const hUsed = h * scale;
|
||||
const gap = gapRatio * hUsed;
|
||||
const x0 = xCenter - wTotal / 2;
|
||||
|
||||
let x = x0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
const cw = sevenSegCharRatio(ch) * hUsed;
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
drawSevenSegDigit(g, ch, x, yTop, cw, hUsed, color);
|
||||
} else if (ch === ':') {
|
||||
drawSevenSegColon(g, x, yTop, cw, hUsed, color);
|
||||
} else if (ch === '.') {
|
||||
drawSevenSegDot(g, x, yTop, cw, hUsed, color);
|
||||
} else if (ch === '-') {
|
||||
drawSevenSegMinus(g, x, yTop, cw, hUsed, color);
|
||||
} else {
|
||||
g.save();
|
||||
g.globalAlpha = dimAlpha;
|
||||
drawSevenSegMinus(g, x, yTop, cw, hUsed, color);
|
||||
g.restore();
|
||||
}
|
||||
x += cw + gap;
|
||||
}
|
||||
return { width: wTotal, height: hUsed };
|
||||
}
|
||||
|
||||
function drawSevenSegDigit(g, ch, x, y, w, h, color) {
|
||||
const digit = Number(ch);
|
||||
const on = SEGMENTS_BY_DIGIT[digit] || SEGMENTS_BY_DIGIT[0];
|
||||
const t = Math.max(2, Math.round(h * 0.14));
|
||||
const r = Math.max(1, Math.round(t * 0.35));
|
||||
const pad = Math.max(1, Math.round(t * 0.45));
|
||||
|
||||
const halfH = h / 2;
|
||||
const vSegH = Math.max(2, Math.floor(halfH - pad - t / 2));
|
||||
|
||||
const seg = {
|
||||
A: { x: x + pad, y: y, w: Math.max(2, w - 2 * pad), h: t },
|
||||
D: { x: x + pad, y: y + h - t, w: Math.max(2, w - 2 * pad), h: t },
|
||||
G: { x: x + pad, y: y + halfH - t / 2, w: Math.max(2, w - 2 * pad), h: t },
|
||||
F: { x: x, y: y + pad, w: t, h: vSegH },
|
||||
B: { x: x + w - t, y: y + pad, w: t, h: vSegH },
|
||||
E: { x: x, y: y + halfH + t / 2, w: t, h: vSegH },
|
||||
C: { x: x + w - t, y: y + halfH + t / 2, w: t, h: vSegH },
|
||||
};
|
||||
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
for (const key of Object.keys(seg)) {
|
||||
const s = seg[key];
|
||||
const alpha = on.has(key) ? 1 : 0.12;
|
||||
g.globalAlpha = alpha;
|
||||
g.beginPath();
|
||||
roundedRect(g, s.x, s.y, s.w, s.h, r);
|
||||
g.fill();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawSevenSegColon(g, x, y, w, h, color) {
|
||||
const r = Math.max(2, Math.round(h * 0.06));
|
||||
const cx = x + w / 2;
|
||||
const y1 = y + h * 0.33;
|
||||
const y2 = y + h * 0.67;
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.globalAlpha = 0.95;
|
||||
g.beginPath(); g.arc(cx, y1, r, 0, Math.PI * 2); g.fill();
|
||||
g.beginPath(); g.arc(cx, y2, r, 0, Math.PI * 2); g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawSevenSegDot(g, x, y, w, h, color) {
|
||||
const r = Math.max(2, Math.round(h * 0.06));
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h * 0.82;
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.globalAlpha = 0.95;
|
||||
g.beginPath(); g.arc(cx, cy, r, 0, Math.PI * 2); g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawSevenSegMinus(g, x, y, w, h, color) {
|
||||
const t = Math.max(2, Math.round(h * 0.14));
|
||||
const r = Math.max(1, Math.round(t * 0.35));
|
||||
const pad = Math.max(1, Math.round(t * 0.45));
|
||||
const seg = { x: x + pad, y: y + h / 2 - t / 2, w: Math.max(2, w - 2 * pad), h: t };
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.globalAlpha = 0.95;
|
||||
g.beginPath();
|
||||
roundedRect(g, seg.x, seg.y, seg.w, seg.h, r);
|
||||
g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
const SEGMENTS_BY_DIGIT = [
|
||||
new Set(['A','B','C','D','E','F']),
|
||||
new Set(['B','C']),
|
||||
new Set(['A','B','G','E','D']),
|
||||
new Set(['A','B','G','C','D']),
|
||||
new Set(['F','G','B','C']),
|
||||
new Set(['A','F','G','C','D']),
|
||||
new Set(['A','F','G','E','C','D']),
|
||||
new Set(['A','B','C']),
|
||||
new Set(['A','B','C','D','E','F','G']),
|
||||
new Set(['A','B','C','D','F','G']),
|
||||
];
|
||||
|
||||
function computeButtons(rect) {
|
||||
const padX = 8;
|
||||
const padY = 0;
|
||||
const external = isExternalClient();
|
||||
let gap = external ? 6 : 8;
|
||||
let btnH = external ? 32 : 39;
|
||||
const content = computeContentRect(rect, padX);
|
||||
const btnW = Math.max(60, content.w);
|
||||
const availableH = Math.max(84, rect.h - 18);
|
||||
const wantedH = btnH * 3 + gap * 2;
|
||||
if (wantedH > availableH) {
|
||||
const scale = availableH / wantedH;
|
||||
btnH = Math.max(external ? 18 : 24, Math.floor(btnH * scale));
|
||||
gap = Math.max(external ? 2 : 3, Math.floor(gap * scale));
|
||||
}
|
||||
const x = content.x;
|
||||
const totalH = btnH * 3 + gap * 2;
|
||||
const yBottom = rect.y + rect.h - padY;
|
||||
const y0 = yBottom - totalH;
|
||||
const r1 = { x, y: y0, w: btnW, h: btnH };
|
||||
const deleteToggleW = Math.max(btnH, Math.min(Math.floor(btnW * 0.28), external ? 42 : 52));
|
||||
const resetW = Math.max(1, btnW - gap - deleteToggleW);
|
||||
const r2 = { x, y: y0 + btnH + gap, w: resetW, h: btnH };
|
||||
const rDelete = { x: x + resetW + gap, y: r2.y, w: deleteToggleW, h: btnH };
|
||||
const r3 = { x, y: y0 + (btnH + gap) * 2, w: btnW, h: btnH };
|
||||
return { startStop: r1, reset: r2, deleteToggle: rDelete, auto: r3, yTop: y0 };
|
||||
}
|
||||
|
||||
function pointInRect(x, y, r) {
|
||||
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
|
||||
}
|
||||
|
||||
function getActiveEntry(shared) {
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
if (!entries.length) return null;
|
||||
return entries[entries.length - 1] || null;
|
||||
}
|
||||
|
||||
function isRunning(shared) {
|
||||
const entry = getActiveEntry(shared);
|
||||
return !!entry?.running;
|
||||
}
|
||||
|
||||
function elapsedForEntry(entry, now) {
|
||||
const base = Number.isFinite(entry?.elapsedMs) ? entry.elapsedMs : 0;
|
||||
if (!entry?.running) return base;
|
||||
const startTs = Number.isFinite(entry?.startTs) ? entry.startTs : now;
|
||||
return base + Math.max(0, now - startTs);
|
||||
}
|
||||
|
||||
function sumAllEntriesMs(entries, now) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
let sum = 0;
|
||||
for (const entry of list) sum += elapsedForEntry(entry, now);
|
||||
return sum;
|
||||
}
|
||||
|
||||
function deleteEntry(shared, index) {
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
if (index < 0 || index >= entries.length) return false;
|
||||
const removed = entries.splice(index, 1)[0];
|
||||
if (removed?.running) shared._silenceStartTs = null;
|
||||
shared.scrollOffset = Math.max(0, shared.scrollOffset || 0);
|
||||
shared._dragScroll = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pointer(evt, rect, _CONFIG, shared) {
|
||||
if (!evt) return false;
|
||||
const x = Number(evt.x);
|
||||
const y = Number(evt.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
|
||||
const now = shared._lastNow || performance.now();
|
||||
const btns = shared._buttonRects || computeButtons(rect);
|
||||
const scroll = shared._scrollInfo;
|
||||
|
||||
if (evt.type === 'pointerdown') {
|
||||
if (shared._deleteVisible) {
|
||||
const deleteRects = Array.isArray(shared._deleteRects) ? shared._deleteRects : [];
|
||||
for (const hit of deleteRects) {
|
||||
if (hit?.rect && pointInRect(x, y, hit.rect)) {
|
||||
return deleteEntry(shared, hit.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === 'wheel') {
|
||||
if (!scroll || !scroll.scrollable || !pointInRect(x, y, scroll.listRect)) return false;
|
||||
const dy = Number(evt.deltaY);
|
||||
if (!Number.isFinite(dy) || dy === 0) return false;
|
||||
const sign = Math.sign(dy);
|
||||
const step = Math.max(1, Math.round(Math.abs(dy) / 100));
|
||||
// "Natural": scroll down => Richtung neueste (Offset ↓), scroll up => Richtung ältere (Offset ↑)
|
||||
shared.scrollOffset = clampInt((shared.scrollOffset || 0) - sign * step, 0, scroll.maxOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.startStop)) {
|
||||
// Manuelles Bedienen schaltet Auto aus, damit die Uhr nicht sofort wieder "zurückspringt".
|
||||
shared.auto = false;
|
||||
if (isRunning(shared)) stop(shared, now);
|
||||
else startNew(shared, now);
|
||||
return true;
|
||||
}
|
||||
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.reset)) {
|
||||
reset(shared, now);
|
||||
return true;
|
||||
}
|
||||
if (evt.type === 'pointerdown' && btns.deleteToggle && pointInRect(x, y, btns.deleteToggle)) {
|
||||
shared._deleteVisible = !shared._deleteVisible;
|
||||
shared._deleteRects = [];
|
||||
return true;
|
||||
}
|
||||
if (evt.type === 'pointerdown' && pointInRect(x, y, btns.auto)) {
|
||||
shared.auto = !shared.auto;
|
||||
shared._silenceStartTs = null;
|
||||
if (shared.auto) {
|
||||
const levelDb = Number.isFinite(shared._lastLevelDb) ? shared._lastLevelDb : -120;
|
||||
if (levelDb > getSilenceDb()) startNew(shared, now);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!scroll || !scroll.scrollable) return false;
|
||||
|
||||
if (evt.type === 'pointerdown') {
|
||||
if (pointInRect(x, y, scroll.listRect)) {
|
||||
shared._dragScroll = { startY: y, startOffset: shared.scrollOffset || 0 };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt.type === 'pointermove') {
|
||||
if (!shared._dragScroll) return false;
|
||||
const dy = y - shared._dragScroll.startY;
|
||||
const stepPx = Math.max(6, Number(scroll.lineStepPx) || 24);
|
||||
const deltaLines = Math.round(dy / stepPx);
|
||||
// "Natural": Finger nach unten => ältere Einträge (Offset ↑), Finger nach oben => neuere (Offset ↓)
|
||||
shared.scrollOffset = clampInt((shared._dragScroll.startOffset || 0) + deltaLines, 0, scroll.maxOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (evt.type === 'pointerup' || evt.type === 'pointercancel') {
|
||||
if (!shared._dragScroll) return false;
|
||||
shared._dragScroll = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function draw(g, rect, _CONFIG, shared) {
|
||||
g.save();
|
||||
try {
|
||||
const now = performance.now();
|
||||
const entries = Array.isArray(shared.entries) ? shared.entries : [];
|
||||
const style = (_CONFIG?.STOPWATCH_DISPLAY_STYLE === 'seven') ? 'seven' : 'mono';
|
||||
const external = isExternalClient();
|
||||
const padX = 8;
|
||||
const content = computeContentRect(rect, padX);
|
||||
|
||||
// Header-Hintergrund säubern (wie andere Meter)
|
||||
g.save();
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 26);
|
||||
g.restore();
|
||||
|
||||
// Titel / Status
|
||||
g.save();
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
const status = shared.auto ? 'AUTO' : (isRunning(shared) ? 'RUN' : 'STOP');
|
||||
g.fillText(`Stoppuhr (${status})`, content.cx, rect.y - 12);
|
||||
g.restore();
|
||||
|
||||
// Zeit-Anzeige (oben) + Historie darunter (jede Start-Aktion erzeugt eine neue Zeile)
|
||||
const btns = computeButtons(rect);
|
||||
shared._buttonRects = btns;
|
||||
const contentTop = rect.y + 10;
|
||||
const contentBottom = btns.yTop - 4;
|
||||
const contentH = Math.max(0, contentBottom - contentTop);
|
||||
|
||||
const timeW = content.w;
|
||||
const listRect = { x: content.x, y: contentTop, w: timeW, h: contentH };
|
||||
const deleteBtnW = Math.max(external ? 18 : 22, Math.min(external ? 24 : 28, Math.floor(content.w * 0.16)));
|
||||
const deleteGap = Math.max(external ? 4 : 6, Math.round(deleteBtnW * 0.28));
|
||||
const canDeleteRows = content.w >= (external ? 88 : 112);
|
||||
const showDeleteRows = !!shared._deleteVisible && canDeleteRows;
|
||||
const rowTimeW = showDeleteRows ? Math.max(1, content.w - deleteBtnW - deleteGap) : content.w;
|
||||
const rowTimeCx = showDeleteRows ? content.x + rowTimeW / 2 : content.cx;
|
||||
const deleteX = content.x + content.w - deleteBtnW;
|
||||
const deleteRects = [];
|
||||
|
||||
const drawDeleteBtn = (r, active = false) => {
|
||||
g.save();
|
||||
g.fillStyle = active ? 'rgba(255,59,59,0.18)' : 'rgba(255,59,59,0.1)';
|
||||
g.strokeStyle = active ? '#ff3b3b' : 'rgba(255,120,120,0.65)';
|
||||
g.lineWidth = 1.2;
|
||||
g.beginPath();
|
||||
roundedRect(g, r.x, r.y, r.w, r.h, 5);
|
||||
g.fill();
|
||||
g.stroke();
|
||||
g.fillStyle = '#ffd6d6';
|
||||
const labelSize = Math.max(9, Math.min(13, Math.floor(r.h * 0.56)));
|
||||
g.font = `bold ${labelSize}px ui-monospace, monospace`;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.fillText('X', r.x + r.w / 2, r.y + r.h / 2 + 0.5);
|
||||
g.restore();
|
||||
};
|
||||
|
||||
if (style === 'seven') {
|
||||
const lineGap = 8;
|
||||
const totalText = formatTime(sumAllEntriesMs(entries, now));
|
||||
const entrySizingTexts = entries.length
|
||||
? entries.map((e) => formatTime(elapsedForEntry(e, now)))
|
||||
: [formatTime(0)];
|
||||
const entryRatioMax = Math.max(...entrySizingTexts.map((t) => sevenSegTotalRatio(t)));
|
||||
const totalRatio = sevenSegTotalRatio(totalText);
|
||||
|
||||
// Einzelzeiten und Summe getrennt skalieren: nur die Zeilen reservieren Platz für die X-Buttons.
|
||||
let segH = Math.floor(rowTimeW / Math.max(1e-6, entryRatioMax));
|
||||
let totalSegH = Math.floor(timeW / Math.max(1e-6, totalRatio));
|
||||
segH = Math.max(external ? 10 : 12, Math.min(external ? 108 : 140, segH));
|
||||
totalSegH = Math.max(external ? 10 : 12, Math.min(external ? 108 : 140, totalSegH));
|
||||
// Höhe begrenzen: mindestens 1 Eintrag + Summenzeile muss passen.
|
||||
while (segH > (external ? 10 : 12) || totalSegH > (external ? 10 : 12)) {
|
||||
const refH = Math.max(segH, totalSegH);
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refH * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refH * (external ? 0.12 : 0.18)));
|
||||
const needed = segH + totalSegH + lineGap + topPad + bottomPad;
|
||||
if (needed <= contentH) break;
|
||||
if (totalSegH > segH) totalSegH -= 1;
|
||||
else segH -= 1;
|
||||
}
|
||||
const refH = Math.max(segH, totalSegH);
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refH * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refH * (external ? 0.12 : 0.18)));
|
||||
const stepY = segH + (external ? 5 : lineGap);
|
||||
|
||||
const totalY = Math.floor(contentBottom - bottomPad - totalSegH);
|
||||
const ySep = Math.round(totalY - topPad) + 0.5;
|
||||
|
||||
const entryAreaH = Math.max(0, ySep - contentTop);
|
||||
const maxEntryByHeight = Math.max(1, Math.floor((entryAreaH + lineGap) / Math.max(1, stepY)));
|
||||
const displayEntryLines = maxEntryByHeight;
|
||||
|
||||
const maxOffset = Math.max(0, entries.length - displayEntryLines);
|
||||
shared.scrollOffset = clampInt(shared.scrollOffset || 0, 0, maxOffset);
|
||||
const start = Math.max(0, entries.length - displayEntryLines - (shared.scrollOffset || 0));
|
||||
const visible = entries.length ? entries.slice(start, start + displayEntryLines) : [];
|
||||
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const entry = visible[i];
|
||||
const running = !!entry?.running;
|
||||
const y = contentTop + i * stepY;
|
||||
const col = running ? '#00e7ff' : 'rgba(255,255,255,0.9)';
|
||||
drawSevenSegString(g, formatTime(elapsedForEntry(entry, now)), rowTimeCx, y, rowTimeW, segH, col);
|
||||
if (showDeleteRows) {
|
||||
const r = { x: deleteX, y: y + Math.max(0, (segH - deleteBtnW) / 2), w: deleteBtnW, h: Math.min(deleteBtnW, segH) };
|
||||
deleteRects.push({ index: start + i, rect: r });
|
||||
drawDeleteBtn(r, running);
|
||||
}
|
||||
}
|
||||
|
||||
// Trennlinie über der Summenzeile (10. Zeile bleibt unten fix)
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(0,231,255,0.55)';
|
||||
g.setLineDash([6, 5]);
|
||||
g.beginPath();
|
||||
g.moveTo(content.x, ySep);
|
||||
g.lineTo(content.x + timeW, ySep);
|
||||
g.stroke();
|
||||
g.setLineDash([]);
|
||||
g.restore();
|
||||
|
||||
drawSevenSegString(g, totalText, content.cx, totalY, timeW, totalSegH, '#34d399');
|
||||
|
||||
const scrollable = maxOffset > 0;
|
||||
const entryRect = { x: content.x, y: contentTop, w: timeW, h: entryAreaH };
|
||||
shared._scrollInfo = { scrollable, maxLines: displayEntryLines, maxOffset, listRect: entryRect, lineStepPx: stepY };
|
||||
} else {
|
||||
const totalText = formatTime(sumAllEntriesMs(entries, now));
|
||||
const sampleEntries = entries;
|
||||
const sampleTexts = sampleEntries.length ? sampleEntries.map((e) => formatTime(elapsedForEntry(e, now))) : [formatTime(0)];
|
||||
let fontSize = fitFontSizeToWidth(g, sampleTexts, rowTimeW, { min: external ? 12 : 14, max: external ? 108 : 140 });
|
||||
let totalFontSize = fitFontSizeToWidth(g, [totalText], timeW, { min: external ? 12 : 14, max: external ? 108 : 140 });
|
||||
fontSize = Math.max(external ? 12 : 14, Math.min(external ? 108 : 140, fontSize));
|
||||
totalFontSize = Math.max(external ? 12 : 14, Math.min(external ? 108 : 140, totalFontSize));
|
||||
// Höhe begrenzen: mindestens 1 Eintrag + Summenzeile muss passen
|
||||
while (fontSize > (external ? 12 : 14) || totalFontSize > (external ? 12 : 14)) {
|
||||
const refSize = Math.max(fontSize, totalFontSize);
|
||||
const lineGap = Math.max(external ? 2 : 4, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refSize * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const needed = fontSize + totalFontSize + lineGap + topPad + bottomPad;
|
||||
if (needed <= contentH) break;
|
||||
if (totalFontSize > fontSize) totalFontSize -= 1;
|
||||
else fontSize -= 1;
|
||||
}
|
||||
const refSize = Math.max(fontSize, totalFontSize);
|
||||
const lineGap = Math.max(external ? 2 : 4, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const topPad = Math.max(external ? 6 : 10, Math.round(refSize * (external ? 0.18 : 0.28)));
|
||||
const bottomPad = Math.max(external ? 5 : 8, Math.round(refSize * (external ? 0.12 : 0.18)));
|
||||
const stepY = fontSize + lineGap;
|
||||
|
||||
const totalY = Math.floor(contentBottom - bottomPad - totalFontSize);
|
||||
const ySep = Math.round(totalY - topPad) + 0.5;
|
||||
const entryAreaH = Math.max(0, ySep - contentTop);
|
||||
const maxEntryByHeight = Math.max(1, Math.floor((entryAreaH + lineGap) / Math.max(1, stepY)));
|
||||
const displayEntryLines = maxEntryByHeight;
|
||||
|
||||
const maxOffset = Math.max(0, entries.length - displayEntryLines);
|
||||
shared.scrollOffset = clampInt(shared.scrollOffset || 0, 0, maxOffset);
|
||||
const start = Math.max(0, entries.length - displayEntryLines - (shared.scrollOffset || 0));
|
||||
const visible = entries.slice(start, start + displayEntryLines);
|
||||
|
||||
g.save();
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'top';
|
||||
g.font = `bold ${fontSize}px ui-monospace, monospace`;
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const entry = visible[i];
|
||||
const running = !!entry?.running;
|
||||
const y = contentTop + i * stepY;
|
||||
const ms = elapsedForEntry(entry, now);
|
||||
g.fillStyle = running ? '#00e7ff' : 'rgba(255,255,255,0.9)';
|
||||
g.fillText(formatTime(ms), rowTimeCx, y);
|
||||
if (showDeleteRows) {
|
||||
const btnH = Math.max(16, Math.min(deleteBtnW, fontSize));
|
||||
const r = { x: deleteX, y: y + Math.max(0, (fontSize - btnH) / 2), w: deleteBtnW, h: btnH };
|
||||
deleteRects.push({ index: start + i, rect: r });
|
||||
drawDeleteBtn(r, running);
|
||||
}
|
||||
}
|
||||
|
||||
g.save();
|
||||
g.strokeStyle = 'rgba(0,231,255,0.55)';
|
||||
g.setLineDash([6, 5]);
|
||||
g.beginPath();
|
||||
g.moveTo(content.x, ySep);
|
||||
g.lineTo(content.x + timeW, ySep);
|
||||
g.stroke();
|
||||
g.setLineDash([]);
|
||||
g.restore();
|
||||
|
||||
g.fillStyle = '#34d399';
|
||||
g.font = `bold ${totalFontSize}px ui-monospace, monospace`;
|
||||
g.fillText(totalText, content.cx, totalY);
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.restore();
|
||||
|
||||
const scrollable = maxOffset > 0;
|
||||
const entryRect = { x: content.x, y: contentTop, w: timeW, h: entryAreaH };
|
||||
shared._scrollInfo = { scrollable, maxLines: displayEntryLines, maxOffset, listRect: entryRect, lineStepPx: stepY };
|
||||
}
|
||||
shared._deleteRects = deleteRects;
|
||||
|
||||
// Buttons
|
||||
const drawBtn = (r, label, active = false, warn = false, { blink = false } = {}) => {
|
||||
g.save();
|
||||
if (active && blink) {
|
||||
const phase = (now % 800) / 800; // 0..1
|
||||
const pulse = 0.08 + 0.22 * (0.5 + 0.5 * Math.sin(phase * Math.PI * 2));
|
||||
g.fillStyle = `rgba(0,231,255,${pulse.toFixed(3)})`;
|
||||
} else {
|
||||
g.fillStyle = active ? 'rgba(0,231,255,0.18)' : 'rgba(180,200,220,0.08)';
|
||||
}
|
||||
g.strokeStyle = warn ? '#ff3b3b' : (active ? '#00e7ff' : 'rgba(0,231,255,0.55)');
|
||||
g.lineWidth = 1.5;
|
||||
g.beginPath();
|
||||
const rad = 6;
|
||||
roundedRect(g, r.x, r.y, r.w, r.h, rad);
|
||||
g.fill();
|
||||
g.stroke();
|
||||
g.fillStyle = warn ? '#ffdddd' : '#e7f6ff';
|
||||
const labelSize = Math.max(external ? 8 : 10, Math.min(external ? 11 : 12, Math.floor(r.h * (external ? 0.32 : 0.34))));
|
||||
g.font = `bold ${labelSize}px ui-monospace, monospace`;
|
||||
g.textAlign = 'center';
|
||||
g.fillText(label, r.x + r.w / 2, r.y + r.h / 2 + Math.max(3, Math.round(labelSize * 0.32)));
|
||||
g.restore();
|
||||
};
|
||||
|
||||
drawBtn(btns.startStop, isRunning(shared) ? 'Stop' : 'Start', isRunning(shared));
|
||||
drawBtn(btns.reset, 'Reset', false, true);
|
||||
drawBtn(btns.deleteToggle, 'X', !!shared._deleteVisible, true);
|
||||
drawBtn(btns.auto, 'Auto', shared.auto, false, { blink: true });
|
||||
} finally {
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function roundedRect(g, x, y, w, h, r) {
|
||||
const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||
g.moveTo(x + rr, y);
|
||||
g.arcTo(x + w, y, x + w, y + h, rr);
|
||||
g.arcTo(x + w, y + h, x, y + h, rr);
|
||||
g.arcTo(x, y + h, x, y, rr);
|
||||
g.arcTo(x, y, x + w, y, rr);
|
||||
g.closePath();
|
||||
}
|
||||
|
||||
function clampInt(v, lo, hi) {
|
||||
const n = Math.round(Number(v));
|
||||
if (!Number.isFinite(n)) return lo;
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// meters/tp.js — True Peak (dBTP) L/R mit nichtlinearer Skala, Warnschwelle und Glanzkante
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
export const id = 'tp';
|
||||
|
||||
const TP_PERCENT_SCALE = [
|
||||
{ db: -60, percent: '0,00 %', frac: 0.0000 },
|
||||
{ db: -50, percent: '16,67 %', frac: 0.0373 },
|
||||
{ db: -40, percent: '33,33 %', frac: 0.1429 },
|
||||
{ db: -35, percent: '41,67 %', frac: 0.2112 },
|
||||
{ db: -30, percent: '50,00 %', frac: 0.2857 },
|
||||
{ db: -25, percent: '58,33 %', frac: 0.3851 },
|
||||
{ db: -20, percent: '66,67 %', frac: 0.4783 },
|
||||
{ db: -15, percent: '75,00 %', frac: 0.6087 },
|
||||
{ db: -10, percent: '83,33 %', frac: 0.7391 },
|
||||
{ db: -5, percent: '91,67 %', frac: 0.8696 },
|
||||
{ db: 0, percent: '100,00 %', frac: 1.0000 },
|
||||
];
|
||||
|
||||
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 = TP_PERCENT_SCALE[0].db;
|
||||
const LOG_TOP = TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db;
|
||||
|
||||
function interpolateFrac(db) {
|
||||
if (db <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
|
||||
if (db >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
|
||||
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
|
||||
const prev = TP_PERCENT_SCALE[i - 1];
|
||||
const curr = TP_PERCENT_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 TP_PERCENT_SCALE[TP_PERCENT_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 innerW = Math.max(2, barW - 2);
|
||||
|
||||
const drawBar = (x0, val) => {
|
||||
const yVal = mapY(val);
|
||||
const colNorm = CONFIG.TP_COLOR_NORMAL;
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// Balken-Overlay-Ticks auf Basis der Vintage-Prozent-Skala
|
||||
const EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
|
||||
|
||||
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 TP_PERCENT_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 EXTRA_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 TP_PERCENT_SCALE) {
|
||||
const y = mapY(point.db);
|
||||
if (y < colRect.y || y > colRect.y + colRect.h) continue;
|
||||
const value = Math.abs(point.db);
|
||||
const dbLabel = Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||||
|
||||
const VU_BOTTOM_DB = -20;
|
||||
const VU_TOP_DB = 3;
|
||||
const SCALE_TICKS_DB = [-20, -10, -7, -5, -3, 0, 1, 2, 3];
|
||||
const EXTRA_BAR_TICKS_DB = [-2, -1];
|
||||
const SKIP_AUTO_MINOR_DB = [-1.5];
|
||||
const VINTAGE_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 },
|
||||
];
|
||||
|
||||
// meters/vu.js — VU (L/R) mit Hold, Warnschwelle und Skala
|
||||
// Erwartet update(packet) mit packet.vuL / packet.vuR (in dBFS).
|
||||
|
||||
export const id = 'vu';
|
||||
|
||||
export function initShared(CONFIG) {
|
||||
const now = performance.now();
|
||||
return {
|
||||
values: { L: VU_BOTTOM_DB, R: VU_BOTTOM_DB },
|
||||
hold: { L: VU_BOTTOM_DB, R: VU_BOTTOM_DB },
|
||||
_holdState: {
|
||||
L: createPeakHoldState(VU_BOTTOM_DB, now, CONFIG?.VU_HOLD_MS ?? 600),
|
||||
R: createPeakHoldState(VU_BOTTOM_DB, now, CONFIG?.VU_HOLD_MS ?? 600),
|
||||
},
|
||||
calDbfs: CONFIG.VU_DBFS_REF,
|
||||
offset: CONFIG.VU_OFFSET_DB,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
// packet.vuL / vuR sind dBFS (durch Worklet als RMS/VU-integriert geliefert)
|
||||
const L = Number.isFinite(packet.vuL) ? packet.vuL : -60;
|
||||
const R = Number.isFinite(packet.vuR) ? packet.vuR : -60;
|
||||
shared.values.L = L - shared.calDbfs + shared.offset; // in VU
|
||||
shared.values.R = R - shared.calDbfs + shared.offset; // in VU
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG, shared) {
|
||||
const VU_TOP = VU_TOP_DB, VU_BOTTOM = VU_BOTTOM_DB;
|
||||
const clampVU = (v) => Math.max(VU_BOTTOM, Math.min(VU_TOP, v));
|
||||
const deflectionFrac = (dB) => {
|
||||
const table = VINTAGE_DEFLECTION;
|
||||
if (dB <= table[0].db) return table[0].frac;
|
||||
if (dB >= table[table.length - 1].db) return table[table.length - 1].frac;
|
||||
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;
|
||||
return prev.frac + t * (curr.frac - prev.frac);
|
||||
}
|
||||
}
|
||||
return table[table.length - 1].frac;
|
||||
};
|
||||
// Ensure scale labels, ticks and bar deflection share the same DIN-vintage mapping.
|
||||
const mapY = (dB) => {
|
||||
const clamped = clampVU(dB);
|
||||
const frac = deflectionFrac(clamped);
|
||||
const yBot = rect.y + rect.h;
|
||||
return yBot - frac * rect.h;
|
||||
};
|
||||
|
||||
|
||||
// Live-Offset anwenden: UI-Änderungen wirken sofort
|
||||
const effOff = Number(CONFIG && CONFIG.VU_OFFSET_DB) || 0;
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
|
||||
|
||||
|
||||
// Layout: zwei vertikale Balken + mittige Skala
|
||||
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 centerLeft = leftX + barW / 2;
|
||||
const centerRight = rightX + barW / 2;
|
||||
|
||||
// Werte
|
||||
const rawVuL = shared.values.L + offCorr;
|
||||
const rawVuR = shared.values.R + offCorr;
|
||||
const vuL = clampVU(rawVuL);
|
||||
const vuR = clampVU(rawVuR);
|
||||
const smooth = smoothHeader(shared, rawVuL, rawVuR);
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
// Header-Hintergrund säubern, damit keine alten Werte liegen bleiben
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.VU_HEADER_SHOW_VALUE && (rawVuL > CONFIG.VU_RED_START || rawVuR > CONFIG.VU_RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.VU_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
const isRedL = rawVuL > CONFIG.VU_RED_START;
|
||||
const isRedR = rawVuR > CONFIG.VU_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('Volume Units', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
const yFloor = mapY(VU_BOTTOM);
|
||||
const yRed = mapY(CONFIG.VU_RED_START);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const drawVuBar = (x0, vu) => {
|
||||
const yVal = mapY(vu);
|
||||
const colNorm = CONFIG.VU_COLOR_NORMAL;
|
||||
const colWarn = CONFIG.VU_COLOR_WARN;
|
||||
|
||||
if (vu > CONFIG.VU_RED_START) {
|
||||
if (CONFIG.VU_RED_BAR_ONLY) {
|
||||
// Normaler Teil unten
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, Math.min(yRed, yFloor), innerW, Math.max(0, yFloor - yRed));
|
||||
// Roter Teil oben
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, Math.min(yVal, yRed), 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));
|
||||
}
|
||||
|
||||
// leichte Glanzkante
|
||||
g.globalAlpha = .12; g.fillStyle = '#fff'; g.fillRect(x0 + 1, yVal, innerW, 2); g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
// Balken zeichnen
|
||||
drawVuBar(leftX, vuL);
|
||||
|
||||
// Red stripe overlay (left)
|
||||
drawRedStripe(g, {x:leftX, w:innerW}, centerX, mapY);
|
||||
drawVuBar(rightX, vuR);
|
||||
// Overlay ticks on bars
|
||||
drawOverlayTicksLR(g, leftX, rightX, innerW, mapY, VU_TOP, VU_BOTTOM, SCALE_TICKS_DB, getVuAlignmentTick(CONFIG));
|
||||
|
||||
|
||||
// Red stripe overlay (right)
|
||||
drawRedStripe(g, {x:rightX, w:innerW}, centerX, mapY);
|
||||
const now = performance.now();
|
||||
const holdMs = (CONFIG.VU_HOLD_MS ?? 600);
|
||||
const decayDbPerS = (CONFIG.VU_DECAY_DB_PER_S ?? 35);
|
||||
const holdOpts = {
|
||||
holdMs,
|
||||
decayDbPerS,
|
||||
floor: VU_BOTTOM,
|
||||
riseThreshold: 0.2,
|
||||
};
|
||||
if (!shared._holdState) {
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold?.L ?? VU_BOTTOM, now, holdMs),
|
||||
R: createPeakHoldState(shared.hold?.R ?? VU_BOTTOM, now, holdMs),
|
||||
};
|
||||
}
|
||||
shared.hold.L = stepPeakHold(vuL, shared._holdState.L, now, holdOpts);
|
||||
shared.hold.R = stepPeakHold(vuR, shared._holdState.R, now, holdOpts);
|
||||
|
||||
// Hold-Anzeigen
|
||||
g.save();
|
||||
g.fillStyle = '#fff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
|
||||
drawVuStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
}, mapY);
|
||||
}
|
||||
|
||||
function drawVuStaticOverlay(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, 'vu-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, CONFIG);
|
||||
drawRedStripe(cg, { x: leftX, w: innerW }, centerX, mapY);
|
||||
drawRedStripe(cg, { x: rightX, w: innerW }, centerX, mapY);
|
||||
drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, VU_TOP_DB, VU_BOTTOM_DB, SCALE_TICKS_DB, getVuAlignmentTick(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('dB (VU)', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function drawRedStripe(g, rectLite, centerX, mapY) {
|
||||
// Red zone = [0..+3] dB
|
||||
const yTop = mapY(VU_TOP_DB);
|
||||
const y0 = mapY(0);
|
||||
const y1 = Math.min(y0, yTop), y2 = Math.max(y0, yTop);
|
||||
const isLeft = (rectLite.x + rectLite.w/2) < centerX;
|
||||
const innerEdge = isLeft ? (rectLite.x + rectLite.w) : rectLite.x;
|
||||
const gap = Math.abs(centerX - innerEdge);
|
||||
const inset = Math.max(1, Math.floor(gap * 0.35));
|
||||
const cx = Math.round(isLeft ? (innerEdge + inset) : (innerEdge - inset)) + 0.5;
|
||||
g.save();
|
||||
g.strokeStyle = WARN_COLOR;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath();
|
||||
g.moveTo(cx, y1);
|
||||
g.lineTo(cx, y2);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
|
||||
// Dünner roter Mittelstrich für 0..+3 dB
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, TOP, BOTTOM, majors, alignmentTick = null) {
|
||||
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 minorW = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const x1L = Math.round(cxL - minorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - minorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const top = TOP, bot = BOTTOM;
|
||||
const hi = Math.max(top, bot), lo = Math.min(top, bot);
|
||||
const inRange = (v) => v <= hi && v >= lo;
|
||||
|
||||
if (Array.isArray(majors) && majors.length > 0) {
|
||||
const sorted = majors.slice().sort((a, b) => b - a); // desc (top->bottom)
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const vMaj = sorted[i];
|
||||
if (inRange(vMaj)) {
|
||||
const yMaj = Math.round(mapY(vMaj)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(vMaj, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMajor(yMaj, isHighlight);
|
||||
}
|
||||
if (i < sorted.length - 1) {
|
||||
const vNext = sorted[i + 1];
|
||||
const mid = (vMaj + vNext) / 2;
|
||||
const skip = SKIP_AUTO_MINOR_DB.some((s) => Math.abs(mid - s) < 1e-3);
|
||||
if (inRange(mid) && !skip) {
|
||||
const yMin = Math.round(mapY(mid)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(mid, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMinor(yMin, isHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of EXTRA_BAR_TICKS_DB) {
|
||||
if (!inRange(extra)) continue;
|
||||
const yExtra = Math.round(mapY(extra)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(extra, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMinor(yExtra, isHighlight);
|
||||
}
|
||||
|
||||
if (highlightValue !== null && inRange(highlightValue) && !highlightMatched) {
|
||||
const y = Math.round(mapY(highlightValue)) + 0.5;
|
||||
drawMajor(y, true);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||||
if (!shared._header) {
|
||||
shared._header = { L: rawL, R: rawR };
|
||||
return shared._header;
|
||||
}
|
||||
shared._header.L += alpha * (rawL - shared._header.L);
|
||||
shared._header.R += alpha * (rawR - shared._header.R);
|
||||
return shared._header;
|
||||
}
|
||||
|
||||
function drawScale(g, colRect, centerX, mapY, CONFIG) {
|
||||
drawHairlineGrid(g, colRect, mapY, VU_TOP_DB, VU_BOTTOM_DB, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
for (const step of VINTAGE_DEFLECTION) {
|
||||
const y = mapY(step.db);
|
||||
if (y < colRect.y - 0.5 || y > colRect.y + colRect.h + 0.5) continue;
|
||||
const dbLabel = step.db > 0 ? `+${step.db}` : `${step.db}`;
|
||||
g.fillText(dbLabel, centerX, y + 5);
|
||||
}
|
||||
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function getVuAlignmentTick(CONFIG) {
|
||||
if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null;
|
||||
return { value: -4, color: WARN_COLOR };
|
||||
}
|
||||
Reference in New Issue
Block a user