Files
Phoenix/www/views/realtime.js
T

944 lines
32 KiB
JavaScript

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,
'1s': 1,
'2s': 2,
'4s': 4,
'10s': 10,
'20s': 20,
'30s': 30,
manual: Infinity,
}));
const INTEGRATION_TAU = {
impulse: 0.035,
fast: 0.125,
slow: 1.0,
peak: 0.01,
};
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, 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;
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,
ballisticsData?.overlay || null,
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
);
}
} else if (useNativeFftEngine) {
const nativeLevels = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
if (!nativeLevels || !nativeLevels.length) {
drawWaiting(g, plotX, plotY);
} else {
const integrated = applyIntegration(state, nativeLevels, 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'
);
}
} 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_INTEGRATION,
CONFIG.RTA_PEAK_HOLD_MODE,
CONFIG.RTA_PEAK_HOLD_SEC,
CONFIG.RTA_PEAK_DECAY_DB_PER_S,
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 && INTEGRATION_TAU[CONFIG.RTA_INTEGRATION])
? CONFIG.RTA_INTEGRATION
: 'fast';
const tau = INTEGRATION_TAU[mode] || 0.125;
const now = performance.now();
const last = state.lastIntegrationTs || now;
const dt = Math.max(1 / 240, (now - last) / 1000);
state.lastIntegrationTs = now;
const alpha = (tau > 0 && dt > 0) ? 1 - Math.exp(-dt / tau) : 1;
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];
buffer[i] = prev + (levels[i] - prev) * alpha;
}
return buffer;
}
function applyPeakHold(state, levels, CONFIG, range) {
const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'auto';
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) || 0);
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;
let allowDecay = mode === 'off';
if (mode === 'auto') {
allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000;
}
if (allowDecay && decayRate > 0) {
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
}
buffer[i] = clamp(buffer[i], range.bottom, range.top);
}
return buffer;
}
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;
}
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;
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,
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 src = isVectorLike(packet.bands_avg || packet.bands)
? (packet.bands_avg || packet.bands)
: null;
if (!src || (expectedLen && src.length !== expectedLen)) return null;
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
const v = Number(src[i]);
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top);
}
return out;
}
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));
}