232 lines
6.8 KiB
JavaScript
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();
|
|
}
|