947 lines
32 KiB
JavaScript
947 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 = {
|
|
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, 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_DETECTOR,
|
|
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 === '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 || '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));
|
|
}
|