// workers/spectrogram.worker.js // Handles Spectrogram rendering off the main thread using OffscreenCanvas. const FRAME_INTERVAL_MS = 1000 / 24; const MAX_QUEUE_AGE_MS = 2000; // wenn länger nichts geflossen ist, trotzdem zeichnen const MAX_PENDING_COLUMNS = 64; const LUT_SIZE = 256; const 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] }, ]; // Pre-calculated constants for performance const LUT_SIZE_MINUS_ONE = LUT_SIZE - 1; const state = { canvas: null, ctx: null, width: 0, height: 0, topDb: -9, bottomDb: -90, gamma: 0.9, fMin: 20, fMax: 20000, history: null, writeIndex: 0, imgData: null, pixels: null, lut: null, lastDraw: 0, drawTimer: null, pendingQueue: [], lastColumnTs: 0, }; self.onmessage = (event) => { const data = event.data || {}; switch (data.type) { case 'init': handleInit(data); break; case 'resize': handleResize(data); break; case 'config': handleConfig(data); break; case 'column': handleColumn(data); break; case 'dispose': dispose(); break; default: break; } }; function handleInit({ canvas, width, height, topDb, bottomDb, gamma, fMin, fMax }) { if (!canvas) return; state.canvas = canvas; state.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true }); if (!state.ctx) { postMessage({ type: 'error', error: 'ctx' }); return; } state.ctx.imageSmoothingEnabled = false; state.topDb = Number.isFinite(topDb) ? topDb : state.topDb; state.bottomDb = Number.isFinite(bottomDb) ? bottomDb : state.bottomDb; state.fMin = Number.isFinite(fMin) ? fMin : state.fMin; state.fMax = Number.isFinite(fMax) ? fMax : state.fMax; state.gamma = clampGamma(gamma); allocateBuffers(width, height); rebuildLut(); postMessage({ type: 'ready' }); } function handleResize({ width, height, fMin, fMax }) { if (!state.canvas || !state.ctx) return; if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return; allocateBuffers(width, height); if (Number.isFinite(fMin)) state.fMin = fMin; if (Number.isFinite(fMax)) state.fMax = fMax; } function handleConfig({ topDb, bottomDb, gamma }) { if (Number.isFinite(topDb)) state.topDb = topDb; if (Number.isFinite(bottomDb)) state.bottomDb = bottomDb; if (Number.isFinite(gamma)) state.gamma = clampGamma(gamma); rebuildLut(); } function handleColumn({ data }) { if (!data || !(data instanceof Float32Array)) return; if (!state.history || state.height !== data.length) { allocateBuffers(state.width, data.length); } // Ringpuffer: halte nur die letzten wenige Columns, neueste gewinnt state.pendingQueue.push(data); while (state.pendingQueue.length > MAX_PENDING_COLUMNS) state.pendingQueue.shift(); state.lastColumnTs = performance.now(); maybeDraw(); } function allocateBuffers(width, height) { const clampedWidth = Math.max(1, Math.floor(width)); const clampedHeight = Math.max(1, Math.floor(height)); // Vermeide unnötige Re-allokation if (state.history && state.width === clampedWidth && state.height === clampedHeight) { return; // Keine Größenänderung, behalte bestehende Buffers } state.width = clampedWidth; state.height = clampedHeight; if (state.canvas) { state.canvas.width = clampedWidth; state.canvas.height = clampedHeight; } // Allokiere neuen Buffer nur wenn nötig const neededSize = clampedWidth * clampedHeight; if (!state.history || state.history.length !== neededSize) { state.history = new Float32Array(neededSize); } state.history.fill(state.bottomDb); state.writeIndex = 0; if (state.ctx) { state.imgData = state.ctx.createImageData(clampedWidth, clampedHeight); state.pixels = state.imgData.data; } } function rebuildLut() { state.lut = buildLut(LUT_SIZE); } function maybeDraw() { if (!state.ctx || !state.imgData || !state.history) return; const now = performance.now(); const delta = now - state.lastDraw; const tooLongNoColumn = state.lastColumnTs && (now - state.lastColumnTs > MAX_QUEUE_AGE_MS); const readyToDraw = delta >= FRAME_INTERVAL_MS || tooLongNoColumn; if (readyToDraw) { if (drawFrame()) { state.lastDraw = now; } } else if (!state.drawTimer) { state.drawTimer = setTimeout(() => { state.drawTimer = null; if (drawFrame()) { state.lastDraw = performance.now(); } }, FRAME_INTERVAL_MS - delta); } } function drawFrame() { if (!state.ctx || !state.imgData || !state.history || !state.lut) return false; if (!flushPendingColumn()) return false; const { width, height, history, pixels, imgData } = state; const range = Math.max(1e-3, state.topDb - state.bottomDb); const lut = state.lut; const base = state.writeIndex; const invertedHeight = height - 1; for (let x = 0; x < width; x++) { const srcX = (base + x) % width; const srcXOffset = srcX; for (let y = 0; y < height; y++) { const dB = history[y * width + srcXOffset]; const clamped = dB < state.bottomDb ? state.bottomDb : dB > state.topDb ? state.topDb : dB; let norm = (clamped - state.bottomDb) / range; norm = norm < 0 ? 0 : norm > 1 ? 1 : norm; norm = Math.pow(norm, state.gamma); const lutIdx = Math.min(LUT_SIZE_MINUS_ONE, Math.round(norm * LUT_SIZE_MINUS_ONE)); const destY = invertedHeight - y; const pixelIndex = (destY * width + x) * 4; const lutOffset = lutIdx * 3; pixels[pixelIndex + 0] = lut[lutOffset + 0]; pixels[pixelIndex + 1] = lut[lutOffset + 1]; pixels[pixelIndex + 2] = lut[lutOffset + 2]; pixels[pixelIndex + 3] = 255; } } state.ctx.putImageData(imgData, 0, 0); return true; } function flushPendingColumn() { if (!state.pendingQueue.length || !state.history) return false; const width = state.width; const height = state.height; let writeIdx = state.writeIndex; const hist = state.history; while (state.pendingQueue.length) { const column = state.pendingQueue.shift(); if (!column) continue; const col = (column.length === height) ? column : normalizeColumn(column, height, state.bottomDb); for (let y = 0; y < height; y++) { const value = col[y]; hist[y * width + writeIdx] = Number.isFinite(value) ? value : state.bottomDb; } writeIdx = (writeIdx + 1) % width; } state.writeIndex = writeIdx; return true; } function normalizeColumn(column, targetHeight, fillDb) { const adjusted = new Float32Array(targetHeight); const copyLength = Math.min(column.length, targetHeight); adjusted.set(column.subarray(0, copyLength)); adjusted.fill(fillDb, copyLength); return adjusted; } function buildLut(size) { const lut = new Uint8ClampedArray(size * 3); const sizeMinusOne = size - 1; for (let i = 0; i < size; i++) { const t = i / sizeMinusOne; const [r, g, b] = colorFromStops(t); const offset = i * 3; lut[offset + 0] = r; lut[offset + 1] = g; lut[offset + 2] = b; } return lut; } function colorFromStops(tValue) { const t = tValue < 0 ? 0 : tValue > 1 ? 1 : tValue; for (let i = 1; i < COLOR_STOPS.length; i++) { const left = COLOR_STOPS[i - 1]; const right = COLOR_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 = COLOR_STOPS[COLOR_STOPS.length - 1].color; return [last[0], last[1], last[2]]; } function clampGamma(value) { if (!Number.isFinite(value)) return 0.9; if (value < 0.3) return 0.3; if (value > 1.2) return 1.2; return value; } function dispose() { if (state.drawTimer) { clearTimeout(state.drawTimer); state.drawTimer = null; } if (state.history) { state.history = null; } if (state.imgData) { state.imgData = null; } if (state.pixels) { state.pixels = null; } if (state.lut) { state.lut = null; } state.pendingColumn = null; state.canvas = null; state.ctx = null; if (typeof self.close === 'function') { try { self.close(); } catch (_) {} } }