// 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; }