323 lines
8.5 KiB
JavaScript
323 lines
8.5 KiB
JavaScript
// 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 (_) {}
|
|
}
|
|
}
|