488 lines
15 KiB
JavaScript
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;
|
|
}
|