Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,941 @@
|
||||
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 useIirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir';
|
||||
const rtaData = typeof audio.getRtaData === 'function'
|
||||
? audio.getRtaData()
|
||||
: null;
|
||||
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 || '1_6')
|
||||
: 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);
|
||||
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,
|
||||
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 bpo = CONFIG.RTA_BPO_MODE || '1_6';
|
||||
const engine = CONFIG.RTA_ENGINE || 'fft';
|
||||
const layoutMode = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||
const rtaCenters = (layoutMode === 'rtw') ? 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));
|
||||
}
|
||||
Reference in New Issue
Block a user