Recover analyzer DSP and realtime pipeline corrections
This commit is contained in:
+145
-236
@@ -1,9 +1,7 @@
|
||||
// workers/spectrogram.worker.js
|
||||
// Handles Spectrogram rendering off the main thread using OffscreenCanvas.
|
||||
// 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 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] },
|
||||
@@ -13,9 +11,6 @@ const COLOR_STOPS = [
|
||||
{ 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,
|
||||
@@ -24,33 +19,27 @@ const state = {
|
||||
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,
|
||||
stripe: null,
|
||||
stripePixels: null,
|
||||
drawn: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const data = event.data || {};
|
||||
switch (data.type) {
|
||||
const message = event.data || {};
|
||||
switch (message.type) {
|
||||
case 'init':
|
||||
handleInit(data);
|
||||
init(message);
|
||||
break;
|
||||
case 'resize':
|
||||
handleResize(data);
|
||||
resize(message);
|
||||
break;
|
||||
case 'config':
|
||||
handleConfig(data);
|
||||
configure(message);
|
||||
break;
|
||||
case 'column':
|
||||
handleColumn(data);
|
||||
drawColumn(message);
|
||||
break;
|
||||
case 'dispose':
|
||||
dispose();
|
||||
@@ -60,221 +49,155 @@ self.onmessage = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
function handleInit({ canvas, width, height, topDb, bottomDb, gamma, fMin, fMax }) {
|
||||
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: 'ctx' });
|
||||
postMessage({ type: 'error', error: '2d-context' });
|
||||
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();
|
||||
|
||||
applyConfig(topDb, bottomDb, gamma);
|
||||
allocate(width, height);
|
||||
clearCanvas();
|
||||
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 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 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 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 rebuildLut() {
|
||||
state.lut = buildLut(LUT_SIZE);
|
||||
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 maybeDraw() {
|
||||
if (!state.ctx || !state.imgData || !state.history) return;
|
||||
|
||||
const now = performance.now();
|
||||
const delta = now - state.lastDraw;
|
||||
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;
|
||||
}
|
||||
|
||||
const tooLongNoColumn = state.lastColumnTs && (now - state.lastColumnTs > MAX_QUEUE_AGE_MS);
|
||||
const readyToDraw = delta >= FRAME_INTERVAL_MS || tooLongNoColumn;
|
||||
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();
|
||||
}
|
||||
|
||||
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 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 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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(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];
|
||||
|
||||
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 span = right.t - left.t || 1;
|
||||
const rel = (t - left.t) / span;
|
||||
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])),
|
||||
@@ -282,41 +205,27 @@ function colorFromStops(tValue) {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const last = COLOR_STOPS[COLOR_STOPS.length - 1].color;
|
||||
return [last[0], last[1], last[2]];
|
||||
return COLOR_STOPS[COLOR_STOPS.length - 1].color.slice();
|
||||
}
|
||||
|
||||
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 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() {
|
||||
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.stripe = null;
|
||||
state.stripePixels = null;
|
||||
state.lut = null;
|
||||
state.canvas = null;
|
||||
state.ctx = null;
|
||||
|
||||
if (typeof self.close === 'function') {
|
||||
try { self.close(); } catch (_) {}
|
||||
}
|
||||
if (typeof self.close === 'function') self.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user