Files
Phoenix/www/meters/ppm_ebu.js
T

405 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: -12 … +12 dB
// - 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 = 24 / 2.8;
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 -12 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 : -12;
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.57 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 : +12;
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -12;
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 ?? -12,
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;
}