837 lines
25 KiB
JavaScript
837 lines
25 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 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,
|
|
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;
|
|
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);
|
|
let spectroDirty = true;
|
|
if (hasPhoenixSpectro) {
|
|
spectroDirty = phoenixSpectroSeq !== (state.lastPhoenixSpectroSeq || 0);
|
|
}
|
|
|
|
let columnsToEmit = 0;
|
|
if (hasPhoenixSpectro) {
|
|
if (spectroDirty) {
|
|
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate;
|
|
while (state.scrollAccumulator >= 1) {
|
|
columnsToEmit += 1;
|
|
state.scrollAccumulator -= 1;
|
|
}
|
|
if (columnsToEmit < 1) {
|
|
columnsToEmit = 1;
|
|
state.scrollAccumulator = 0;
|
|
}
|
|
}
|
|
} 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);
|
|
const columns = [];
|
|
for (let i = 0; i < columnsToEmit; i++) {
|
|
columns.push(i === 0 ? column : new Float32Array(column));
|
|
}
|
|
|
|
if (state.useWorker && state.workerReady) {
|
|
columns.forEach(col => sendColumnToWorker(state, col));
|
|
} else {
|
|
columns.forEach(col => writeColumnToHistory(state, col));
|
|
}
|
|
}
|
|
|
|
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;
|
|
teardownWorker(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) {
|
|
if (state.worker) {
|
|
try {
|
|
state.worker.postMessage({ type: 'dispose' });
|
|
setTimeout(() => {
|
|
try { state.worker.terminate(); } catch (_) {}
|
|
}, 10);
|
|
} catch (_) {}
|
|
}
|
|
state.worker = null;
|
|
state.workerReady = false;
|
|
state.spectroCanvasEl = null;
|
|
state.spectroCanvasTransferred = false;
|
|
}
|
|
|
|
function handleWorkerMessage(state, event) {
|
|
const data = event.data || {};
|
|
if (data.type === 'ready') {
|
|
state.workerReady = true;
|
|
} else if (data.type === 'dbg' && state._dbgEnabled) {
|
|
console.debug('[spectro worker]', data);
|
|
}
|
|
}
|
|
|
|
function sendColumnToWorker(state, column) {
|
|
if (!state.worker || !column) return;
|
|
|
|
// Prüfe ob Worker bereit ist und nicht überlastet
|
|
if (!state.workerReady) return;
|
|
|
|
try {
|
|
state.worker.postMessage({ type: 'column', data: column }, [column.buffer]);
|
|
} catch (e) {
|
|
state.useWorker = false;
|
|
}
|
|
advanceWriteIndex(state);
|
|
|
|
// Debug: alle ~1s senden wir ein Status-Log
|
|
if (state._dbgEnabled && performance.now() >= state._dbgNextLog) {
|
|
state._dbgNextLog = performance.now() + 1000;
|
|
console.debug('[spectro main] sent column', { writeIndex: state.writeIndex });
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|