Files

928 lines
29 KiB
JavaScript

// views/spectrogram.js — FFT Spectrogram View with optional Worker renderer
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
const PLOT = { left: 35, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
const METER_WIDTH = 140;
const METER_GAP = 8;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
const MIN_ROW_COUNT = 16;
const RTA_X_TICKS = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const SMOOTHING_MAX = 0.2;
const TIME_GRID_SPACING = 100;
const SPECTROGRAM_ID = 'spectrogram';
const SPECTRO_RENDER_SCALE = 0.85;
const BASE_SCROLL_CSS_PX_PER_SECOND = 60;
const WORKER_WATCHDOG_MS = 750;
const MAX_SCROLL_COLUMNS_PER_DRAW = 64;
const SUPPORTS_WORKER = typeof window !== 'undefined'
&& typeof Worker !== 'undefined'
&& typeof HTMLCanvasElement !== 'undefined'
&& !!HTMLCanvasElement.prototype.transferControlToOffscreen;
// Pre-calculated constants
const LOG10 = Math.log(10);
const DB_TO_LINEAR_FACTOR = LOG10 / 10;
const SPECTRO_COLOR_STOPS = [
{ t: 0.0, color: [0, 0, 0] },
{ t: 0.25, color: [0, 0, 80] },
{ t: 0.5, color: [0, 135, 140] },
{ t: 0.75, color: [220, 220, 0] },
{ t: 1.0, color: [255, 255, 255] },
];
export const id = SPECTROGRAM_ID;
export function init() {
return {
useWorker: false, // wird im Render je nach Config gesetzt
worker: null,
workerReady: false,
workerBusy: false,
workerMessageId: 0,
workerWatchdog: null,
pendingWorkerColumn: null,
pendingWorkerRepeat: 0,
pendingWorkerDropped: 0,
workerStats: { sent: 0, drawn: 0, dropped: 0, restarts: 0, lastDrawMs: 0 },
offscreenWidth: 0,
offscreenHeight: 0,
spectroCanvasEl: null,
spectroCanvasTransferred: false,
lastTopDb: null,
lastBottomDb: null,
lastGamma: null,
width: 0,
height: 0,
freqBounds: null,
freqMap: [],
freqMapKey: '',
sampleRate: 48000,
binCount: 0,
history: null,
writeIndex: 0,
imgData: null,
pixelData: null,
scrollAccumulator: 0,
configSig: '',
gridCache: null,
_initialized: false,
_lastScrollTs: 0,
lastPhoenixSpectroSeq: 0,
};
}
export function resize() {}
export function destroy(state) {
teardownWorker(state);
state.history = null;
state.imgData = null;
state.pixelData = null;
state.freqMap = [];
state.freqMapKey = '';
state.gridCache = null;
removeSpectroCanvasElement(state);
if (state.spectroCanvasEl && !state.spectroCanvasTransferred) {
state.spectroCanvasEl.width = 0;
state.spectroCanvasEl.height = 0;
}
}
export async function render(env, state) {
if (!state._initialized) {
await new Promise(resolve => setTimeout(resolve, 0));
state._initialized = true;
}
// Config-Flag schaltet Worker ein (default true), wenn Offscreen verfügbar
state.useWorker = SUPPORTS_WORKER && !!env.config?.SPECTRO_USE_WORKER;
if (!state.useWorker && state.worker) {
teardownWorker(state);
removeSpectroCanvasElement(state);
}
state._dbgNextLog = state._dbgNextLog || 0;
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
const analyzer = audio.getAnalyser?.();
let freqBuf = audio.getFreqBuffer?.();
const phoenixSpectroSeq = Number(env.audio?.phoenixSpectroSeq || 0);
const hasPhoenixSpectro = Number.isFinite(phoenixSpectroSeq) && phoenixSpectroSeq > 0;
if (!freqBuf && analyzer) {
freqBuf = new Float32Array(analyzer.frequencyBinCount || 2048);
}
const plotX = PLOT.left;
const topInset = Number.isFinite(Number(env?.topInset)) ? Number(env.topInset) : PLOT.top;
const plotY = topInset;
const canvasOffsetX = Number.isFinite(Number(env.embeddedOffsetX)) ? Number(env.embeddedOffsetX) : 0;
const canvasOffsetY = Number.isFinite(Number(env.embeddedOffsetY)) ? Number(env.embeddedOffsetY) : 0;
const slotList = env.slots ? env.slots(SPECTROGRAM_ID) : null;
const activeMeter = (slotList && slotList[0]) || 'ppm-din';
const showMeter = activeMeter !== 'none';
const plotW = Math.max(16, Math.floor(rect.w - PLOT.left - PLOT.right - (showMeter ? (METER_WIDTH + METER_GAP) : 0)));
const plotH = Math.max(MIN_ROW_COUNT, Math.floor(rect.h - plotY - PLOT.bottom));
const dpr = Math.max(1, window.devicePixelRatio || 1);
const renderScale = Math.max(0.5, Math.min(1, SPECTRO_RENDER_SCALE));
const plotWpx = Math.max(1, Math.floor(plotW * dpr * renderScale));
const plotHpx = Math.max(1, Math.floor(plotH * dpr * renderScale));
const range = getDbRange(CONFIG);
const gamma = Math.max(0.3, Math.min(1.2, Number(CONFIG.SPECTRO_GAMMA ?? 0.9)));
state._dbgEnabled = !!CONFIG.SPECTRO_DEBUG;
layoutSpectroCanvas(state, canvasOffsetX + plotX, canvasOffsetY + plotY, plotW, plotH, plotWpx, plotHpx);
drawSpectrogramBackground(g, plotX, plotY, plotW, plotH);
if (!analyzer || !freqBuf || !freqBuf.length) {
drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH);
drawSpectrogramLegendHud(range, gamma);
if (showMeter) {
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
}
return;
}
const width = plotW;
const height = Math.max(MIN_ROW_COUNT, plotH);
const dimensionsChanged = state.width !== width || state.height !== height;
state.width = width;
state.height = height;
const fs = analyzer?.context?.sampleRate || audio.sampleRate || Math.max(48000, (audio.nyq || 24000) * 2);
const nyq = fs / 2;
const freqBounds = getFreqBounds(CONFIG, nyq);
const boundsChanged = !state.freqBounds ||
state.freqBounds.min !== freqBounds.min ||
state.freqBounds.max !== freqBounds.max;
if (boundsChanged) {
state.freqBounds = { ...freqBounds };
}
state.sampleRate = fs;
if (dimensionsChanged || boundsChanged || state.binCount !== freqBuf.length) {
ensureFreqMap(state, height, freqBounds, freqBuf.length, fs);
}
if (state.useWorker) {
setupWorker(state, plotWpx, plotHpx, range, gamma, freqBounds);
} else {
ensureFallbackBuffers(state, width, height, range.bottom, g);
}
const scrollRate = resolveScrollRate(CONFIG);
const outputPixelScale = state.useWorker ? (plotWpx / Math.max(1, plotW)) : 1;
let spectroDirty = true;
if (hasPhoenixSpectro) {
spectroDirty = phoenixSpectroSeq !== (state.lastPhoenixSpectroSeq || 0);
}
let columnsToEmit = 0;
if (hasPhoenixSpectro) {
if (spectroDirty) {
const previousSeq = Number(state.lastPhoenixSpectroSeq || 0);
const sequenceDelta = previousSeq > 0
? Math.max(1, phoenixSpectroSeq - previousSeq)
: 1;
const fftSize = Number(env.audio?.phoenixSpectroMeta?.fftSize) || freqBuf.length * 2;
const pixelsPerSourceFrame = spectrogramPixelsPerSourceFrame(scrollRate, fs, fftSize);
const scrollStep = stepScrollAccumulator(
state.scrollAccumulator,
pixelsPerSourceFrame * outputPixelScale,
sequenceDelta,
);
state.scrollAccumulator = scrollStep.accumulator;
columnsToEmit = scrollStep.columns;
}
} else {
const nowTs = performance.now();
const dtMs = Math.max(1, nowTs - (state._lastScrollTs || nowTs));
state._lastScrollTs = nowTs;
const targetFrameMs = 1000 / 60;
const frameUnits = dtMs / targetFrameMs;
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate * frameUnits;
while (state.scrollAccumulator >= 1) {
columnsToEmit += 1;
state.scrollAccumulator -= 1;
}
}
if (spectroDirty) {
analyzer.smoothingTimeConstant = Math.min(SMOOTHING_MAX, Math.max(0, analyzer.smoothingTimeConstant ?? 0));
analyzer.getFloatFrequencyData(freqBuf);
if (hasPhoenixSpectro) {
state.lastPhoenixSpectroSeq = phoenixSpectroSeq;
}
}
if (columnsToEmit > 0 && spectroDirty) {
const column = buildColumnData(state, freqBuf, range.bottom);
if (state.useWorker) {
queueColumnForWorker(state, column, columnsToEmit);
} else {
const repeat = Math.min(state.width, columnsToEmit);
for (let index = 0; index < repeat; index++) writeColumnToHistory(state, column);
}
}
if (!state.useWorker) {
drawSpectrogramImage(g, state, plotX, plotY, range, gamma);
}
drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH);
drawSpectrogramLegendHud(range, gamma);
if (showMeter) {
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
}
}
function drawSpectrogramBackground(g, plotX, plotY, plotW, plotH) {
g.save();
g.clearRect(plotX, plotY, plotW, plotH);
g.restore();
}
function setupWorker(state, widthPx, heightPx, range, gamma, freqBounds) {
const canvasEl = ensureSpectroCanvasElement(state);
if (!canvasEl) {
state.useWorker = false;
return;
}
if (!state.worker) {
try {
const workerUrl = new URL('../workers/spectrogram.worker.js', import.meta.url);
const worker = new Worker(workerUrl, { type: 'module' });
worker.onmessage = (event) => handleWorkerMessage(state, event);
worker.onerror = () => {
state.useWorker = false;
restartWorker(state);
};
const offscreen = canvasEl.transferControlToOffscreen();
state.spectroCanvasEl = canvasEl;
state.worker = worker;
state.workerReady = false;
state.offscreenWidth = widthPx;
state.offscreenHeight = heightPx;
state.spectroCanvasTransferred = true;
state.worker.postMessage({
type: 'init',
canvas: offscreen,
width: widthPx,
height: heightPx,
topDb: range.top,
bottomDb: range.bottom,
gamma,
fMin: freqBounds?.min,
fMax: freqBounds?.max,
}, [offscreen]);
state.lastTopDb = range.top;
state.lastBottomDb = range.bottom;
state.lastGamma = gamma;
} catch (e) {
state.useWorker = false;
teardownWorker(state);
return;
}
}
const needsResize = state.offscreenWidth !== widthPx || state.offscreenHeight !== heightPx;
if (needsResize) {
state.offscreenWidth = widthPx;
state.offscreenHeight = heightPx;
state.worker?.postMessage({
type: 'resize',
width: widthPx,
height: heightPx,
fMin: freqBounds?.min,
fMax: freqBounds?.max,
});
}
const needsConfigUpdate = state.lastTopDb !== range.top ||
state.lastBottomDb !== range.bottom ||
state.lastGamma !== gamma;
if (needsConfigUpdate) {
state.lastTopDb = range.top;
state.lastBottomDb = range.bottom;
state.lastGamma = gamma;
state.worker?.postMessage({
type: 'config',
topDb: range.top,
bottomDb: range.bottom,
gamma
});
}
}
function teardownWorker(state) {
clearWorkerWatchdog(state);
if (state.worker) {
try {
state.worker.postMessage({ type: 'dispose' });
setTimeout(() => {
try { state.worker.terminate(); } catch (_) {}
}, 10);
} catch (_) {}
}
state.worker = null;
state.workerReady = false;
state.workerBusy = false;
state.pendingWorkerColumn = null;
state.pendingWorkerRepeat = 0;
state.pendingWorkerDropped = 0;
}
function handleWorkerMessage(state, event) {
const data = event.data || {};
if (data.type === 'ready') {
state.workerReady = true;
state.workerBusy = false;
pumpWorkerColumn(state);
} else if (data.type === 'drawn') {
if (Number(data.id) !== Number(state.workerMessageId)) return;
clearWorkerWatchdog(state);
state.workerBusy = false;
state.workerStats.drawn += Math.max(0, Number(data.repeat) || 0);
state.workerStats.lastDrawMs = Math.max(0, Number(data.drawMs) || 0);
pumpWorkerColumn(state);
} else if (data.type === 'error') {
restartWorker(state);
} else if (data.type === 'dbg' && state._dbgEnabled) {
console.debug('[spectro worker]', data);
}
}
function queueColumnForWorker(state, column, repeat = 1) {
if (!column) return;
const requested = Math.max(1, Math.floor(Number(repeat) || 1));
if (state.pendingWorkerColumn) {
state.pendingWorkerDropped += 1;
state.workerStats.dropped += 1;
}
state.pendingWorkerColumn = column;
state.pendingWorkerRepeat = Math.min(
Math.max(1, state.offscreenWidth || state.width || 1),
Math.max(0, state.pendingWorkerRepeat) + requested,
);
pumpWorkerColumn(state);
}
function pumpWorkerColumn(state) {
if (!state.worker || !state.workerReady || state.workerBusy || !state.pendingWorkerColumn) return;
const column = state.pendingWorkerColumn;
const repeat = Math.min(MAX_SCROLL_COLUMNS_PER_DRAW, Math.max(1, state.pendingWorkerRepeat || 1));
const dropped = Math.max(0, state.pendingWorkerDropped || 0);
state.pendingWorkerColumn = null;
// A large value means the display was suspended or overloaded. Never catch
// up for seconds; jump by a bounded amount and continue with live data.
state.pendingWorkerRepeat = 0;
state.pendingWorkerDropped = 0;
state.workerBusy = true;
state.workerMessageId += 1;
const id = state.workerMessageId;
try {
state.worker.postMessage({ type: 'column', id, data: column, repeat, dropped }, [column.buffer]);
state.workerStats.sent += repeat;
} catch (e) {
restartWorker(state);
return;
}
clearWorkerWatchdog(state);
state.workerWatchdog = setTimeout(() => {
if (!state.workerBusy || id !== state.workerMessageId) return;
restartWorker(state);
}, WORKER_WATCHDOG_MS);
if (state._dbgEnabled && performance.now() >= state._dbgNextLog) {
state._dbgNextLog = performance.now() + 1000;
console.debug('[spectro]', { ...state.workerStats, busy: state.workerBusy, queuedRepeat: state.pendingWorkerRepeat });
}
}
function clearWorkerWatchdog(state) {
if (!state.workerWatchdog) return;
clearTimeout(state.workerWatchdog);
state.workerWatchdog = null;
}
function restartWorker(state) {
clearWorkerWatchdog(state);
if (state.worker) {
try { state.worker.terminate(); } catch (_) {}
}
state.worker = null;
state.workerReady = false;
state.workerBusy = false;
state.workerStats.restarts += 1;
removeSpectroCanvasElement(state);
}
function ensureFallbackBuffers(state, width, height, fillDb, g) {
const needsInit = !state.history ||
state.width !== width ||
state.height !== height ||
!state.imgData ||
!state.pixelData;
if (!needsInit) return;
state.width = width;
state.height = height;
state.history = Array.from({ length: width }, () => {
const arr = new Float32Array(height);
arr.fill(fillDb);
return arr;
});
state.writeIndex = 0;
state.scrollAccumulator = 0;
state._lastScrollTs = performance.now();
state.imgData = g.createImageData(width, height);
state.pixelData = state.imgData.data;
}
function ensureFreqMap(state, height, freqBounds, binCount, fs) {
const key = [
height,
freqBounds.min.toFixed(3),
freqBounds.max.toFixed(3),
binCount,
fs
].join('|');
if (state.freqMapKey === key && state.freqMap && state.freqMap.length === height) {
return;
}
state.freqMap = buildFreqMap(height, freqBounds, fs, binCount);
state.freqMapKey = key;
state.binCount = binCount;
}
function buildColumnData(state, freqBuf, fallbackDb) {
const height = state.height;
if (!height) return null;
const column = new Float32Array(height);
const freqMap = state.freqMap || [];
const bins = freqBuf.length;
for (let y = 0; y < height; y++) {
const map = freqMap[y];
if (!map) {
column[y] = fallbackDb;
continue;
}
let binLo = map.binLo;
let binHi = map.binHi;
if (!Number.isFinite(binLo)) binLo = 0;
if (!Number.isFinite(binHi)) binHi = binLo;
binLo = Math.max(0, Math.min(bins - 1, binLo));
binHi = Math.max(binLo, Math.min(bins - 1, binHi));
let linearSum = 0;
let count = 0;
let maxDb = -Infinity;
for (let bin = binLo; bin <= binHi; bin++) {
const dB = freqBuf[bin];
if (!Number.isFinite(dB)) continue;
linearSum += Math.exp(dB * DB_TO_LINEAR_FACTOR);
count++;
if (dB > maxDb) maxDb = dB;
}
if (count > 0) {
const avgLinear = linearSum / count;
column[y] = Math.log10(Math.max(avgLinear, 1e-12)) * 10;
} else if (Number.isFinite(maxDb)) {
column[y] = maxDb;
} else {
column[y] = fallbackDb;
}
}
return column;
}
function writeColumnToHistory(state, column) {
if (!state.history || !column) return;
const target = state.history[state.writeIndex];
if (target) target.set(column);
advanceWriteIndex(state);
}
function drawSpectrogramImage(g, state, plotX, plotY, range, gamma) {
const img = state.imgData;
const pixels = state.pixelData;
if (!img || !pixels || !state.history) return;
const width = state.width;
const height = state.height;
const totalRange = Math.max(1e-3, range.top - range.bottom);
const baseIndex = state.writeIndex;
const colorStops = SPECTRO_COLOR_STOPS;
for (let x = 0; x < width; x++) {
const historyIdx = (baseIndex + x) % width;
const column = state.history[historyIdx];
if (!column) continue;
for (let y = 0; y < height; y++) {
const dB = column[y];
const clamped = dB < range.bottom ? range.bottom : dB > range.top ? range.top : dB;
let norm = (clamped - range.bottom) / totalRange;
norm = Math.pow(norm < 0 ? 0 : norm > 1 ? 1 : norm, gamma);
const [r, gVal, bVal] = colorFromNorm(norm, colorStops);
const idx = ((height - 1 - y) * width + x) * 4;
pixels[idx + 0] = r;
pixels[idx + 1] = gVal;
pixels[idx + 2] = bVal;
pixels[idx + 3] = 255;
}
}
g.putImageData(img, plotX, plotY);
}
function drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH) {
const bounds = state.freqBounds;
if (!bounds) return;
const ticks = RTA_X_TICKS.filter((f) => f >= bounds.min && f <= bounds.max);
drawCachedSpectroGrid(state, g, plotX, plotY, plotW, plotH, bounds, ticks);
g.save();
g.fillStyle = '#bcd';
g.textAlign = 'right';
g.font = '12px monospace';
for (const freq of ticks) {
const y = freqToPixel(freq, state, plotY, plotH, bounds);
if (!Number.isFinite(y)) continue;
if (freq <= Math.max(20, bounds.min * 1.05)) {
g.textBaseline = 'alphabetic';
g.fillText(formatFreqLabel(freq), plotX - 6, y);
} else {
g.textBaseline = 'middle';
g.fillText(formatFreqLabel(freq), plotX - 6, y);
}
}
g.textAlign = 'right';
g.textBaseline = 'bottom';
g.fillText('Zeit →', plotX + plotW - 6, plotY + plotH - 4);
g.restore();
}
function resolveScrollRate(CONFIG) {
const mode = Number(CONFIG?.SPECTRO_SCROLL_MODE ?? 0);
if (mode === 0.5) return 0.5;
if (mode === 2) return 2;
if (mode === 4) return 4;
if (mode === 6) return 6;
return 1;
}
export function stepScrollAccumulator(accumulator, rate, frameDelta = 1) {
const safeAccumulator = Number.isFinite(Number(accumulator)) ? Math.max(0, Number(accumulator)) : 0;
const safeRate = Number.isFinite(Number(rate)) ? Math.max(0.01, Math.min(64, Number(rate))) : 1;
const safeDelta = Number.isFinite(Number(frameDelta)) ? Math.max(1, Math.floor(Number(frameDelta))) : 1;
const total = safeAccumulator + safeRate * safeDelta;
const columns = Math.floor(total + 1e-9);
return { columns, accumulator: Math.max(0, total - columns) };
}
export function spectrogramPixelsPerSourceFrame(rate, sampleRate, fftSize) {
const safeRate = [0.5, 1, 2, 4, 6].includes(Number(rate)) ? Number(rate) : 1;
const safeSampleRate = Math.max(8000, Number(sampleRate) || 48000);
const safeFftSize = Math.max(1024, Math.floor(Number(fftSize) || 4096));
const hopSamples = Math.max(128, Math.floor(safeFftSize / 8));
return BASE_SCROLL_CSS_PX_PER_SECOND * safeRate * hopSamples / safeSampleRate;
}
function advanceWriteIndex(state) {
const width = Math.max(1, state.width || 1);
state.writeIndex = (state.writeIndex + 1) % width;
}
function drawSpectrogramLegendHud(range, gamma = 1) {
if (!range) return;
const wrap = document.getElementById('spectroLegendWrap');
const canvas = document.getElementById('spectroLegend');
if (!wrap || !canvas) return;
if (wrap.style.display === 'none' || wrap.hidden) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssWidth = Math.max(200, canvas.clientWidth || canvas.width || 200);
const cssHeight = Math.max(32, canvas.clientHeight || canvas.height || 32);
const width = Math.round(cssWidth * dpr);
const height = Math.round(cssHeight * dpr);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.scale(dpr, dpr);
const w = width / dpr;
const h = height / dpr;
const span = Math.max(1e-3, range.top - range.bottom);
const paddingTop = 0;
const paddingSide = 20;
const gradientWidth = Math.max(80, w - paddingSide * 2);
const gradientHeight = Math.max(10, Math.min(18, h * 0.45));
const gradientX = paddingSide;
const gradientY = paddingTop;
const steps = Math.max(1, Math.round(gradientWidth));
const stepWidth = gradientWidth / steps;
for (let i = 0; i < steps; i++) {
const frac = steps <= 1 ? 0 : i / (steps - 1);
const gammaFrac = Math.pow(Math.min(1, Math.max(0, frac)), gamma);
const [r, gCol, bCol] = colorFromNorm(gammaFrac, SPECTRO_COLOR_STOPS);
ctx.fillStyle = `rgb(${r},${gCol},${bCol})`;
ctx.fillRect(gradientX + i * stepWidth, gradientY, stepWidth + 1, gradientHeight);
}
const ticks = [range.bottom, range.bottom + span / 2, range.top];
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.fillStyle = '#e6f9ff';
ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const gap = 9;
for (const value of ticks) {
const frac = (value - range.bottom) / span;
const x = gradientX + Math.min(1, Math.max(0, frac)) * gradientWidth;
ctx.beginPath();
ctx.moveTo(x, gradientY + gradientHeight);
ctx.lineTo(x, gradientY + gradientHeight + 7);
ctx.stroke();
ctx.fillText(`${formatDbLabel(value)} dB`, x, gradientY + gradientHeight + gap + 1);
}
ctx.restore();
}
function drawCachedSpectroGrid(state, ctx, plotX, plotY, plotW, plotH, bounds, ticks) {
const key = [
Math.round(plotW),
Math.round(plotH),
bounds.min.toFixed(2),
bounds.max.toFixed(2),
ticks.join(','),
TIME_GRID_SPACING
].join('|');
const needsRebuild = !state.gridCache
|| state.gridCache.key !== key
|| state.gridCache.width !== Math.round(plotW)
|| state.gridCache.height !== Math.round(plotH);
if (needsRebuild) {
const canvas = document.createElement('canvas');
const cw = Math.max(1, Math.round(plotW));
const ch = Math.max(1, Math.round(plotH));
canvas.width = cw;
canvas.height = ch;
const cg = canvas.getContext('2d');
if (cg) {
cg.save();
cg.scale(cw / plotW, ch / plotH);
cg.strokeStyle = FRAME_COLOR;
cg.lineWidth = 2;
cg.strokeRect(0.5, 0.5, plotW - 1, plotH - 1);
cg.strokeStyle = 'rgba(0,231,255,0.25)';
cg.lineWidth = 1;
for (const freq of ticks) {
const y = freqToPixel(freq, { height: plotH, freqBounds: bounds }, 0, plotH, bounds);
if (!Number.isFinite(y)) continue;
cg.beginPath();
cg.moveTo(0, y + 0.5);
cg.lineTo(plotW, y + 0.5);
cg.stroke();
}
// Removed vertical time grid lines for cleaner spectrogram view
cg.restore();
}
state.gridCache = { key, canvas, width: cw, height: ch };
}
ctx.drawImage(state.gridCache.canvas, plotX, plotY, plotW, plotH);
}
function freqToPixel(freq, state, plotY, plotH, bounds) {
if (!bounds) return plotY + plotH;
const minF = Math.max(10, bounds.min);
const maxF = Math.max(minF + 1, bounds.max);
const logRatio = Math.log(maxF / minF);
if (!Number.isFinite(logRatio) || logRatio <= 0) return plotY + plotH;
const frac = Math.log(freq / minF) / logRatio;
const height = state.height || plotH;
const row = (frac < 0 ? 0 : frac > 1 ? 1 : frac) * Math.max(0, height - 1);
return plotY + (height - 1 - row);
}
function formatFreqLabel(freq) {
if (freq >= 1000) {
const val = freq / 1000;
return val >= 10 ? `${Math.round(val)}k` : `${val.toFixed(1)}k`;
}
return String(Math.round(freq));
}
function formatDbLabel(value) {
if (!Number.isFinite(value)) return '0';
const rounded = Math.abs(value) < 10 ? value.toFixed(1) : Math.round(value);
return String(rounded);
}
async function drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter) {
const meterRect = {
x: plotX + plotW + METER_GAP,
y: plotY,
w: METER_WIDTH,
h: plotH
};
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
g.restore();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
const active = activeMeter || 'ppm-din';
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.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();
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
}
function getDbRange(CONFIG) {
const BASE_TOP_DB = -9;
const top = Number.isFinite(CONFIG.DBFS_TOP) ? CONFIG.DBFS_TOP : BASE_TOP_DB;
const bottom = Number.isFinite(CONFIG.DBFS_BOTTOM) ? CONFIG.DBFS_BOTTOM : -63;
return { top, bottom };
}
function getFreqBounds(CONFIG, nyq) {
const min = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5 : 20;
const max = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5000 : 20000;
return {
min: Math.max(10, min),
max: Math.min(Math.max(min + 1, max), nyq)
};
}
function buildFreqMap(height, freqBounds, fs, binCount) {
const nyq = fs / 2;
const minF = Math.max(10, freqBounds.min);
const maxF = Math.max(minF + 1, Math.min(freqBounds.max, nyq));
const denom = Math.max(1, height - 1);
const ratio = maxF / minF;
const map = new Array(height);
for (let y = 0; y < height; y++) {
const loPos = denom === 0 ? 0 : (y - 0.5) / denom;
const hiPos = denom === 0 ? 0 : (y + 0.5) / denom;
const fLo = minF * Math.pow(ratio, loPos < 0 ? 0 : loPos > 1 ? 1 : loPos);
const fHi = minF * Math.pow(ratio, hiPos < 0 ? 0 : hiPos > 1 ? 1 : hiPos);
const binLo = clampBin(Math.floor(freqToBin(fLo, nyq, binCount)), binCount);
const binHi = clampBin(Math.ceil(freqToBin(fHi, nyq, binCount)), binCount);
map[y] = {
binLo: Math.min(binLo, binHi),
binHi: Math.max(binLo, binHi)
};
}
return map;
}
function freqToBin(freq, nyq, binCount) {
if (!Number.isFinite(freq) || freq <= 0) return 0;
const maxIdx = Math.max(1, binCount - 1);
return (Math.min(freq, nyq) / nyq) * maxIdx;
}
function clampBin(idx, binCount) {
if (!Number.isFinite(idx)) return 0;
const maxIdx = Math.max(0, binCount - 1);
return idx < 0 ? 0 : idx > maxIdx ? maxIdx : idx;
}
function colorFromNorm(value, stops) {
const t = value < 0 ? 0 : value > 1 ? 1 : value;
for (let i = 1; i < stops.length; i++) {
const left = stops[i - 1];
const right = stops[i];
if (t <= right.t) {
const span = right.t - left.t || 1;
const rel = (t - left.t) / span;
return [
Math.round(left.color[0] + rel * (right.color[0] - left.color[0])),
Math.round(left.color[1] + rel * (right.color[1] - left.color[1])),
Math.round(left.color[2] + rel * (right.color[2] - left.color[2])),
];
}
}
const last = stops[stops.length - 1].color;
return [last[0], last[1], last[2]];
}
function layoutSpectroCanvas(state, plotX, plotY, plotW, plotH, widthPx, heightPx) {
const canvas = ensureSpectroCanvasElement(state);
if (!canvas) return;
canvas.style.left = `${plotX}px`;
canvas.style.top = `${plotY}px`;
canvas.style.width = `${plotW}px`;
canvas.style.height = `${plotH}px`;
if (!state.spectroCanvasTransferred) {
canvas.width = widthPx;
canvas.height = heightPx;
}
}
function ensureSpectroCanvasElement(state) {
if (state.spectroCanvasEl) return state.spectroCanvasEl;
let canvas = document.getElementById('spectroCanvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'spectroCanvas';
canvas.className = 'spectrogram-layer';
canvas.style.pointerEvents = 'none';
canvas.style.willChange = 'transform';
const overlay = document.getElementById('cv');
if (overlay && overlay.parentNode) {
overlay.parentNode.insertBefore(canvas, overlay);
} else {
document.body.appendChild(canvas);
}
}
state.spectroCanvasEl = canvas;
return canvas;
}
function removeSpectroCanvasElement(state) {
const canvas = state.spectroCanvasEl || document.getElementById('spectroCanvas');
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
}
state.spectroCanvasEl = null;
state.spectroCanvasTransferred = false;
}