Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
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';
|
||||
|
||||
const VU_BOTTOM_DB = -20;
|
||||
const VU_TOP_DB = 3;
|
||||
const SCALE_TICKS_DB = [-20, -10, -7, -5, -3, 0, 1, 2, 3];
|
||||
const EXTRA_BAR_TICKS_DB = [-2, -1];
|
||||
const SKIP_AUTO_MINOR_DB = [-1.5];
|
||||
const VINTAGE_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 },
|
||||
];
|
||||
|
||||
// meters/vu.js — VU (L/R) mit Hold, Warnschwelle und Skala
|
||||
// Erwartet update(packet) mit packet.vuL / packet.vuR (in dBFS).
|
||||
|
||||
export const id = 'vu';
|
||||
|
||||
export function initShared(CONFIG) {
|
||||
const now = performance.now();
|
||||
return {
|
||||
values: { L: VU_BOTTOM_DB, R: VU_BOTTOM_DB },
|
||||
hold: { L: VU_BOTTOM_DB, R: VU_BOTTOM_DB },
|
||||
_holdState: {
|
||||
L: createPeakHoldState(VU_BOTTOM_DB, now, CONFIG?.VU_HOLD_MS ?? 600),
|
||||
R: createPeakHoldState(VU_BOTTOM_DB, now, CONFIG?.VU_HOLD_MS ?? 600),
|
||||
},
|
||||
calDbfs: CONFIG.VU_DBFS_REF,
|
||||
offset: CONFIG.VU_OFFSET_DB,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(packet, shared) {
|
||||
// packet.vuL / vuR sind dBFS (durch Worklet als RMS/VU-integriert geliefert)
|
||||
const L = Number.isFinite(packet.vuL) ? packet.vuL : -60;
|
||||
const R = Number.isFinite(packet.vuR) ? packet.vuR : -60;
|
||||
shared.values.L = L - shared.calDbfs + shared.offset; // in VU
|
||||
shared.values.R = R - shared.calDbfs + shared.offset; // in VU
|
||||
}
|
||||
|
||||
export function draw(g, rect, CONFIG, shared) {
|
||||
const VU_TOP = VU_TOP_DB, VU_BOTTOM = VU_BOTTOM_DB;
|
||||
const clampVU = (v) => Math.max(VU_BOTTOM, Math.min(VU_TOP, v));
|
||||
const deflectionFrac = (dB) => {
|
||||
const table = VINTAGE_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;
|
||||
};
|
||||
// Ensure scale labels, ticks and bar deflection share the same DIN-vintage mapping.
|
||||
const mapY = (dB) => {
|
||||
const clamped = clampVU(dB);
|
||||
const frac = deflectionFrac(clamped);
|
||||
const yBot = rect.y + rect.h;
|
||||
return yBot - frac * rect.h;
|
||||
};
|
||||
|
||||
|
||||
// Live-Offset anwenden: UI-Änderungen wirken sofort
|
||||
const effOff = Number(CONFIG && CONFIG.VU_OFFSET_DB) || 0;
|
||||
const offCorr = effOff - (shared.offset || 0);
|
||||
|
||||
|
||||
|
||||
// Layout: zwei vertikale Balken + mittige Skala
|
||||
const innerPad = 8, scaleW = 26, gap = 8;
|
||||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||||
let barW = Math.max(8, 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
|
||||
const rawVuL = shared.values.L + offCorr;
|
||||
const rawVuR = shared.values.R + offCorr;
|
||||
const vuL = clampVU(rawVuL);
|
||||
const vuR = clampVU(rawVuR);
|
||||
const smooth = smoothHeader(shared, rawVuL, rawVuR);
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = 'bold 12px ui-monospace, monospace';
|
||||
// Header-Hintergrund säubern, damit keine alten Werte liegen bleiben
|
||||
g.fillStyle = HEADER_BG;
|
||||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||||
const headerWarn = CONFIG.VU_HEADER_SHOW_VALUE && (rawVuL > CONFIG.VU_RED_START || rawVuR > CONFIG.VU_RED_START);
|
||||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||||
g.textAlign = 'center';
|
||||
if (CONFIG.VU_HEADER_SHOW_VALUE) {
|
||||
const yText = rect.y - 12;
|
||||
const fmt = (v) => {
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||||
};
|
||||
const isRedL = rawVuL > CONFIG.VU_RED_START;
|
||||
const isRedR = rawVuR > CONFIG.VU_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 = CONFIG.HEADER_TEXT_COLOR || MID_COLOR;
|
||||
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('Volume Units', centerX, rect.y - 12);
|
||||
}
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
const yFloor = mapY(VU_BOTTOM);
|
||||
const yRed = mapY(CONFIG.VU_RED_START);
|
||||
const innerW = Math.max(2, barW - 2);
|
||||
|
||||
const drawVuBar = (x0, vu) => {
|
||||
const yVal = mapY(vu);
|
||||
const colNorm = CONFIG.VU_COLOR_NORMAL;
|
||||
const colWarn = CONFIG.VU_COLOR_WARN;
|
||||
|
||||
if (vu > CONFIG.VU_RED_START) {
|
||||
if (CONFIG.VU_RED_BAR_ONLY) {
|
||||
// Normaler Teil unten
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, Math.min(yRed, yFloor), innerW, Math.max(0, yFloor - yRed));
|
||||
// Roter Teil oben
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, Math.min(yVal, yRed), innerW, Math.max(0, yRed - yVal));
|
||||
} else {
|
||||
g.fillStyle = colWarn;
|
||||
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = colNorm;
|
||||
g.fillRect(x0 + 1, yVal, innerW, Math.max(0, yFloor - yVal));
|
||||
}
|
||||
|
||||
// leichte Glanzkante
|
||||
g.globalAlpha = .12; g.fillStyle = '#fff'; g.fillRect(x0 + 1, yVal, innerW, 2); g.globalAlpha = 1;
|
||||
};
|
||||
|
||||
// Balken zeichnen
|
||||
drawVuBar(leftX, vuL);
|
||||
|
||||
// Red stripe overlay (left)
|
||||
drawRedStripe(g, {x:leftX, w:innerW}, centerX, mapY);
|
||||
drawVuBar(rightX, vuR);
|
||||
// Overlay ticks on bars
|
||||
drawOverlayTicksLR(g, leftX, rightX, innerW, mapY, VU_TOP, VU_BOTTOM, SCALE_TICKS_DB, getVuAlignmentTick(CONFIG));
|
||||
|
||||
|
||||
// Red stripe overlay (right)
|
||||
drawRedStripe(g, {x:rightX, w:innerW}, centerX, mapY);
|
||||
const now = performance.now();
|
||||
const holdMs = (CONFIG.VU_HOLD_MS ?? 600);
|
||||
const decayDbPerS = (CONFIG.VU_DECAY_DB_PER_S ?? 35);
|
||||
const holdOpts = {
|
||||
holdMs,
|
||||
decayDbPerS,
|
||||
floor: VU_BOTTOM,
|
||||
riseThreshold: 0.2,
|
||||
};
|
||||
if (!shared._holdState) {
|
||||
shared._holdState = {
|
||||
L: createPeakHoldState(shared.hold?.L ?? VU_BOTTOM, now, holdMs),
|
||||
R: createPeakHoldState(shared.hold?.R ?? VU_BOTTOM, now, holdMs),
|
||||
};
|
||||
}
|
||||
shared.hold.L = stepPeakHold(vuL, shared._holdState.L, now, holdOpts);
|
||||
shared.hold.R = stepPeakHold(vuR, shared._holdState.R, now, holdOpts);
|
||||
|
||||
// Hold-Anzeigen
|
||||
g.save();
|
||||
g.fillStyle = '#fff';
|
||||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||||
g.restore();
|
||||
|
||||
drawVuStaticOverlay(g, shared, rect, CONFIG, {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
}, mapY);
|
||||
}
|
||||
|
||||
function drawVuStaticOverlay(g, shared, rect, CONFIG, geom, mapY) {
|
||||
const {
|
||||
leftX,
|
||||
rightX,
|
||||
barW,
|
||||
innerW,
|
||||
scaleX,
|
||||
scaleW,
|
||||
centerX,
|
||||
} = 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.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||||
METER_HEADER_FONT,
|
||||
].join('|');
|
||||
drawCachedStaticLayer(g, shared, 'vu-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||||
cg.save();
|
||||
cg.translate(-layerX, -layerY);
|
||||
drawScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, CONFIG);
|
||||
drawRedStripe(cg, { x: leftX, w: innerW }, centerX, mapY);
|
||||
drawRedStripe(cg, { x: rightX, w: innerW }, centerX, mapY);
|
||||
drawOverlayTicksLR(cg, leftX, rightX, innerW, mapY, VU_TOP_DB, VU_BOTTOM_DB, SCALE_TICKS_DB, getVuAlignmentTick(CONFIG));
|
||||
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 (VU)', centerX, baseY);
|
||||
cg.font = prevFontLabels;
|
||||
cg.restore();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function drawRedStripe(g, rectLite, centerX, mapY) {
|
||||
// Red zone = [0..+3] dB
|
||||
const yTop = mapY(VU_TOP_DB);
|
||||
const y0 = mapY(0);
|
||||
const y1 = Math.min(y0, yTop), y2 = Math.max(y0, yTop);
|
||||
const isLeft = (rectLite.x + rectLite.w/2) < centerX;
|
||||
const innerEdge = isLeft ? (rectLite.x + rectLite.w) : rectLite.x;
|
||||
const gap = Math.abs(centerX - innerEdge);
|
||||
const inset = Math.max(1, Math.floor(gap * 0.35));
|
||||
const cx = Math.round(isLeft ? (innerEdge + inset) : (innerEdge - inset)) + 0.5;
|
||||
g.save();
|
||||
g.strokeStyle = WARN_COLOR;
|
||||
g.lineWidth = 1;
|
||||
g.beginPath();
|
||||
g.moveTo(cx, y1);
|
||||
g.lineTo(cx, y2);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
|
||||
// Dünner roter Mittelstrich für 0..+3 dB
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function drawOverlayTicksLR(g, leftX, rightX, widthPx, mapY, TOP, BOTTOM, majors, alignmentTick = null) {
|
||||
g.save();
|
||||
g.lineWidth = 1;
|
||||
|
||||
const MAJOR_INSET = 2;
|
||||
const MINOR_FRAC = 0.45;
|
||||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||||
const highlightValue = Number.isFinite(alignmentTick?.value) ? alignmentTick.value : null;
|
||||
const highlightColor = alignmentTick?.color || WARN_COLOR;
|
||||
const approx = (a, b) => Math.abs(a - b) < 1e-3;
|
||||
let highlightMatched = false;
|
||||
|
||||
const cxL = leftX + 1 + widthPx / 2;
|
||||
const cxR = rightX + 1 + widthPx / 2;
|
||||
|
||||
const drawMajor = (yPix, highlight = false) => {
|
||||
g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR;
|
||||
const majorW = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||||
const x1L = Math.round(cxL - majorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + majorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - majorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + majorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const drawMinor = (yPix, highlight = false) => {
|
||||
g.strokeStyle = highlight ? highlightColor : DEFAULT_COLOR;
|
||||
const minorW = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||||
const x1L = Math.round(cxL - minorW / 2) + 0.5;
|
||||
const x2L = Math.round(cxL + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||||
|
||||
const x1R = Math.round(cxR - minorW / 2) + 0.5;
|
||||
const x2R = Math.round(cxR + minorW / 2) + 0.5;
|
||||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||||
};
|
||||
|
||||
const top = TOP, bot = BOTTOM;
|
||||
const hi = Math.max(top, bot), lo = Math.min(top, bot);
|
||||
const inRange = (v) => v <= hi && v >= lo;
|
||||
|
||||
if (Array.isArray(majors) && majors.length > 0) {
|
||||
const sorted = majors.slice().sort((a, b) => b - a); // desc (top->bottom)
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const vMaj = sorted[i];
|
||||
if (inRange(vMaj)) {
|
||||
const yMaj = Math.round(mapY(vMaj)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(vMaj, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMajor(yMaj, isHighlight);
|
||||
}
|
||||
if (i < sorted.length - 1) {
|
||||
const vNext = sorted[i + 1];
|
||||
const mid = (vMaj + vNext) / 2;
|
||||
const skip = SKIP_AUTO_MINOR_DB.some((s) => Math.abs(mid - s) < 1e-3);
|
||||
if (inRange(mid) && !skip) {
|
||||
const yMin = Math.round(mapY(mid)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(mid, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMinor(yMin, isHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of EXTRA_BAR_TICKS_DB) {
|
||||
if (!inRange(extra)) continue;
|
||||
const yExtra = Math.round(mapY(extra)) + 0.5;
|
||||
const isHighlight = highlightValue !== null && approx(extra, highlightValue);
|
||||
if (isHighlight) highlightMatched = true;
|
||||
drawMinor(yExtra, isHighlight);
|
||||
}
|
||||
|
||||
if (highlightValue !== null && inRange(highlightValue) && !highlightMatched) {
|
||||
const y = Math.round(mapY(highlightValue)) + 0.5;
|
||||
drawMajor(y, true);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function drawScale(g, colRect, centerX, mapY, CONFIG) {
|
||||
drawHairlineGrid(g, colRect, mapY, VU_TOP_DB, VU_BOTTOM_DB, 1);
|
||||
|
||||
g.save();
|
||||
const prevFont = g.font;
|
||||
g.font = METER_HEADER_FONT;
|
||||
g.fillStyle = LABEL_COLOR;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
for (const step of VINTAGE_DEFLECTION) {
|
||||
const y = mapY(step.db);
|
||||
if (y < colRect.y - 0.5 || y > colRect.y + colRect.h + 0.5) continue;
|
||||
const dbLabel = step.db > 0 ? `+${step.db}` : `${step.db}`;
|
||||
g.fillText(dbLabel, centerX, y + 5);
|
||||
}
|
||||
|
||||
g.font = prevFont;
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function getVuAlignmentTick(CONFIG) {
|
||||
if (!CONFIG || CONFIG.AL_MARKERS_ENABLED === false) return null;
|
||||
return { value: -4, color: WARN_COLOR };
|
||||
}
|
||||
Reference in New Issue
Block a user