Initial Phoenix analyzer

This commit is contained in:
Mikei386
2026-06-02 20:56:19 +02:00
commit e499bac928
62 changed files with 28893 additions and 0 deletions
+334
View File
@@ -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));
}
+156
View File
@@ -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;
}
+571
View File
@@ -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;
}
+404
View File
@@ -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;
}
+503
View File
@@ -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;
}
+44
View File
@@ -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();
}
+49
View File
@@ -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;
}
+693
View File
@@ -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));
}
+317
View File
@@ -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;
}
+398
View File
@@ -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 };
}