// 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; }