Files
Phoenix/www/workers/spectrogram.worker.js

232 lines
6.8 KiB
JavaScript

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