Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// 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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// workers/waveform.worker.js — Renders min/max waveform data on OffscreenCanvas
|
||||
|
||||
const FRAME_INTERVAL_MS = 1000 / 48;
|
||||
const DEFAULT_COLORS = {
|
||||
leftColor: '#00e7ff',
|
||||
rightColor: '#ff6b81',
|
||||
diffColor: '#00e7ff',
|
||||
};
|
||||
const MODE_STACKED = 'stacked';
|
||||
const MODE_OVERLAY = 'overlay';
|
||||
const MODE_DIFF = 'diff';
|
||||
|
||||
const state = {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
mode: MODE_STACKED,
|
||||
colors: { ...DEFAULT_COLORS },
|
||||
pending: null,
|
||||
lastDraw: 0,
|
||||
timer: null,
|
||||
};
|
||||
|
||||
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 'data':
|
||||
handleData(data);
|
||||
break;
|
||||
case 'dispose':
|
||||
dispose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function handleInit({ canvas, width, height, mode, colors }) {
|
||||
if (!canvas) return;
|
||||
state.canvas = canvas;
|
||||
state.ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
state.mode = normalizeMode(mode);
|
||||
state.colors = normalizeColors(colors);
|
||||
handleResize({ width, height });
|
||||
postMessage({ type: 'ready' });
|
||||
}
|
||||
|
||||
function handleResize({ width, height }) {
|
||||
if (!state.canvas || !Number.isFinite(width) || !Number.isFinite(height)) return;
|
||||
state.canvas.width = Math.max(1, Math.floor(width));
|
||||
state.canvas.height = Math.max(1, Math.floor(height));
|
||||
state.width = state.canvas.width;
|
||||
state.height = state.canvas.height;
|
||||
}
|
||||
|
||||
function handleConfig({ mode, colors }) {
|
||||
state.mode = normalizeMode(mode);
|
||||
state.colors = normalizeColors(colors);
|
||||
}
|
||||
|
||||
function handleData({ minMaxL, minMaxR, channelCount }) {
|
||||
state.pending = {
|
||||
minMaxL,
|
||||
minMaxR,
|
||||
channelCount: channelCount || (minMaxR ? 2 : 1),
|
||||
};
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
if (!state.ctx || !state.pending) return;
|
||||
const now = performance.now();
|
||||
const delta = now - state.lastDraw;
|
||||
if (delta >= FRAME_INTERVAL_MS) {
|
||||
drawFrame();
|
||||
state.lastDraw = now;
|
||||
} else if (!state.timer) {
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
drawFrame();
|
||||
state.lastDraw = performance.now();
|
||||
}, FRAME_INTERVAL_MS - delta);
|
||||
}
|
||||
}
|
||||
|
||||
function drawFrame() {
|
||||
if (!state.ctx || !state.pending) return;
|
||||
const { minMaxL, minMaxR, channelCount } = state.pending;
|
||||
state.pending = null;
|
||||
state.ctx.clearRect(0, 0, state.width, state.height);
|
||||
const rects = getChannelRects(state.height, channelCount, state.mode);
|
||||
if (state.mode === MODE_DIFF) {
|
||||
const diffData = buildDiffData(minMaxL, minMaxR);
|
||||
if (diffData) {
|
||||
drawChannel(state.ctx, diffData, rects[0], state.colors.diffColor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.mode === MODE_STACKED && channelCount > 1 && rects.length >= 2) {
|
||||
drawChannel(state.ctx, minMaxL, rects[0], state.colors.leftColor);
|
||||
drawChannel(state.ctx, minMaxR || minMaxL, rects[1], state.colors.rightColor);
|
||||
} else {
|
||||
drawChannel(state.ctx, minMaxL, rects[0], state.colors.leftColor);
|
||||
if (channelCount > 1 && minMaxR) {
|
||||
drawChannel(state.ctx, minMaxR, rects[0], state.colors.rightColor, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeColors(colors) {
|
||||
return {
|
||||
leftColor: colors?.leftColor || DEFAULT_COLORS.leftColor,
|
||||
rightColor: colors?.rightColor || DEFAULT_COLORS.rightColor,
|
||||
diffColor: colors?.diffColor || DEFAULT_COLORS.diffColor,
|
||||
};
|
||||
}
|
||||
|
||||
function drawChannel(ctx, data, rect, color, alpha = 1) {
|
||||
if (!data || !data.length) return;
|
||||
const width = data.length / 2;
|
||||
const scaleX = width ? state.width / width : 1;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = x * 2;
|
||||
const min = clampAmp(data[idx]);
|
||||
const max = clampAmp(data[idx + 1]);
|
||||
const xPos = x * scaleX + 0.5;
|
||||
const yMax = ampToY(max, rect.y, rect.h);
|
||||
const yMin = ampToY(min, rect.y, rect.h);
|
||||
ctx.moveTo(xPos, yMax);
|
||||
ctx.lineTo(xPos, yMin);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getChannelRects(totalHeight, channelCount, mode) {
|
||||
if (mode === MODE_DIFF) {
|
||||
return [{ y: 0, h: totalHeight }];
|
||||
}
|
||||
if (mode === MODE_STACKED && channelCount > 1) {
|
||||
const gap = 6;
|
||||
const half = (totalHeight - gap) / 2;
|
||||
return [
|
||||
{ y: 0, h: half },
|
||||
{ y: half + gap, h: half },
|
||||
];
|
||||
}
|
||||
return [{ y: 0, h: totalHeight }];
|
||||
}
|
||||
|
||||
function ampToY(value, y, h) {
|
||||
const v = clampAmp(value);
|
||||
return y + (1 - ((v + 1) * 0.5)) * h;
|
||||
}
|
||||
|
||||
function clampAmp(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
}
|
||||
|
||||
function normalizeMode(mode) {
|
||||
if (mode === MODE_OVERLAY) return MODE_OVERLAY;
|
||||
if (mode === MODE_DIFF) return MODE_DIFF;
|
||||
return MODE_STACKED;
|
||||
}
|
||||
|
||||
function buildDiffData(minMaxL, minMaxR) {
|
||||
if (!minMaxL || !minMaxR) return null;
|
||||
const pairs = Math.min(minMaxL.length, minMaxR.length) / 2;
|
||||
if (!pairs) return null;
|
||||
const out = new Float32Array(pairs * 2);
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
const idx = i * 2;
|
||||
const lMin = clampAmp(minMaxL[idx]);
|
||||
const lMax = clampAmp(minMaxL[idx + 1]);
|
||||
const rMin = clampAmp(minMaxR[idx]);
|
||||
const rMax = clampAmp(minMaxR[idx + 1]);
|
||||
|
||||
// Korrigierte Differenz-Berechnung
|
||||
const diffMin = lMin - rMin;
|
||||
const diffMax = lMax - rMax;
|
||||
|
||||
out[idx] = clampAmp(Math.min(diffMin, diffMax));
|
||||
out[idx + 1] = clampAmp(Math.max(diffMin, diffMax));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
state.pending = null;
|
||||
state.canvas = null;
|
||||
state.ctx = null;
|
||||
}
|
||||
Reference in New Issue
Block a user