Files
Phoenix/www/views/classic_needles.js

865 lines
28 KiB
JavaScript

// views/classic_needles.js - VU-Meter als klassisches Nadelinstrument
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
import { drawCachedStaticLayer } from './static_layer.js';
import { TRUE_PEAK_MINOR_TICKS, TRUE_PEAK_SCALE } from '../meters/true_peak_scale.js';
export const id = 'classic-needles';
const PANEL_BG = 'rgba(5,5,11,0.78)';
const FRAME_STROKE = 'rgba(0,231,255,0.8)';
const SCALE_ARC_STROKE = 'rgb(0,0,255)'; // wie Tick-Linien der Balken-Meter
const METER_PIVOT_Y_FRAC = 0.70;
const METER_RADIUS_FRAC = 0.50;
const NEEDLE_CAP_RADIUS = 8;
const NEEDLE_SPRING_DAMPING = 0.92; // 1.0 = kritisch gedämpft (kein Überschwingen)
const NEEDLE_SPRING_HZ_UP = 6.5; // nur UI-Physik (VU-Ballistik ist bereits im Worklet)
const NEEDLE_SPRING_HZ_DOWN = 4.2;
const NEEDLE_GOAL_TAU_S = 0.04; // ganz leichtes Smoothing gegen UI-Stufen/Jitter
const FIELD_DIVIDER_STROKE = 'rgba(0,231,255,0.4)'; // wie Felder-Trennlinien
const FIELD_DIVIDER_DASH = [4, 3];
// Spannweite wie klassisches VU-Foto: kein Halbkreis, sondern flacherer Bogen
const ANGLE_START = degToRad(200) - Math.PI * 2; // links unten
const ANGLE_END = degToRad(340) - Math.PI * 2; // rechts unten (-20°)
const VU_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 },
];
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 PPM_EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
const PPM_DIN_MAJOR_TICKS = [-50, -40, -30, -20, -10, -5, 0, +5];
const PPM_DIN_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 init() {
return {
staticLayers: new Map(),
smooth: { L: null, R: null },
goal: { L: null, R: null },
vel: { L: 0, R: 0 },
lastTs: (typeof performance !== 'undefined' ? performance.now() : Date.now()),
};
}
export function resize() {}
export function destroy(state) {
if (state) state.staticLayers = null;
}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, meters, audio } = env;
const meterId = resolveNeedleMeterId(env);
if (!meterId) {
drawCachedStaticLayer(state, g, 'classic-empty', 'empty', rect, (cg) => {
cg.fillStyle = PANEL_BG;
cg.fillRect(0, 0, rect.w, rect.h);
cg.fillStyle = '#bcd';
cg.textAlign = 'left';
cg.font = 'bold 16px ui-monospace, monospace';
cg.fillText('Classic Needles', 12, 44);
cg.fillStyle = '#9aa';
cg.font = '14px ui-monospace, monospace';
cg.fillText('Slot leer — wähle ein Meter im HUD.', 12, 66);
});
return;
}
const scale = getNeedleScaleDescriptor(meterId, CONFIG);
const meterState = meters?.getState?.(meterId) || null;
const raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio);
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
if (meterId === 'rms') {
// RMS ballistics are already measured sample-continuously in the backend.
// A second spring model here would add view-dependent delay and values.
state.smooth.L = targets.L;
state.smooth.R = targets.R;
state.goal.L = targets.L;
state.goal.R = targets.R;
state.vel.L = 0;
state.vel.R = 0;
state.lastTs = now;
} else {
smoothNeedle(state, targets, now);
}
// Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets)
const BOX_LEFT = 0;
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
const BOX_RIGHT = 0;
const BOX_BOTTOM = 0;
const outerRect = {
x: rect.x + BOX_LEFT,
y: rect.y + BOX_TOP,
w: Math.max(140, rect.w - BOX_LEFT - BOX_RIGHT),
h: Math.max(140, rect.h - BOX_TOP - BOX_BOTTOM),
};
const INNER_INSET_X = Math.round(Math.min(140, outerRect.w * 0.08));
const innerRect = {
x: outerRect.x + INNER_INSET_X,
y: outerRect.y,
w: Math.max(140, outerRect.w - INNER_INSET_X * 2),
h: outerRect.h,
};
const innerGap = Math.max(56, innerRect.w * 0.12);
const availableW = Math.max(100, innerRect.w - innerGap);
const meterW = availableW / 2;
const meterH = outerRect.h;
const baseY = outerRect.y;
const decorMetrics = createDecorMetrics(meterW, innerGap);
const gapCenterX = innerRect.x + meterW + innerGap / 2;
const leftBoxClamp = decorMetrics.ownBounds
? {
x: outerRect.x,
y: outerRect.y,
w: Math.max(0, Math.floor(gapCenterX - outerRect.x)),
h: outerRect.h,
}
: outerRect;
const rightBoxClamp = decorMetrics.ownBounds
? {
x: Math.ceil(gapCenterX),
y: outerRect.y,
w: Math.max(0, (outerRect.x + outerRect.w) - Math.ceil(gapCenterX)),
h: outerRect.h,
}
: outerRect;
const metersRects = [
{ x: innerRect.x, y: baseY, w: meterW, h: meterH, label: 'L', norm: state.smooth.L, raw: raw.L, boxClamp: leftBoxClamp },
{ x: innerRect.x + meterW + innerGap, y: baseY, w: meterW, h: meterH, label: 'R', norm: state.smooth.R, raw: raw.R, boxClamp: rightBoxClamp },
];
drawCachedStaticLayer(
state,
g,
'classic-shell',
[
meterId,
rect.w,
rect.h,
outerRect.x,
outerRect.y,
outerRect.w,
outerRect.h,
innerRect.x,
innerRect.w,
innerGap,
scale.kind,
scale.bottom,
scale.top,
scale.redStart,
getFooterText(meterId, CONFIG),
JSON.stringify(scale.majorTicks || []),
JSON.stringify(scale.minorTicks || []),
].join('|'),
rect,
(cg) => {
cg.fillStyle = PANEL_BG;
cg.fillRect(0, 0, rect.w, rect.h);
cg.save();
cg.translate(-rect.x, -rect.y);
cg.strokeStyle = FRAME_STROKE;
cg.lineWidth = 2;
cg.strokeRect(outerRect.x, outerRect.y, outerRect.w, outerRect.h);
metersRects.forEach((m) => {
drawClassicMeter(cg, m, scale, decorMetrics);
drawMeterBox(cg, m, m.boxClamp || outerRect, decorMetrics);
drawRefLabel(cg, m, meterId, CONFIG, decorMetrics);
drawNeedleCap(cg, m);
});
cg.restore();
},
);
metersRects.forEach((m) => {
drawNeedle(g, m, m.norm, getNeedleColor(m.norm, scale));
});
}
function createDecorMetrics(meterW, innerGap) {
const widthT = clamp((meterW - 150) / 170, 0, 1);
const gapT = clamp((innerGap - 56) / 36, 0, 1);
const t = Math.min(widthT, gapT);
return {
ownBounds: meterW < 300 || innerGap < 72,
refFont: roundLerp(10, 12, t),
refOffsetY: roundLerp(7, 10, t),
boxOuterExtra: roundLerp(30, 48, t),
boxPadX: roundLerp(4, 10, t),
boxPadTop: roundLerp(6, 10, t),
boxPadBottom: roundLerp(10, 16, t),
channelFont: roundLerp(18, 26, t),
channelPad: roundLerp(5, 8, t),
majorFont: roundLerp(9, 13, t),
majorTickWarn: roundLerp(11, 16, t),
majorTickNormal: roundLerp(9, 14, t),
majorLabelOffset: roundLerp(14, 20, t),
unitFont: roundLerp(9, 12, t),
unitAngleOffset: lerp(0.10, 0.16, t),
unitOffset: roundLerp(24, 34, t),
pctFont: roundLerp(10, 16, t),
pctInset: roundLerp(30, 48, t),
pct100YOffset: roundLerp(5, 10, t),
pctSignFont: roundLerp(11, 18, t),
pctSignOffsetX: roundLerp(10, 18, t),
pctSignOffsetY: roundLerp(14, 28, t),
};
}
function drawRefLabel(g, rect, meterId, CONFIG, metrics) {
const modeText = getFooterText(meterId, CONFIG);
const refFont = metrics?.refFont ?? 12;
const refOffsetY = metrics?.refOffsetY ?? 10;
g.save();
g.fillStyle = 'rgba(207,233,255,0.7)';
g.font = `${refFont}px ui-monospace, monospace`;
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
g.fillText(modeText, rect.x + rect.w / 2, rect.y + rect.h - refOffsetY);
g.restore();
}
function drawMeterBox(g, rect, clampRect, metrics) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
// Include labels (+20) and "- dB" (+34) plus some safety margin
const outerR = radius + (metrics?.boxOuterExtra ?? 48);
const xSpan = Math.max(Math.abs(Math.cos(ANGLE_START)), Math.abs(Math.cos(ANGLE_END))) * outerR;
const padX = metrics?.boxPadX ?? 10;
const padTop = metrics?.boxPadTop ?? 10;
const padBottom = metrics?.boxPadBottom ?? 16;
let x = cx - xSpan - padX;
let y = cy - outerR - padTop;
let w = (xSpan + padX) * 2;
let h = (cy + NEEDLE_CAP_RADIUS + padBottom) - y;
const clampSource = clampRect || rect;
// Clamp to the available panel area. In compact layouts keep each box inside its own half.
const minX = (clampSource?.x ?? rect.x) + 2;
const minY = (clampSource?.y ?? rect.y) + 2;
const maxX = (clampSource?.x ?? rect.x) + (clampSource?.w ?? rect.w) - 2;
const maxY = (clampSource?.y ?? rect.y) + (clampSource?.h ?? rect.h) - 2;
if (x < minX) { w -= (minX - x); x = minX; }
if (y < minY) { h -= (minY - y); y = minY; }
if (x + w > maxX) w = maxX - x;
if (y + h > maxY) h = maxY - y;
if (w <= 2 || h <= 2) return;
g.save();
g.strokeStyle = FIELD_DIVIDER_STROKE;
g.lineWidth = 1;
g.setLineDash(FIELD_DIVIDER_DASH);
g.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, Math.round(w) - 1, Math.round(h) - 1);
g.setLineDash([]);
const chan = rect?.label;
if (chan === 'L' || chan === 'R') {
g.fillStyle = 'rgba(207,233,255,0.7)';
g.font = `bold ${metrics?.channelFont ?? 26}px ui-monospace, monospace`;
g.textBaseline = 'top';
const pad = metrics?.channelPad ?? 8;
if (chan === 'L') {
g.textAlign = 'left';
g.fillText('L', x + pad, y + pad - 2);
} else {
g.textAlign = 'right';
g.fillText('R', x + w - pad, y + pad - 2);
}
}
g.restore();
}
function getNeedleColor(norm, scale) {
const warn = scale?.warnColor || '#ff3b3b';
const normal = scale?.normalColor || '#ffe066';
if (Number.isFinite(scale?.redStart) && typeof scale?.valueToNorm === 'function') {
const redNorm = scale.valueToNorm(scale.redStart);
if (Number.isFinite(redNorm) && Number.isFinite(norm) && norm >= redNorm) return warn;
}
return normal;
}
function resolveNeedleMeterId(env) {
const slotList = env?.slots?.(id);
const selected = Array.isArray(slotList) ? slotList[0] : null;
if (selected === 'none') return null;
return selected || 'vu';
}
function getFooterText(meterId, CONFIG) {
if (meterId === 'vu') {
return Number.isFinite(CONFIG?.VU_DBFS_REF) ? `Ref ${CONFIG.VU_DBFS_REF.toFixed(1)} dBFS` : 'VU';
}
if (meterId === 'ppm-ebu') return 'PPM (EBU)';
if (meterId === 'ppm-din') return 'PPM (DIN)';
if (meterId === 'tp') return 'dBTP';
if (meterId === 'rms') {
const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu');
return isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
}
if (meterId === 'lufs') return 'LUFS (M)';
return String(meterId || 'meter');
}
function getNeedleScaleDescriptor(meterId, CONFIG) {
if (meterId === 'ppm-ebu') {
const bottom = Number.isFinite(CONFIG?.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
const top = Number.isFinite(CONFIG?.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14;
const redStart = Number.isFinite(CONFIG?.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
const warnColor = CONFIG?.PPM_EBU_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.PPM_EBU_COLOR_NORMAL || '#ffe066';
const valueToNorm = (v) => {
const clamped = clamp(v, bottom, top);
return clamp01((clamped - bottom) / (top - bottom || 1));
};
return {
id: meterId,
kind: 'ppm-ebu',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'dB',
majorTicks: PPM_EBU_MAJOR_TICKS,
minorTicks: PPM_EBU_MINOR_TICKS,
formatMajor: (db) => (db === 0 ? 'TEST' : (db > 0 ? `+${db}` : `${db}`)),
valueToNorm,
};
}
if (meterId === 'ppm-din') {
const bottom = Number.isFinite(CONFIG?.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
const top = Number.isFinite(CONFIG?.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5;
const redStart = Number.isFinite(CONFIG?.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0;
const warnColor = CONFIG?.PPM_DIN_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.PPM_DIN_COLOR_NORMAL || '#ffe066';
const mapRaw = (db) => {
const clamped = clamp(db, bottom, top);
let prev = DIN_SCALE[0];
for (let i = 1; i < DIN_SCALE.length; i++) {
const curr = DIN_SCALE[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 DIN_SCALE[DIN_SCALE.length - 1].pos;
};
const normBottom = mapRaw(bottom);
const normSpan = Math.max(1e-6, 1 - normBottom);
const valueToNorm = (v) => {
const n = mapRaw(v);
return clamp01((n - normBottom) / normSpan);
};
const showAl = CONFIG?.AL_MARKERS_ENABLED !== false;
const dynWarnDb = showAl ? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9) : null;
const minors = PPM_DIN_MINOR_TICKS.slice().map((t) => ({
value: t.db,
color: (t.color === 'warn' || (dynWarnDb !== null && t.db === dynWarnDb)) ? 'warn' : null,
}));
return {
id: meterId,
kind: 'ppm-din',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'dB',
majorTicks: PPM_DIN_MAJOR_TICKS,
minorTicks: minors,
formatMajor: (db) => (db > 0 ? `+${db}` : `${db}`),
valueToNorm,
};
}
if (meterId === 'tp') {
const bottom = -60;
const top = 6;
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
const mapRaw = (db) => {
const clamped = clamp(db, bottom, top);
if (clamped <= TRUE_PEAK_SCALE[0].db) return TRUE_PEAK_SCALE[0].frac;
if (clamped >= TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].db) return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
for (let i = 1; i < TRUE_PEAK_SCALE.length; i++) {
const prev = TRUE_PEAK_SCALE[i - 1];
const curr = TRUE_PEAK_SCALE[i];
if (clamped <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (clamped - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return TRUE_PEAK_SCALE[TRUE_PEAK_SCALE.length - 1].frac;
};
const valueToNorm = (v) => clamp01(mapRaw(v));
return {
id: meterId,
kind: 'tp',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'dBTP',
majorTicks: TRUE_PEAK_SCALE.map((p) => p.db),
minorTicks: TRUE_PEAK_MINOR_TICKS,
formatMajor: (db) => {
return db > 0 ? `+${db}` : String(db);
},
valueToNorm,
};
}
if (meterId === 'rms') {
const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu');
const bottom = -60;
const top = isDBU ? +24 : 0;
const redStart = Number.isFinite(CONFIG?.RMS_RED_START) ? CONFIG.RMS_RED_START : (isDBU ? +20 : 0);
const warnColor = CONFIG?.RMS_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.RMS_COLOR_NORMAL || '#34d399';
const valueToNorm = (db) => {
const v = clamp(db, bottom, top);
if (!isDBU) return clamp01((v - bottom) / (top - bottom || 1));
if (v <= -20) {
const t = (v + 60) / 40;
return clamp01(Math.pow(t, 1.6) * 0.45);
}
if (v <= 0) {
const t = (v + 20) / 20;
return clamp01(0.45 + t * 0.25);
}
if (v <= 20) {
const t = v / 20;
return clamp01(0.70 + t * 0.25);
}
if (v <= 24) {
const t = (v - 20) / 4;
return clamp01(0.95 + t * 0.05);
}
return 1;
};
const majors = isDBU
? [24, 20, 10, 0, -10, -20, -30, -40, -50, -60]
: [0, -6, -12, -18, -24, -30, -40, -60];
const minors = isDBU ? [] : [-3, -9, -15, -21, -27, -33, -36, -42, -48, -54];
return {
id: meterId,
kind: 'rms',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: isDBU ? 'dBu' : 'dBFS',
majorTicks: majors,
minorTicks: minors,
formatMajor: (v) => (v > 0 ? `+${v}` : `${v}`),
valueToNorm,
rmsMode: isDBU ? 'dbu' : 'dbfs',
};
}
if (meterId === 'lufs') {
const bottom = -50;
const top = -5;
const redStart = Number.isFinite(CONFIG?.LUFS_RED_START)
? CONFIG.LUFS_RED_START
: (Number.isFinite(CONFIG?.LUFS_YELLOW_START) ? CONFIG.LUFS_YELLOW_START : -2);
const warnColor = CONFIG?.LUFS_COLOR_RED || '#ff3b3b';
const normalColor = CONFIG?.LUFS_COLOR_YELLOW || '#ffe066';
const valueToNorm = (v) => {
const clamped = clamp(v, bottom, top);
return clamp01((clamped - bottom) / (top - bottom || 1));
};
return {
id: meterId,
kind: 'lufs',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'LUFS',
majorTicks: [-50, -40, -30, -23, -18, -10, -5],
minorTicks: [-45, -35, -25, -20, -15],
formatMajor: (v) => `${v}`,
valueToNorm,
};
}
// Default: VU (Classic-Needles Look)
{
const bottom = -20;
const top = +3;
const redStart = Number.isFinite(CONFIG?.VU_RED_START) ? CONFIG.VU_RED_START : 0;
const warnColor = CONFIG?.VU_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.VU_COLOR_NORMAL || '#ffe066';
const mapRaw = (dB) => {
const db = clamp(dB, bottom, top);
const table = VU_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;
};
const valueToNorm = (v) => clamp01(mapRaw(v));
return {
id: 'vu',
kind: 'vu',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: '- dB',
majorTicks: [-20, -10, -7, -5, -3, -1, 0, 1, 2, 3],
minorTicks: [-15, -12, -9, -8, -6, -4, -2],
formatMajor: (db) => {
if (db < 0) return String(Math.abs(db));
if (db === 3) return '*';
return String(db);
},
valueToNorm,
vuPercentMarks: true,
};
}
}
function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) {
const fallback = () => scale?.bottom ?? -60;
if (meterId === 'lufs') {
const v = Number.isFinite(shared?.momentary) ? shared.momentary : fallback();
return { L: v, R: v };
}
const hasLR = Number.isFinite(shared?.values?.L) || Number.isFinite(shared?.values?.R);
if (!shared || !hasLR) return { L: fallback(), R: fallback() };
let rawL = Number.isFinite(shared.values.L) ? shared.values.L : fallback();
let rawR = Number.isFinite(shared.values.R) ? shared.values.R : fallback();
if (meterId === 'vu') {
const effOff = Number.isFinite(CONFIG?.VU_OFFSET_DB) ? CONFIG.VU_OFFSET_DB : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'tp') {
const effOff = Number.isFinite(CONFIG?.TP_OFFSET_DB) ? CONFIG.TP_OFFSET_DB : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'ppm-ebu') {
const effOff = Number.isFinite(CONFIG?.PPM_EBU_OFFSET) ? CONFIG.PPM_EBU_OFFSET : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'ppm-din') {
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);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'rms') {
const effOff = Number.isFinite(CONFIG?.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
const mode = String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase();
const isDBU = (mode === 'dbu');
if (isDBU) {
const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : 0;
rawL = (rawL - refDbfs) + refDbu;
rawR = (rawR - refDbfs) + refDbu;
}
}
return { L: rawL, R: rawR };
}
function smoothNeedle(state, target, now) {
const prevTs = state.lastTs || now;
const dtFull = Math.max(1e-3, Math.min(0.20, (now - prevTs) / 1000));
const maxStep = 1 / 120;
const steps = Math.max(1, Math.ceil(dtFull / maxStep));
const dt = dtFull / steps;
if (!state.vel) state.vel = { L: 0, R: 0 };
if (!state.goal) state.goal = { L: null, R: null };
const step = (ch) => {
const goal = target[ch];
if (!Number.isFinite(goal)) return;
if (!Number.isFinite(state.smooth[ch])) {
state.smooth[ch] = goal;
state.vel[ch] = 0;
state.goal[ch] = goal;
return;
}
let x = state.smooth[ch];
let v = Number.isFinite(state.vel[ch]) ? state.vel[ch] : 0;
let g = Number.isFinite(state.goal[ch]) ? state.goal[ch] : goal;
for (let i = 0; i < steps; i++) {
const alphaGoal = 1 - Math.exp(-dt / NEEDLE_GOAL_TAU_S);
g = g + alphaGoal * (goal - g);
const rising = g > x;
const hz = rising ? NEEDLE_SPRING_HZ_UP : NEEDLE_SPRING_HZ_DOWN;
const omega = 2 * Math.PI * hz;
const zeta = NEEDLE_SPRING_DAMPING;
// Gedämpfter Feder-Masse-Dämpfer (semi-implicit Euler): x'' + 2ζω x' + ω²(x-goal)=0
const a = (omega * omega) * (g - x) - (2 * zeta * omega) * v;
v = v + a * dt;
x = x + v * dt;
}
state.goal[ch] = g;
state.vel[ch] = v;
state.smooth[ch] = x;
};
step('L');
step('R');
state.lastTs = now;
}
function drawClassicMeter(g, rect, scale, metrics) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
drawScale(g, cx, cy, radius, scale, metrics);
}
function drawScale(g, cx, cy, radius, scale, metrics) {
const mapValueToAngle = (v) => lerp(ANGLE_START, ANGLE_END, scale.valueToNorm(v));
const redStart = scale.redStart;
const warnCol = scale.warnColor || '#ff3b3b';
const majors = Array.isArray(scale.majorTicks) ? scale.majorTicks : [];
const minors = Array.isArray(scale.minorTicks) ? scale.minorTicks : [];
// Skalenbogen
g.save();
g.lineWidth = 3;
g.strokeStyle = SCALE_ARC_STROKE;
g.beginPath();
g.arc(cx, cy, radius, ANGLE_START, ANGLE_END);
g.stroke();
// Major ticks + Beschriftung
g.font = `bold ${metrics?.majorFont ?? 13}px ui-monospace, monospace`;
g.textAlign = 'center';
g.textBaseline = 'middle';
for (const v of majors) {
const ang = mapValueToAngle(v);
const inRed = Number.isFinite(redStart) && v >= redStart;
const tickCol = inRed ? warnCol : '#cfe9ff';
g.lineWidth = inRed ? 2.5 : 2;
g.strokeStyle = tickCol;
g.fillStyle = tickCol;
drawTick(g, cx, cy, radius, ang, inRed ? (metrics?.majorTickWarn ?? 16) : (metrics?.majorTickNormal ?? 14));
const labelOffset = metrics?.majorLabelOffset ?? 20;
const tx = cx + Math.cos(ang) * (radius + labelOffset);
const ty = cy + Math.sin(ang) * (radius + labelOffset);
const label = scale.formatMajor ? scale.formatMajor(v) : String(v);
g.fillText(label, tx, ty);
}
// Minor dots
const dotRad = Math.max(0, radius - 16);
const dotR = 2.2;
for (const tick of minors) {
const v = (typeof tick === 'number') ? tick : tick?.value;
if (!Number.isFinite(v)) continue;
const isWarn = (typeof tick === 'object' && tick?.color === 'warn');
g.fillStyle = isWarn ? warnCol : 'rgba(207,233,255,0.65)';
const ang = mapValueToAngle(v);
const x = cx + Math.cos(ang) * dotRad;
const y = cy + Math.sin(ang) * dotRad;
g.beginPath();
g.arc(x, y, dotR, 0, Math.PI * 2);
g.fill();
}
// Roter Bereich
if (Number.isFinite(redStart)) {
const redAng = mapValueToAngle(redStart);
g.strokeStyle = warnCol;
g.lineWidth = 4;
g.beginPath();
g.arc(cx, cy, radius, redAng, ANGLE_END);
g.stroke();
}
// Unit label oben rechts
if (scale.unitLabel) {
g.fillStyle = '#cfe9ff';
g.font = `bold ${metrics?.unitFont ?? 12}px ui-monospace, monospace`;
g.textBaseline = 'alphabetic';
const dbLabelAng = ANGLE_END - (metrics?.unitAngleOffset ?? 0.16);
const unitOffset = metrics?.unitOffset ?? 34;
g.fillText(scale.unitLabel, cx + Math.cos(dbLabelAng) * (radius + unitOffset), cy + Math.sin(dbLabelAng) * (radius + unitOffset));
}
// VU-Percent-Scale (wie Foto)
if (scale.vuPercentMarks) {
g.font = `bold ${metrics?.pctFont ?? 16}px ui-monospace, monospace`;
g.fillStyle = 'rgba(207,233,255,0.8)';
g.textBaseline = 'middle';
const pctR = Math.max(0, radius - (metrics?.pctInset ?? 48));
const posAtValue = (v) => {
const a = mapValueToAngle(v);
return { x: cx + Math.cos(a) * pctR, y: cy + Math.sin(a) * pctR };
};
const p10 = posAtValue(-20); // 10% ≙ -20 dB
const p50 = posAtValue(-6); // 50% ≙ -6 dB
const p100 = posAtValue(0); // 100% ≙ 0 dB
g.fillText('10', p10.x, p10.y);
g.fillText('50', p50.x, p50.y);
g.fillText('100', p100.x, p100.y + (metrics?.pct100YOffset ?? 10));
g.font = `bold ${metrics?.pctSignFont ?? 18}px ui-monospace, monospace`;
g.fillText('%', p100.x + (metrics?.pctSignOffsetX ?? 18), p100.y + (metrics?.pctSignOffsetY ?? 28));
}
// Glaslinie
g.strokeStyle = 'rgba(255,255,255,0.07)';
g.lineWidth = 8;
g.beginPath();
g.arc(cx, cy, radius - 12, ANGLE_START + 0.04, ANGLE_END - 0.04);
g.stroke();
g.restore();
}
function drawNeedle(g, rect, norm, color) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
const needleLen = radius * 0.9;
if (!Number.isFinite(norm)) return;
const t = clamp01(norm);
const ang = lerp(ANGLE_START, ANGLE_END, t);
const tipX = cx + Math.cos(ang) * needleLen;
const tipY = cy + Math.sin(ang) * needleLen;
const baseX = cx + Math.cos(ang + Math.PI) * (needleLen * 0.08);
const baseY = cy + Math.sin(ang + Math.PI) * (needleLen * 0.08);
g.save();
g.strokeStyle = color;
g.lineWidth = 3;
g.beginPath();
g.moveTo(baseX, baseY);
g.lineTo(tipX, tipY);
g.stroke();
g.restore();
}
function drawNeedleCap(g, rect) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const capRadius = NEEDLE_CAP_RADIUS;
g.save();
const grad = g.createRadialGradient(cx, cy, 2, cx, cy, capRadius * 1.5);
grad.addColorStop(0, '#0b111c');
grad.addColorStop(1, 'rgba(0,231,255,0.35)');
g.fillStyle = grad;
g.strokeStyle = '#00e7ff';
g.lineWidth = 2;
g.beginPath();
g.arc(cx, cy, capRadius, 0, Math.PI * 2);
g.fill();
g.stroke();
g.restore();
}
function drawTick(g, cx, cy, radius, ang, len) {
const x1 = cx + Math.cos(ang) * (radius - len);
const y1 = cy + Math.sin(ang) * (radius - len);
const x2 = cx + Math.cos(ang) * (radius + 2);
const y2 = cy + Math.sin(ang) * (radius + 2);
g.beginPath();
g.moveTo(x1, y1);
g.lineTo(x2, y2);
g.stroke();
}
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v));
}
function clamp01(v) {
return Math.min(1, Math.max(0, v));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function roundLerp(a, b, t) {
return Math.round(lerp(a, b, t));
}
function degToRad(d) {
return (d * Math.PI) / 180;
}