// Incremental OffscreenCanvas renderer for the spectrogram. // One message is processed at a time and acknowledged. The sender therefore // never needs to build an unbounded postMessage queue. 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] }, ]; const state = { canvas: null, ctx: null, width: 0, height: 0, topDb: -9, bottomDb: -90, gamma: 0.9, lut: null, stripe: null, stripePixels: null, drawn: 0, dropped: 0, }; self.onmessage = (event) => { const message = event.data || {}; switch (message.type) { case 'init': init(message); break; case 'resize': resize(message); break; case 'config': configure(message); break; case 'column': drawColumn(message); break; case 'dispose': dispose(); break; default: break; } }; function init({ canvas, width, height, topDb, bottomDb, gamma }) { if (!canvas) return; state.canvas = canvas; state.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true }); if (!state.ctx) { postMessage({ type: 'error', error: '2d-context' }); return; } state.ctx.imageSmoothingEnabled = false; applyConfig(topDb, bottomDb, gamma); allocate(width, height); clearCanvas(); postMessage({ type: 'ready' }); } function resize({ width, height }) { if (!state.ctx) return; const nextWidth = positiveInt(width, state.width || 1); const nextHeight = positiveInt(height, state.height || 1); if (nextWidth === state.width && nextHeight === state.height) return; allocate(nextWidth, nextHeight); clearCanvas(); postMessage({ type: 'resized', width: state.width, height: state.height }); } function configure({ topDb, bottomDb, gamma }) { const before = `${state.topDb}|${state.bottomDb}|${state.gamma}`; applyConfig(topDb, bottomDb, gamma); const after = `${state.topDb}|${state.bottomDb}|${state.gamma}`; if (before !== after) { // Old pixels use the previous colour transfer function. Clearing avoids a // misleading mixed scale after a range/gamma change. clearCanvas(); } } function applyConfig(topDb, bottomDb, gamma) { if (Number.isFinite(topDb)) state.topDb = Number(topDb); if (Number.isFinite(bottomDb)) state.bottomDb = Number(bottomDb); if (state.topDb <= state.bottomDb) state.topDb = state.bottomDb + 1; if (Number.isFinite(gamma)) state.gamma = clamp(Number(gamma), 0.3, 1.2); state.lut = buildLut(); } function allocate(width, height) { state.width = positiveInt(width, 1); state.height = positiveInt(height, 1); state.canvas.width = state.width; state.canvas.height = state.height; state.stripe = null; state.stripePixels = null; } function clearCanvas() { if (!state.ctx) return; state.ctx.save(); state.ctx.globalCompositeOperation = 'copy'; state.ctx.fillStyle = '#000'; state.ctx.fillRect(0, 0, state.width, state.height); state.ctx.restore(); } function drawColumn({ id, data, repeat = 1, dropped = 0 }) { const started = performance.now(); if (!state.ctx || !(data instanceof Float32Array) || !data.length) { postMessage({ type: 'drawn', id, drawMs: 0, repeat: 0, error: 'invalid-column' }); return; } const shift = Math.min(state.width, Math.max(1, Math.floor(Number(repeat) || 1))); ensureStripe(shift); colourStripe(data, shift); if (shift < state.width) { // Canvas-to-self copy is defined as if the source were captured before the // draw, so overlapping left shifts do not smear the image. state.ctx.drawImage( state.canvas, shift, 0, state.width - shift, state.height, 0, 0, state.width - shift, state.height, ); } state.ctx.putImageData(state.stripe, state.width - shift, 0); state.drawn += shift; state.dropped += Math.max(0, Number(dropped) || 0); postMessage({ type: 'drawn', id, repeat: shift, drawMs: performance.now() - started, drawn: state.drawn, dropped: state.dropped, }); } function ensureStripe(width) { if (state.stripe && state.stripe.width === width && state.stripe.height === state.height) return; state.stripe = state.ctx.createImageData(width, state.height); state.stripePixels = state.stripe.data; } function colourStripe(column, stripeWidth) { const pixels = state.stripePixels; const range = Math.max(1e-6, state.topDb - state.bottomDb); const sourceLast = Math.max(0, column.length - 1); const targetLast = Math.max(1, state.height - 1); for (let targetY = 0; targetY < state.height; targetY++) { // Source columns are low-to-high frequency; canvas rows are top-to-bottom. const sourcePos = (state.height - 1 - targetY) * sourceLast / targetLast; const lo = Math.floor(sourcePos); const hi = Math.min(sourceLast, lo + 1); const frac = sourcePos - lo; const a = finiteOr(column[lo], state.bottomDb); const b = finiteOr(column[hi], a); const db = a + (b - a) * frac; const norm = Math.pow(clamp((db - state.bottomDb) / range, 0, 1), state.gamma); const lutIndex = Math.min(LUT_SIZE - 1, Math.round(norm * (LUT_SIZE - 1))) * 3; for (let x = 0; x < stripeWidth; x++) { const pixel = (targetY * stripeWidth + x) * 4; pixels[pixel] = state.lut[lutIndex]; pixels[pixel + 1] = state.lut[lutIndex + 1]; pixels[pixel + 2] = state.lut[lutIndex + 2]; pixels[pixel + 3] = 255; } } } function buildLut() { const lut = new Uint8ClampedArray(LUT_SIZE * 3); for (let index = 0; index < LUT_SIZE; index++) { const [r, g, b] = colorFromStops(index / (LUT_SIZE - 1)); const offset = index * 3; lut[offset] = r; lut[offset + 1] = g; lut[offset + 2] = b; } return lut; } function colorFromStops(value) { const t = clamp(value, 0, 1); for (let index = 1; index < COLOR_STOPS.length; index++) { const left = COLOR_STOPS[index - 1]; const right = COLOR_STOPS[index]; if (t <= right.t) { const rel = (t - left.t) / Math.max(1e-9, right.t - left.t); 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])), ]; } } return COLOR_STOPS[COLOR_STOPS.length - 1].color.slice(); } function positiveInt(value, fallback) { const number = Math.floor(Number(value)); return Number.isFinite(number) && number > 0 ? number : fallback; } function finiteOr(value, fallback) { return Number.isFinite(value) ? value : fallback; } function clamp(value, min, max) { return value < min ? min : value > max ? max : value; } function dispose() { state.stripe = null; state.stripePixels = null; state.lut = null; state.canvas = null; state.ctx = null; if (typeof self.close === 'function') self.close(); }