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
+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;
}