865 lines
28 KiB
JavaScript
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;
|
|
}
|