Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,868 @@
|
||||
// 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';
|
||||
|
||||
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 TP_PERCENT_SCALE = [
|
||||
{ db: -60, frac: 0.0000 },
|
||||
{ db: -50, frac: 0.0373 },
|
||||
{ db: -40, frac: 0.1429 },
|
||||
{ db: -35, frac: 0.2112 },
|
||||
{ db: -30, frac: 0.2857 },
|
||||
{ db: -25, frac: 0.3851 },
|
||||
{ db: -20, frac: 0.4783 },
|
||||
{ db: -15, frac: 0.6087 },
|
||||
{ db: -10, frac: 0.7391 },
|
||||
{ db: -5, frac: 0.8696 },
|
||||
{ db: 0, frac: 1.0000 },
|
||||
];
|
||||
|
||||
const TP_EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
|
||||
|
||||
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());
|
||||
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 = 0;
|
||||
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 <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
|
||||
if (clamped >= 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 (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 TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
|
||||
};
|
||||
const valueToNorm = (v) => clamp01(mapRaw(v));
|
||||
return {
|
||||
id: meterId,
|
||||
kind: 'tp',
|
||||
bottom,
|
||||
top,
|
||||
redStart,
|
||||
warnColor,
|
||||
normalColor,
|
||||
unitLabel: 'dBTP',
|
||||
majorTicks: TP_PERCENT_SCALE.map((p) => p.db),
|
||||
minorTicks: TP_EXTRA_MINOR_TICKS,
|
||||
formatMajor: (db) => {
|
||||
const v = Math.abs(db);
|
||||
return Number.isInteger(v) ? String(v) : v.toFixed(1);
|
||||
},
|
||||
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 : +4;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user