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