Files
2026-06-02 20:56:19 +02:00

488 lines
15 KiB
JavaScript

// views/waveform.js — Live waveform display with optional worker renderer
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
const PLOT = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
const METER_WIDTH = 140;
const METER_GAP = 8;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
const MIN_HEIGHT = 80;
const MODE_STACKED = 'stacked';
const MODE_OVERLAY = 'overlay';
const MODE_DIFF = 'diff';
const WAVEFORM_RENDER_SCALE = 0.9;
const SUPPORTS_WORKER = typeof window !== 'undefined'
&& typeof Worker !== 'undefined'
&& typeof HTMLCanvasElement !== 'undefined'
&& !!HTMLCanvasElement.prototype.transferControlToOffscreen;
export const id = 'waveform';
export function init() {
return {
useWorker: SUPPORTS_WORKER,
worker: null,
workerReady: false,
canvasEl: null,
canvasTransferred: false,
offscreenWidth: 0,
offscreenHeight: 0,
lastMode: MODE_STACKED,
lastWorkerColorsKey: '',
lastChannels: 1,
diffScratch: new Float32Array(0),
gridCache: null,
};
}
export function destroy(state) {
teardownWorker(state);
removeWaveCanvasElement(state);
state.gridCache = null;
}
export function resize() {}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
const plotX = PLOT.left;
const topInset = Number.isFinite(Number(env?.topInset)) ? Number(env.topInset) : PLOT.top;
const plotY = topInset;
const canvasOffsetX = Number.isFinite(Number(env.embeddedOffsetX)) ? Number(env.embeddedOffsetX) : 0;
const canvasOffsetY = Number.isFinite(Number(env.embeddedOffsetY)) ? Number(env.embeddedOffsetY) : 0;
const slotList = env.slots ? env.slots(id) : null;
const activeMeter = (slotList && slotList[0]) || 'ppm-din';
const showMeter = activeMeter !== 'none';
const plotW = Math.max(16, Math.floor(rect.w - PLOT.left - PLOT.right - (showMeter ? (METER_WIDTH + METER_GAP) : 0)));
const plotH = Math.max(MIN_HEIGHT, Math.floor(rect.h - plotY - PLOT.bottom));
const dpr = Math.max(1, window.devicePixelRatio || 1);
const renderScale = Math.max(0.5, Math.min(1, WAVEFORM_RENDER_SCALE));
const pixelWidth = Math.max(32, Math.floor(plotW * dpr * renderScale));
const pixelHeight = Math.max(32, Math.floor(plotH * dpr * renderScale));
layoutWaveCanvas(state, canvasOffsetX + plotX, canvasOffsetY + plotY, plotW, plotH, pixelWidth, pixelHeight);
if (!state.useWorker) {
detachWaveCanvas(state);
}
const mode = normalizeMode(CONFIG.WAVEFORM_MODE);
const windowSec = clampWindow(CONFIG.WAVEFORM_WINDOW_SEC);
const envelope = audio?.getWaveformEnvelope
? audio.getWaveformEnvelope(pixelWidth, windowSec)
: null;
let payload = envelope
? {
minMaxL: envelope.minMaxL,
minMaxR: envelope.minMaxR,
pixelWidth: envelope.pixelWidth || pixelWidth,
cssWidth: plotW,
cssHeight: plotH,
channels: envelope.channels || 1,
mode,
config: {
leftColor: CONFIG.WAVEFORM_COLOR_LEFT,
rightColor: CONFIG.WAVEFORM_COLOR_RIGHT,
diffColor: CONFIG.WAVEFORM_COLOR_DIFF,
},
}
: null;
if (payload) {
state.lastChannels = payload.channels;
}
drawWaveformBackground(g, plotX, plotY, plotW, plotH);
if (state.useWorker) {
setupWaveformWorker(state, pixelWidth, pixelHeight, mode, payload?.config || null);
if (state.workerReady && payload) {
sendWaveformToWorker(state, payload);
payload = null;
}
}
if (!state.useWorker && payload) {
drawWaveformFallback(g, state, plotX, plotY, plotW, plotH, payload, mode);
}
const gridChannels = mode === MODE_DIFF ? 1 : state.lastChannels;
drawWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, gridChannels);
if (showMeter) {
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
}
}
function drawWaveformBackground(g, plotX, plotY, plotW, plotH) {
g.save();
g.clearRect(plotX, plotY, plotW, plotH);
g.restore();
}
function drawWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, channelCount) {
drawCachedWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, channelCount);
}
function drawCachedWaveformGrid(g, state, plotX, plotY, plotW, plotH, mode, channelCount) {
if (!state) {
drawWaveformGridDirect(g, plotX, plotY, plotW, plotH, mode, channelCount);
return;
}
const key = [
Math.round(plotW),
Math.round(plotH),
mode,
channelCount,
].join('|');
const needsRebuild = !state.gridCache
|| state.gridCache.key !== key
|| state.gridCache.width !== Math.round(plotW)
|| state.gridCache.height !== Math.round(plotH);
if (needsRebuild) {
const canvas = document.createElement('canvas');
const cw = Math.max(1, Math.round(plotW));
const ch = Math.max(1, Math.round(plotH));
canvas.width = cw;
canvas.height = ch;
const cg = canvas.getContext('2d');
if (cg) {
drawWaveformGridDirect(cg, 0, 0, plotW, plotH, mode, channelCount);
}
state.gridCache = { key, canvas, width: cw, height: ch };
}
g.drawImage(state.gridCache.canvas, plotX, plotY, plotW, plotH);
}
function drawWaveformGridDirect(g, plotX, plotY, plotW, plotH, mode, channelCount) {
const rects = getChannelRects(plotY, plotH, channelCount, mode);
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(plotX, plotY, plotW, plotH);
g.lineWidth = 1;
g.setLineDash([4, 6]);
for (const rect of rects) {
const lines = [-1, -0.5, 0.5, 1];
g.strokeStyle = 'rgba(0,231,255,0.25)';
for (const amp of lines) {
const y = ampToY(amp, rect.y, rect.h) + 0.5;
g.beginPath();
g.moveTo(plotX, y);
g.lineTo(plotX + plotW, y);
g.stroke();
}
g.strokeStyle = 'rgba(0,231,255,0.45)';
const zeroY = ampToY(0, rect.y, rect.h) + 0.5;
g.setLineDash([]);
g.beginPath();
g.moveTo(plotX, zeroY);
g.lineTo(plotX + plotW, zeroY);
g.stroke();
g.setLineDash([4, 6]);
}
g.setLineDash([]);
g.fillStyle = '#bcd';
g.textAlign = 'left';
g.textBaseline = 'top';
g.font = 'bold 14px ui-monospace, monospace';
g.fillText('now', plotX + plotW - 34, plotY + plotH - 18);
const showChannelLabels = mode === MODE_STACKED && channelCount > 1;
if (showChannelLabels) {
g.font = 'bold 38px ui-monospace, monospace';
g.textBaseline = 'top';
g.textAlign = 'left';
g.fillText('L', plotX + 6, rects[0]?.y + 4 || plotY + 4);
g.textAlign = 'left';
g.textBaseline = 'bottom';
g.fillText('R', plotX + 6, plotY + plotH - 6);
} else if (mode === MODE_DIFF) {
g.font = 'bold 32px ui-monospace, monospace';
g.textBaseline = 'top';
g.textAlign = 'left';
g.fillText('Δ', plotX + 6, rects[0]?.y + 4 || plotY + 4);
}
g.restore();
}
function drawWaveformFallback(g, state, plotX, plotY, plotW, plotH, payload, mode) {
const cfg = payload.config || {};
const displayChannels = mode === MODE_DIFF ? 1 : payload.channels;
const rects = getChannelRects(plotY, plotH, displayChannels, mode);
const scaleX = payload.pixelWidth ? (plotW / payload.pixelWidth) : 1;
const drawChannel = (data, rect, color, alpha = 1) => {
if (!data) return;
g.save();
g.strokeStyle = color;
g.globalAlpha = alpha;
for (let x = 0; x < payload.pixelWidth; x++) {
const idx = x * 2;
const min = clampAmp(data[idx]);
const max = clampAmp(data[idx + 1]);
const xPos = plotX + x * scaleX + 0.5;
g.beginPath();
g.moveTo(xPos, ampToY(max, rect.y, rect.h));
g.lineTo(xPos, ampToY(min, rect.y, rect.h));
g.stroke();
}
g.restore();
};
if (mode === MODE_DIFF) {
const rect = rects[0];
const diffData = buildDiffMinMax(state, payload.minMaxL, payload.minMaxR, payload.pixelWidth);
if (diffData) {
const col = cfg.diffColor || FRAME_COLOR;
drawChannel(diffData, rect, col, 1);
}
} else if (mode === MODE_STACKED && payload.channels > 1) {
const colL = cfg.leftColor || FRAME_COLOR;
const colR = cfg.rightColor || '#ff6b81';
drawChannel(payload.minMaxL, rects[0], colL, 1);
drawChannel(payload.minMaxR || payload.minMaxL, rects[1], colR, 1);
} else {
const rect = rects[0];
const colL = cfg.leftColor || FRAME_COLOR;
const colR = cfg.rightColor || '#ff6b81';
drawChannel(payload.minMaxL, rect, colL, 1);
if (payload.channels > 1 && payload.minMaxR) {
drawChannel(payload.minMaxR, rect, colR, 0.75);
}
}
}
function buildDiffMinMax(state, minMaxL, minMaxR) {
if (!minMaxL || !minMaxR) return null;
const pairs = Math.min(minMaxL.length, minMaxR.length) / 2;
if (!pairs || pairs <= 0) return null;
if (!state.diffScratch || state.diffScratch.length !== pairs * 2) {
state.diffScratch = new Float32Array(pairs * 2);
}
const out = state.diffScratch;
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 setupWaveformWorker(state, width, height, mode, colors) {
const workerColors = normalizeWaveformColors(colors);
const workerColorsKey = getWaveformColorsKey(workerColors);
if (!state.worker) {
const canvas = ensureWaveCanvasElement(state);
if (!canvas) {
state.useWorker = false;
return;
}
try {
if (!state.canvasTransferred) {
canvas.width = width;
canvas.height = height;
}
const offscreen = canvas.transferControlToOffscreen();
const workerUrl = new URL('../workers/waveform.worker.js', import.meta.url);
const worker = new Worker(workerUrl, { type: 'module' });
worker.onmessage = (event) => {
if (event.data?.type === 'ready') state.workerReady = true;
};
worker.onerror = (err) => console.warn('Waveform worker error:', err?.message || err);
worker.postMessage({
type: 'init',
canvas: offscreen,
width,
height,
mode,
colors: workerColors,
}, [offscreen]);
state.worker = worker;
state.workerReady = false;
state.offscreenWidth = width;
state.offscreenHeight = height;
state.canvasTransferred = true;
state.lastMode = mode;
state.lastWorkerColorsKey = workerColorsKey;
} catch (err) {
console.warn('Waveform worker init failed, using fallback:', err);
state.useWorker = false;
teardownWorker(state);
}
return;
}
if (state.offscreenWidth !== width || state.offscreenHeight !== height) {
state.offscreenWidth = width;
state.offscreenHeight = height;
state.worker.postMessage({ type: 'resize', width, height });
}
if (state.lastMode !== mode || state.lastWorkerColorsKey !== workerColorsKey) {
state.lastMode = mode;
state.lastWorkerColorsKey = workerColorsKey;
state.worker.postMessage({ type: 'config', mode, colors: workerColors });
}
}
function normalizeWaveformColors(colors) {
return {
leftColor: colors?.leftColor || FRAME_COLOR,
rightColor: colors?.rightColor || '#ff6b81',
diffColor: colors?.diffColor || FRAME_COLOR,
};
}
function getWaveformColorsKey(colors) {
return [colors.leftColor, colors.rightColor, colors.diffColor].join('|');
}
function sendWaveformToWorker(state, payload) {
if (!state.worker) return;
state.worker.postMessage({
type: 'data',
channelCount: payload.channels,
mode: payload.mode,
minMaxL: payload.minMaxL,
minMaxR: payload.minMaxR,
});
}
function teardownWorker(state) {
if (state.worker) {
try { state.worker.postMessage({ type: 'dispose' }); } catch (_) {}
try { state.worker.terminate(); } catch (_) {}
}
state.worker = null;
state.workerReady = false;
state.canvasTransferred = false;
state.lastWorkerColorsKey = '';
removeWaveCanvasElement(state);
}
function layoutWaveCanvas(state, plotX, plotY, plotW, plotH, widthPx, heightPx) {
const canvas = ensureWaveCanvasElement(state);
if (!canvas) return;
canvas.style.left = `${plotX}px`;
canvas.style.top = `${plotY}px`;
canvas.style.width = `${plotW}px`;
canvas.style.height = `${plotH}px`;
canvas.style.display = plotW > 0 && plotH > 0 ? 'block' : 'none';
if (!state.canvasTransferred) {
canvas.width = widthPx;
canvas.height = heightPx;
}
}
function detachWaveCanvas(state) {
const canvas = ensureWaveCanvasElement(state);
if (canvas) {
canvas.style.width = '0px';
canvas.style.height = '0px';
canvas.style.display = 'none';
}
}
function ensureWaveCanvasElement(state) {
if (state.canvasEl) return state.canvasEl;
let canvas = document.getElementById('waveformCanvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'waveformCanvas';
canvas.className = 'waveform-layer';
const overlay = document.getElementById('cv');
if (overlay && overlay.parentNode) {
overlay.parentNode.insertBefore(canvas, overlay);
} else {
document.body.appendChild(canvas);
}
}
state.canvasEl = canvas;
return canvas;
}
function removeWaveCanvasElement(state) {
const canvas = state.canvasEl || document.getElementById('waveformCanvas');
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
}
state.canvasEl = null;
state.canvasTransferred = false;
}
function clampAmp(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function ampToY(value, y, h) {
const clamped = clampAmp(value);
return y + (1 - ((clamped + 1) * 0.5)) * h;
}
function getChannelRects(plotY, plotH, channelCount, mode) {
if (mode === MODE_DIFF) {
return [{ y: plotY, h: plotH }];
}
if (mode === MODE_STACKED && channelCount > 1) {
const gap = 8;
const half = (plotH - gap) / 2;
return [
{ y: plotY, h: half },
{ y: plotY + half + gap, h: half },
];
}
return [{ y: plotY, h: plotH }];
}
async function drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter) {
const meterRect = { x: plotX + plotW + METER_GAP, y: plotY, w: METER_WIDTH, h: plotH };
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
g.restore();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
const active = activeMeter || 'ppm-din';
if (active === 'none') return;
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, meterRect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const innerRect = {
x: meterRect.x,
y: meterRect.y + METER_PAD_TOP + innerOffset,
w: meterRect.w,
h: innerHeight,
};
g.save();
g.beginPath();
g.rect(meterRect.x + g.lineWidth / 2, meterRect.y + g.lineWidth / 2, meterRect.w - g.lineWidth, meterRect.h - g.lineWidth);
g.clip();
await meters.draw(g, innerRect, active, CONFIG);
g.restore();
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
}
function clampWindow(value) {
if (!Number.isFinite(value)) return 1;
const min = 0.1;
const max = 15;
if (value < min) return min;
if (value > max) return max;
return value;
}
function normalizeMode(mode) {
if (mode === MODE_OVERLAY) return MODE_OVERLAY;
if (mode === MODE_DIFF) return MODE_DIFF;
return MODE_STACKED;
}