Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user