import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js'; import { DEFAULT_TOP_INSET, FRAME_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js'; // views/realtime.js — IEC-konformer Real-Time Analyzer const METER_WIDTH = 140; const METER_GAP = 8; const METER_PAD_TOP = 15; const METER_PAD_BOTTOM = 5; const METER_SLOT_SHRINK = 24; // reduziert nutzbare Höhe leicht, damit Slot niedriger wirkt const METER_EXTRA_BOTTOM_PAD = 5; const FLOOR_DB = -120; const RTA_X_TICKS = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; const DEFAULT_RTA_BAR_BASE_COLOR = MID_COLOR; const RTA_PEAK_COLOR = WARN_COLOR; const EMBEDDED_RTA_HIDE_20HZ_WIDTH = 420; const STATIC_LABEL_PAD_LEFT = 40; const STATIC_LABEL_PAD_BOTTOM = 28; const STATIC_STROKE_PAD = 2; const PEAK_HOLD_MAP = new Map(Object.entries({ off: 0, auto: null, fall: null, '1s': 1, '2s': 2, '4s': 4, '10s': 10, '20s': 20, '30s': 30, manual: Infinity, })); const INTEGRATION_TAU = { fast: 0.125, medium: 0.5, slow: 1.0, }; export const id = 'realtime'; export function init() { return { bandKey: null, bands: [], mapping: [], ewma: new Float32Array(0), peakHold: new Float32Array(0), lastPeakTime: [], displayLevels: new Float32Array(0), levelBuffer: new Float32Array(0), rtBarLevels: new Float32Array(0), rtBarPeakTimes: [], lastRtBarTs: 0, lastDisplayUpdate: 0, holdSampleTs: 0, lastIntegrationTs: 0, configSig: '', staticLayers: new Map(), }; } export function resize() {} export function destroy(state) { if (state) state.staticLayers = null; } export async function render(env, state) { const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env; const nyq = audio.nyq || 24000; const analyser = audio.getAnalyser?.(); const buf = audio.getFreqBuffer?.(); const rtaData = typeof audio.getRtaData === 'function' ? audio.getRtaData() : null; const useIirEngine = rtaData?.engine === 'iir' || ((CONFIG.RTA_ENGINE || 'fft') === 'iir'); const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft'; const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null; const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw') ? selectLocalRtwPacket(nativeRtaPacket, CONFIG.RTA_BPO_MODE || nativeRtaPacket.bpo || '1_3') : nativeRtaPacket; const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET; const PLOT = { left: 43, top: topInset, right: 0, bottom: 18 }; const plotX = PLOT.left; const plotY = PLOT.top; const slotList = env.slots ? env.slots('realtime') : null; const activeMeter = (slotList && slotList[0]) || 'vu'; const showMeter = activeMeter !== 'none'; const plotW = Math.max(0, (rect.w - PLOT.right) - (showMeter ? (METER_WIDTH + METER_GAP) : 0) - plotX); const plotH = rect.h - PLOT.top - PLOT.bottom; const range = getDbRange(); const freqBounds = getFreqBounds(CONFIG, nyq, displayRtaPacket); const hide20HzTick = shouldHide20HzTick(env, plotW); const freqTicks = RTA_X_TICKS.filter((f) => { if (hide20HzTick && f === 20) return false; return f >= freqBounds.min && f <= freqBounds.max * 1.05; }); const gridConfig = Object.assign({}, CONFIG, { DBFS_TOP: range.top, DBFS_BOTTOM: range.bottom, Y_TICKS: range.ticks, }); const alignBase = Number.isFinite(CONFIG.DIGITAL_REF_DBFS_RMS) ? CONFIG.DIGITAL_REF_DBFS_RMS : -18; const gainOffset = useIirEngine ? Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB || 0) : 0; const showAlignMarker = CONFIG.AL_MARKERS_ENABLED !== false; const refDb = showAlignMarker ? alignBase + gainOffset : null; const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; const plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT; const plotPadBottom = STATIC_LABEL_PAD_BOTTOM; drawCachedStaticLayer( state, g, 'rta-plot', [ plotW, plotH, range.top, range.bottom, freqBounds.min, freqBounds.max, freqTicks.join(','), refDb ?? 'none', hide20HzTick ? 1 : 0, plotPadLeft, plotPadBottom, ].join('|'), { x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom }, (lg) => { utils.drawDbfsGridRect( lg, plotPadLeft, 0, plotW, plotH, gridConfig, freqBounds.max, { freqTicks, freqMin: freqBounds.min, refDb, refStyle: 'rgba(0,231,255,0.35)', refDash: [3, 4], }, ); redrawPlotEdges(lg, plotPadLeft, 0, plotW, plotH, CONFIG); }, ); const configSig = buildConfigSignature(CONFIG, nyq, buf?.length || 0); if (state.configSig !== configSig) { resetState(state); state.configSig = configSig; } if (!audio.alive) { drawWaiting(g, plotX, plotY); } else { const binCount = buf?.length || analyser?.frequencyBinCount || 2048; ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, displayRtaPacket); if (!state.mapping.length) { drawWaiting(g, plotX, plotY); } else if (useIirEngine) { const ballisticsData = mapIirLevels(displayRtaPacket, CONFIG, range, state.mapping.length); const baseLevels = ballisticsData?.primary; if (!baseLevels || !baseLevels.length) { drawWaiting(g, plotX, plotY); } else { const integrated = baseLevels; const displayBase = applyDisplayHold(state, integrated, CONFIG, range); // Native IIR values already contain the selected power-domain // integration. A second browser attack/hold stage would falsify it. const display = displayBase; syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir'); state.displayLevels = display; state.currentRange = range; if (typeof window !== 'undefined') window.__RTA_STATE__ = state; renderSpectrum( g, state, display, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, ballisticsData?.overlay || null, ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average' ); } } else if (useNativeFftEngine) { const ballisticsData = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length); const nativeLevels = ballisticsData?.primary; if (!nativeLevels || !nativeLevels.length) { drawWaiting(g, plotX, plotY); } else { // Native FFT packets already contain the selected power-domain // integration. Integrating them again in the browser adds lag and // changes the measured value. const integrated = nativeLevels; const displayBase = applyDisplayHold(state, integrated, CONFIG, range); const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars' ? applyRealtimeBarBallistics(state, displayBase, CONFIG, range) : displayBase; syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft'); state.displayLevels = display; state.currentRange = range; if (typeof window !== 'undefined') window.__RTA_STATE__ = state; renderSpectrum( g, state, display, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, ballisticsData?.overlay || null, ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average' ); } } else if (!analyser || !buf) { drawWaiting(g, plotX, plotY); } else { analyser.getFloatFrequencyData(buf); const levels = computeBandLevels(state, utils, buf, CONFIG, range); const integrated = applyIntegration(state, levels, CONFIG, range); const displayBase = applyDisplayHold(state, integrated, CONFIG, range); const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars' ? applyRealtimeBarBallistics(state, displayBase, CONFIG, range) : displayBase; applyPeakHold(state, integrated, CONFIG, range); state.displayLevels = display; state.currentRange = range; if (typeof window !== 'undefined') window.__RTA_STATE__ = state; renderSpectrum( g, state, display, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, null, CONFIG.RTA_BALLISTICS_MODE || 'average' ); } } if (showMeter) { await drawMeter(env, state, plotX, plotY, plotW, plotH); } } function drawWaiting(g, plotX, plotY) { g.fillStyle = '#9aa'; const y = plotY >= 40 ? (plotY - 26) : (plotY + 22); g.fillText('Warte auf Audio …', plotX + 10, y); } function shouldHide20HzTick(env, plotW) { const containerView = env?.containerView; const embeddedInMultiView = env?.embedded === true && (containerView === 'split-view' || containerView === 'quad-view'); return embeddedInMultiView && plotW < EMBEDDED_RTA_HIDE_20HZ_WIDTH; } function buildConfigSignature(CONFIG, nyq, binCount) { return [ CONFIG.RTA_FREQ_RANGE, CONFIG.RTA_BPO_MODE, CONFIG.RTA_WEIGHTING, CONFIG.RTA_BAR_LAYOUT, CONFIG.RTA_DETECTOR, CONFIG.RTA_INTEGRATION, CONFIG.RTA_PEAK_HOLD_MODE, CONFIG.RTA_PEAK_HOLD_SEC, CONFIG.RTA_PEAK_DECAY_DB_PER_S, CONFIG.RTA_PEAK_RESET_TOKEN, CONFIG.RTA_DISPLAY_HOLD_SEC, CONFIG.REALTIME_RENDER_STYLE, CONFIG.REALTIME_BAR_HOLD_MS, CONFIG.REALTIME_BAR_DECAY_DB_PER_S, CONFIG.RTA_ENGINE, CONFIG.RTA_IIR_ORDER, CONFIG.RTA_IIR_TAU_FAST, CONFIG.RTA_IIR_TAU_SLOW, CONFIG.RTA_DISPLAY_GAIN_FFT_DB, CONFIG.RTA_DISPLAY_GAIN_IIR_DB, nyq, binCount, ].join('|'); } function resetState(state) { state.bandKey = null; state.bands = []; state.mapping = []; state.ewma = new Float32Array(0); state.peakHold = new Float32Array(0); state.lastPeakTime = []; state.displayLevels = new Float32Array(0); state.levelBuffer = new Float32Array(0); state.rtBarLevels = new Float32Array(0); state.rtBarPeakTimes = []; state.lastRtBarTs = 0; state.lastDisplayUpdate = 0; state.holdSampleTs = 0; state.lastIntegrationTs = 0; } function ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, rtaPacket) { const freqRange = CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm'; const layoutMode = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec'; const bpo = layoutMode === 'rtw' ? (rtaPacket?.bpo || '1_3') : (CONFIG.RTA_BPO_MODE || '1_6'); const engine = rtaPacket?.engine || CONFIG.RTA_ENGINE || 'fft'; const packetCenters = isVectorLike(rtaPacket?.centers) ? Array.from(rtaPacket.centers) : null; const rtaCenters = (layoutMode === 'rtw') ? (packetCenters || getRtwCenters(bpo)) : null; const centerKey = rtaCenters ? `${rtaCenters.length}:${rtaCenters[0]}:${rtaCenters[rtaCenters.length - 1]}` : 'static'; const key = `${layoutMode}|${freqRange}|${bpo}|${nyq}|${binCount}|${engine}|${centerKey}`; if (state.bandKey === key && state.mapping.length) return; let bands; if (layoutMode === 'rtw') { bands = buildFixedRtwBands(bpo, freqBounds, nyq, rtaCenters); if (!bands.length) { bands = utils.makeFractionalOctaveBands(nyq, bpo, { fMin: freqBounds.min, fMax: freqBounds.max, }); } } else { bands = utils.makeFractionalOctaveBands(nyq, bpo, { fMin: freqBounds.min, fMax: freqBounds.max, }); } const mapping = utils.buildBandBinMapping(bands, nyq, binCount); state.bandKey = key; state.bands = bands; state.mapping = mapping; const len = mapping.length; state.ewma = new Float32Array(len); state.ewma.fill(range.bottom); state.peakHold = new Float32Array(len); state.peakHold.fill(range.bottom); state.lastPeakTime = new Array(len).fill(performance.now()); state.displayLevels = new Float32Array(len); state.displayLevels.fill(range.bottom); state.rtBarLevels = new Float32Array(len); state.rtBarLevels.fill(range.bottom); state.rtBarPeakTimes = new Array(len).fill(performance.now()); } function computeBandLevels(state, utils, buf, CONFIG, range) { const weighting = CONFIG.RTA_WEIGHTING || 'z'; const weightFn = (freq) => utils.weightingDb(freq, weighting); const raw = utils.computeBandLevels(state.mapping, buf, { floor: FLOOR_DB, weightingFn: weightFn, }); const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0; if (!state.levelBuffer || state.levelBuffer.length !== raw.length) { state.levelBuffer = new Float32Array(raw.length); } const out = state.levelBuffer; for (let i = 0; i < raw.length; i++) { out[i] = clamp(raw[i] + gain, range.bottom, range.top); } return out; } function applyIntegration(state, levels, CONFIG, range) { const mode = (CONFIG.RTA_INTEGRATION === 'impulse' || INTEGRATION_TAU[CONFIG.RTA_INTEGRATION]) ? CONFIG.RTA_INTEGRATION : 'fast'; const peakDetector = CONFIG.RTA_DETECTOR === 'peak'; const now = performance.now(); const last = state.lastIntegrationTs || now; const dt = Math.max(1 / 240, (now - last) / 1000); state.lastIntegrationTs = now; if (!state.ewma || state.ewma.length !== levels.length) { state.ewma = new Float32Array(levels.length); state.ewma.fill(range.bottom); } const buffer = state.ewma; for (let i = 0; i < levels.length; i++) { const prev = buffer[i]; const tau = peakDetector ? 0.010 : (mode === 'impulse' ? (levels[i] >= prev ? 0.035 : 1.5) : (INTEGRATION_TAU[mode] || 0.125)); const alpha = (tau > 0 && dt > 0) ? 1 - Math.exp(-dt / tau) : 1; buffer[i] = prev + (levels[i] - prev) * alpha; } return buffer; } function applyPeakHold(state, levels, CONFIG, range) { const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'fall'; const len = levels.length; const mapped = PEAK_HOLD_MAP.get(mode); const holdSec = Number.isFinite(mapped) ? Math.max(0, mapped) : Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0); const decayRate = Math.max(0, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 20); const now = performance.now(); const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000); state.holdSampleTs = now; if (!state.peakHold || state.peakHold.length !== len) { state.peakHold = new Float32Array(len); state.lastPeakTime = new Array(len).fill(now); } const buffer = state.peakHold; if (mode === 'off') { for (let i = 0; i < len; i++) buffer[i] = clamp(levels[i], range.bottom, range.top); state.lastPeakTime = new Array(len).fill(now); return buffer; } for (let i = 0; i < len; i++) { const current = clamp(levels[i], range.bottom, range.top); if (current >= buffer[i] - 0.05) { buffer[i] = current; state.lastPeakTime[i] = now; continue; } if (mapped === Infinity || mode === 'manual') continue; if (mode === 'auto' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) { buffer[i] = current; state.lastPeakTime[i] = now; } else if (mode === 'fall' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) { buffer[i] = Math.max(current, buffer[i] - decayRate * dt); } buffer[i] = clamp(buffer[i], range.bottom, range.top); } return buffer; } function syncNativePeakHold(state, packet, CONFIG, range, expectedLen, engine) { const src = isVectorLike(packet?.bands_peak) && packet.bands_peak.length === expectedLen ? packet.bands_peak : null; if (!src) return applyPeakHold(state, state.displayLevels, CONFIG, range); const gain = engine === 'iir' ? (Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? 0) || 0) : (Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0); if (!state.peakHold || state.peakHold.length !== expectedLen) { state.peakHold = new Float32Array(expectedLen); } for (let i = 0; i < expectedLen; i++) { const value = Number(src[i]); state.peakHold[i] = clamp( (Number.isFinite(value) ? value : range.bottom) + gain, range.bottom, range.top, ); } return state.peakHold; } function applyDisplayHold(state, levels, CONFIG, range) { const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0); const len = levels.length; const now = performance.now(); if (!state.displayLevels || state.displayLevels.length !== len) { state.displayLevels = new Float32Array(len); state.lastDisplayUpdate = 0; } const buffer = state.displayLevels; if (holdSec <= 0) { for (let i = 0; i < len; i++) { buffer[i] = clamp(levels[i], range.bottom, range.top); } state.lastDisplayUpdate = now; return buffer; } const dt = (now - (state.lastDisplayUpdate || 0)) / 1000; if (dt >= holdSec) { for (let i = 0; i < len; i++) { buffer[i] = clamp(levels[i], range.bottom, range.top); } state.lastDisplayUpdate = now; return buffer; } for (let i = 0; i < len; i++) { const incoming = clamp(levels[i], range.bottom, range.top); buffer[i] = Math.max(buffer[i], incoming); } return buffer; } function applyRealtimeBarBallistics(state, levels, CONFIG, range) { const holdMs = Math.max(0, Number(CONFIG.REALTIME_BAR_HOLD_MS) || 0); const decayRate = Math.max(0, Number(CONFIG.REALTIME_BAR_DECAY_DB_PER_S) || 0); const len = levels.length; const now = performance.now(); const dt = Math.max(0, (now - (state.lastRtBarTs || now)) / 1000); state.lastRtBarTs = now; if (!state.rtBarLevels || state.rtBarLevels.length !== len) { state.rtBarLevels = new Float32Array(len); state.rtBarLevels.fill(range.bottom); state.rtBarPeakTimes = new Array(len).fill(now); } const buffer = state.rtBarLevels; if (!Array.isArray(state.rtBarPeakTimes) || state.rtBarPeakTimes.length !== len) { state.rtBarPeakTimes = new Array(len).fill(now); } for (let i = 0; i < len; i++) { const incoming = clamp(levels[i], range.bottom, range.top); if (incoming >= buffer[i] - 0.05) { buffer[i] = incoming; state.rtBarPeakTimes[i] = now; continue; } const heldLongEnough = (now - (state.rtBarPeakTimes[i] || 0)) >= holdMs; if (!heldLongEnough) continue; if (decayRate > 0) { buffer[i] = Math.max(incoming, buffer[i] - decayRate * dt); } else { buffer[i] = incoming; } buffer[i] = clamp(buffer[i], range.bottom, range.top); } return buffer; } function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode) { const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec'; const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length) ? overlayPeaks : null; if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') { renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay); } else if (layout === 'rtw') { renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlay); } else { renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay); } } function renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks) { const baseY = plotY + plotH; const zeroY = mapDbToY(0, range, plotY, plotH); const logMin = Math.log10(freqBounds.min); const logSpan = Math.max(1e-6, Math.log10(freqBounds.max) - logMin); const hasOverlay = isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length; for (let i = 0; i < state.mapping.length; i++) { const band = state.mapping[i]; const level = levels[i]; const xLeft = mapLogX(band.fLo, plotX, plotW, logMin, logSpan); const xRight = mapLogX(band.fHi, plotX, plotW, logMin, logSpan); const width = Math.max(2, xRight - xLeft - 1); drawBar(g, xLeft, width, level, zeroY, baseY, plotY, plotH, range, CONFIG); if (hasOverlay && Number.isFinite(overlayPeaks[i])) { drawPeakOverlay(g, xLeft, width, overlayPeaks[i], plotY, plotH, range); } if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') { drawHold(g, xLeft, width, state.peakHold[i], plotY, plotH, range); } } } function renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlayPeaks) { const baseY = plotY + plotH; const zeroY = mapDbToY(0, range, plotY, plotH); const slotW = plotW / Math.max(1, state.mapping.length); const hasOverlay = isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length; for (let i = 0; i < state.mapping.length; i++) { const level = levels[i]; const x = plotX + i * slotW + slotW * 0.15; const width = Math.max(2, slotW * 0.7); drawBar(g, x, width, level, zeroY, baseY, plotY, plotH, range, CONFIG); if (hasOverlay && Number.isFinite(overlayPeaks[i])) { drawPeakOverlay(g, x, width, overlayPeaks[i], plotY, plotH, range); } if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') { drawHold(g, x, width, state.peakHold[i], plotY, plotH, range); } } } function renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks) { const logMin = Math.log10(freqBounds.min); const logSpan = Math.max(1e-6, Math.log10(freqBounds.max) - logMin); g.save(); g.strokeStyle = '#36bdf8'; g.lineWidth = 1.6; g.beginPath(); let moved = false; for (let i = 0; i < state.mapping.length; i++) { const band = state.mapping[i]; const x = mapLogX(band.center, plotX, plotW, logMin, logSpan); const y = mapDbToY(levels[i], range, plotY, plotH); if (!moved) { g.moveTo(x, y); moved = true; } else { g.lineTo(x, y); } } g.stroke(); g.restore(); if (isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length) { const markerWidth = Math.max(2, plotW / Math.max(40, state.mapping.length * 4)); for (let i = 0; i < state.mapping.length; i++) { if (!Number.isFinite(overlayPeaks[i])) continue; const band = state.mapping[i]; const x = mapLogX(band.center, plotX, plotW, logMin, logSpan) - markerWidth / 2; drawPeakOverlay(g, x, markerWidth, overlayPeaks[i], plotY, plotH, range); } } // Peak markers const barWidth = Math.max(2, plotW / Math.max(10, state.mapping.length * 2)); if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') { for (let i = 0; i < state.mapping.length; i++) { const band = state.mapping[i]; const x = mapLogX(band.center, plotX, plotW, logMin, logSpan) - barWidth / 2; drawHold(g, x, barWidth, state.peakHold[i], plotY, plotH, range); } } } function drawBar(g, x, width, level, zeroY, baseY, plotY, plotH, range, CONFIG) { const baseColor = CONFIG?.RTA_BAR_BASE_COLOR || DEFAULT_RTA_BAR_BASE_COLOR; const y = mapDbToY(level, range, plotY, plotH); if (level <= 0) { g.fillStyle = baseColor; g.fillRect(x, y, width, Math.max(0, baseY - y)); } else { g.fillStyle = baseColor; g.fillRect(x, zeroY, width, Math.max(0, baseY - zeroY)); g.fillStyle = RTA_PEAK_COLOR; g.fillRect(x, y, width, Math.max(0, zeroY - y)); } g.globalAlpha = 0.12; g.fillStyle = '#fff'; g.fillRect(x, y, width, 2); g.globalAlpha = 1; } function drawHold(g, x, width, value, plotY, plotH, range) { if (!Number.isFinite(value)) return; if (value <= (range.bottom + 0.05)) return; const yHold = mapDbToY(value, range, plotY, plotH); g.fillStyle = '#fff'; g.fillRect(x, yHold - 1, width, 2); } function drawPeakOverlay(g, x, width, value, plotY, plotH, range) { if (!Number.isFinite(value)) return; const y = mapDbToY(value, range, plotY, plotH); g.save(); g.strokeStyle = FRAME_COLOR; g.lineWidth = 1.4; g.beginPath(); g.moveTo(x, y); g.lineTo(x + width, y); g.stroke(); g.restore(); } function redrawPlotEdges(g, x, y, w, h, CONFIG) { const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.beginPath(); g.moveTo(x - gutterL, y); g.lineTo(x + w, y); g.stroke(); g.beginPath(); g.moveTo(x - gutterL, y + h); g.lineTo(x + w, y + h); g.stroke(); } async function drawMeter(env, state, plotX, plotY, plotW, plotH) { const meterRect = { x: plotX + plotW + METER_GAP, y: plotY, w: METER_WIDTH, h: plotH }; const { ctx: g, config: CONFIG, meters } = env; drawCachedStaticLayer( state, g, 'rta-meter-shell', `${meterRect.w}x${meterRect.h}`, { x: meterRect.x - STATIC_STROKE_PAD, y: meterRect.y - STATIC_STROKE_PAD, w: meterRect.w + STATIC_STROKE_PAD * 2, h: meterRect.h + STATIC_STROKE_PAD * 2 }, (lg) => { lg.fillStyle = PANEL_BG; lg.fillRect(STATIC_STROKE_PAD, STATIC_STROKE_PAD, meterRect.w, meterRect.h); lg.strokeStyle = FRAME_COLOR; lg.lineWidth = 2; lg.strokeRect(STATIC_STROKE_PAD + 0.5, STATIC_STROKE_PAD + 0.5, meterRect.w - 1, meterRect.h - 1); }); const slotList = env.slots ? env.slots('realtime') : null; const active = (slotList && slotList[0]) || 'vu'; if (active === 'none') return; const shrink = Math.max(0, METER_SLOT_SHRINK); const innerHeight = Math.max(40, meterRect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD); const innerOffset = shrink / 2; const innerRect = { x: meterRect.x, y: meterRect.y + METER_PAD_TOP + innerOffset, w: meterRect.w, h: innerHeight, }; g.save(); g.lineWidth = 2; g.beginPath(); g.rect(meterRect.x + g.lineWidth / 2, meterRect.y + g.lineWidth / 2, meterRect.w - g.lineWidth, meterRect.h - g.lineWidth); g.clip(); await meters.draw(g, innerRect, active, CONFIG); g.restore(); } function createStaticLayerCanvas(width, height) { const w = Math.max(1, width | 0); const h = Math.max(1, height | 0); if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h); const c = document.createElement('canvas'); c.width = w; c.height = h; return c; } function drawCachedStaticLayer(state, g, layerId, key, rect, build) { if (!state || !rect || rect.w <= 0 || rect.h <= 0) return; if (!state.staticLayers) state.staticLayers = new Map(); const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`; let layer = state.staticLayers.get(fullKey); if (!layer) { const canvas = createStaticLayerCanvas(rect.w, rect.h); const ctx = canvas.getContext('2d'); build(ctx); layer = { canvas }; state.staticLayers.set(fullKey, layer); } g.drawImage(layer.canvas, rect.x, rect.y); } function mapLogX(freq, x0, w, logMin, logSpan) { const clamped = Math.max(5, freq); const frac = (Math.log10(clamped) - logMin) / (logSpan || 1); return x0 + Math.max(0, Math.min(1, frac)) * w; } function mapDbToY(value, range, plotY, plotH) { const top = range.top; const bottom = range.bottom; const clamped = Math.max(bottom, Math.min(top, value)); const t = (clamped - bottom) / (top - bottom || 1); return plotY + plotH - t * plotH; } function getDbRange() { const top = 9; const bottom = -36; const ticks = []; for (let v = bottom; v <= top + 1e-6; v += 9) { ticks.push(v); } if (!ticks.includes(top)) ticks.push(top); return { top, bottom, ticks }; } function getFreqBounds(CONFIG, nyq, rtaData) { let min = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5 : 20; let max = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5000 : 20000; if (rtaData) { if (Number.isFinite(rtaData.freqMin)) min = Math.max(min, rtaData.freqMin); if (Number.isFinite(rtaData.freqMax)) max = Math.min(max, rtaData.freqMax); } return { min, max: Math.min(max, nyq) }; } function buildFixedRtwBands(bpoMode, freqBounds, nyq, overrideCenters) { const centers = isVectorLike(overrideCenters) && overrideCenters.length ? overrideCenters.slice() : getRtwCenters(bpoMode); if (!centers.length) return []; const n = resolveRtwBpoValue(bpoMode); const factor = Math.pow(2, 1 / (2 * n)); const minF = freqBounds.min; const maxF = Math.min(freqBounds.max, nyq); const bands = []; for (const fc of centers) { if (!Number.isFinite(fc)) continue; if (fc < minF || fc > maxF) continue; const fLo = Math.max(fc / factor, minF); const fHi = Math.min(fc * factor, maxF); if (fHi <= fLo) continue; bands.push({ center: fc, fLo, fHi }); } return bands; } export function selectLocalRtwPacket(packet, bpoMode) { if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet; const desiredCenters = getRtwCenters(bpoMode); if (!desiredCenters.length) return packet; if (packet.centers.length === desiredCenters.length) { return packet.bpo === bpoMode ? packet : { ...packet, bpo: bpoMode }; } const keyFor = (value) => Number(value).toFixed(1); const indexByCenter = new Map(); for (let i = 0; i < packet.centers.length; i++) { indexByCenter.set(keyFor(packet.centers[i]), i); } const pick = []; const selectedCenters = []; const used = new Set(); for (const center of desiredCenters) { let idx = indexByCenter.get(keyFor(center)); if (!Number.isInteger(idx)) { let bestIdx = -1; let bestErr = Infinity; for (let i = 0; i < packet.centers.length; i++) { if (used.has(i)) continue; const candidate = Number(packet.centers[i]); if (!Number.isFinite(candidate)) continue; const relErr = Math.abs(candidate - center) / Math.max(center, 1); if (relErr < bestErr) { bestErr = relErr; bestIdx = i; } } if (bestIdx >= 0 && bestErr <= 0.04) { idx = bestIdx; } } if (Number.isInteger(idx) && !used.has(idx)) { used.add(idx); pick.push(idx); selectedCenters.push(Number(packet.centers[idx])); } } if (!pick.length) return packet; const pickVector = (src) => { if (!isVectorLike(src)) return []; return pick.map((idx) => Number(src[idx])); }; return { ...packet, bpo: bpoMode, centers: selectedCenters, bands_avg: pickVector(packet.bands_avg || packet.bands), bands_peak: pickVector(packet.bands_peak), bands: pickVector(packet.bands || packet.bands_avg), }; } function mapIirLevels(packet, CONFIG, range, expectedLen) { if (!packet || packet.engine !== 'iir') return null; const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB; const top = (range && Number.isFinite(range.top)) ? range.top : 9; const gain = Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? 0) || 0; const ensureLen = (arr) => (isVectorLike(arr) && (!expectedLen || arr.length === expectedLen)) ? arr : null; const avgRaw = ensureLen(packet.bands_avg || packet.bands); const peakRaw = ensureLen(packet.bands_peak); const requestedMode = (CONFIG.RTA_BALLISTICS_MODE === 'peak' || CONFIG.RTA_BALLISTICS_MODE === 'both') ? CONFIG.RTA_BALLISTICS_MODE : 'average'; let modeResolved = requestedMode; if (modeResolved === 'both' && !(avgRaw && peakRaw)) { modeResolved = avgRaw ? 'average' : (peakRaw ? 'peak' : 'average'); } if (modeResolved === 'peak' && !peakRaw) { modeResolved = avgRaw ? 'average' : null; } if (modeResolved === 'average' && !avgRaw) { modeResolved = peakRaw ? 'peak' : null; } if (!modeResolved) return null; const sourcePrimary = modeResolved === 'peak' ? peakRaw : avgRaw; if (!sourcePrimary) return null; const convert = (src) => { const out = new Float32Array(src.length); for (let i = 0; i < src.length; i++) { const v = Number(src[i]); const val = Number.isFinite(v) ? v : bottom; out[i] = clamp(val + gain, bottom, top); } return out; }; const primary = convert(sourcePrimary); let overlay = null; let modeForRenderer = modeResolved; if (requestedMode === 'both' && avgRaw && peakRaw) { overlay = convert(peakRaw); modeForRenderer = 'both'; } if (modeForRenderer === 'both' && !overlay) { modeForRenderer = modeResolved === 'peak' ? 'peak' : 'average'; } return { primary, overlay: (modeForRenderer === 'both') ? overlay : null, mode: modeForRenderer, }; } function mapNativeFftLevels(packet, CONFIG, range, expectedLen) { if (!packet || packet.engine !== 'fft') return null; const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB; const top = (range && Number.isFinite(range.top)) ? range.top : 9; const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0; const ensureLen = (arr) => isVectorLike(arr) && (!expectedLen || arr.length === expectedLen) ? arr : null; const avgRaw = ensureLen(packet.bands_avg || packet.bands); const peakRaw = ensureLen(packet.bands_peak); const requested = ['average', 'peak', 'both'].includes(CONFIG.RTA_BALLISTICS_MODE) ? CONFIG.RTA_BALLISTICS_MODE : 'average'; const convert = (src) => { if (!src) return null; const out = new Float32Array(src.length); for (let i = 0; i < src.length; i++) { const value = Number(src[i]); out[i] = clamp((Number.isFinite(value) ? value : bottom) + gain, bottom, top); } return out; }; const average = convert(avgRaw); const peak = convert(peakRaw); if (requested === 'peak' && peak) return { primary: peak, overlay: null, mode: 'peak' }; if (requested === 'both' && average && peak) return { primary: average, overlay: peak, mode: 'both' }; if (average) return { primary: average, overlay: null, mode: 'average' }; if (peak) return { primary: peak, overlay: null, mode: 'peak' }; return null; } function isVectorLike(v) { return !!(v && (Array.isArray(v) || ArrayBuffer.isView(v))); } if (typeof window !== 'undefined') { window.resetRealTimeAnalyzerPeakHold = function resetRealTimeAnalyzerPeakHold() { const state = window.__RTA_STATE__; if (!state || !state.peakHold || !state.peakHold.length) return; const floor = (state.currentRange && state.currentRange.bottom) || FLOOR_DB; state.peakHold = new Float32Array(state.peakHold.length); state.peakHold.fill(floor); state.displayLevels = new Float32Array(state.peakHold.length); state.displayLevels.fill(floor); state.lastPeakTime = new Array(state.peakHold.length).fill(performance.now()); }; } function clamp(val, min, max) { return Math.min(max, Math.max(min, val)); }