Recover analyzer DSP and realtime pipeline corrections

This commit is contained in:
Mikei386
2026-07-21 19:57:52 +02:00
parent e499bac928
commit 91aeccb938
27 changed files with 3410 additions and 924 deletions
+145 -236
View File
@@ -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();
}