Initial Phoenix analyzer

This commit is contained in:
Mikei386
2026-06-02 20:56:19 +02:00
commit e499bac928
62 changed files with 28893 additions and 0 deletions
+868
View File
@@ -0,0 +1,868 @@
// views/classic_needles.js - VU-Meter als klassisches Nadelinstrument
// Nutzt die bestehenden VU-Werte (inkl. Offsets/Kalibrierung) und zeichnet
// zwei kompakte, horizontale Nadelinstrumente (L/R) mit Slot-Auswahl.
import { drawCachedStaticLayer } from './static_layer.js';
export const id = 'classic-needles';
const PANEL_BG = 'rgba(5,5,11,0.78)';
const FRAME_STROKE = 'rgba(0,231,255,0.8)';
const SCALE_ARC_STROKE = 'rgb(0,0,255)'; // wie Tick-Linien der Balken-Meter
const METER_PIVOT_Y_FRAC = 0.70;
const METER_RADIUS_FRAC = 0.50;
const NEEDLE_CAP_RADIUS = 8;
const NEEDLE_SPRING_DAMPING = 0.92; // 1.0 = kritisch gedämpft (kein Überschwingen)
const NEEDLE_SPRING_HZ_UP = 6.5; // nur UI-Physik (VU-Ballistik ist bereits im Worklet)
const NEEDLE_SPRING_HZ_DOWN = 4.2;
const NEEDLE_GOAL_TAU_S = 0.04; // ganz leichtes Smoothing gegen UI-Stufen/Jitter
const FIELD_DIVIDER_STROKE = 'rgba(0,231,255,0.4)'; // wie Felder-Trennlinien
const FIELD_DIVIDER_DASH = [4, 3];
// Spannweite wie klassisches VU-Foto: kein Halbkreis, sondern flacherer Bogen
const ANGLE_START = degToRad(200) - Math.PI * 2; // links unten
const ANGLE_END = degToRad(340) - Math.PI * 2; // rechts unten (-20°)
const VU_DEFLECTION = [
{ db: -20, frac: 0.000 },
{ db: -10, frac: 0.165 },
{ db: -7, frac: 0.264 },
{ db: -5, frac: 0.352 },
{ db: -3, frac: 0.463 },
{ db: 0, frac: 0.686 },
{ db: +1, frac: 0.779 },
{ db: +2, frac: 0.883 },
{ db: +3, frac: 1.000 },
];
const DIN_SCALE = [
{ db: -50, pos: 0.0205 },
{ db: -40, pos: 0.0564 },
{ db: -35, pos: 0.0974 },
{ db: -30, pos: 0.1538 },
{ db: -25, pos: 0.2308 },
{ db: -20, pos: 0.3179 },
{ db: -15, pos: 0.4359 },
{ db: -10, pos: 0.5538 },
{ db: -5, pos: 0.7026 },
{ db: 0, pos: 0.8513 },
{ db: +5, pos: 1.0000 },
];
const TP_PERCENT_SCALE = [
{ db: -60, frac: 0.0000 },
{ db: -50, frac: 0.0373 },
{ db: -40, frac: 0.1429 },
{ db: -35, frac: 0.2112 },
{ db: -30, frac: 0.2857 },
{ db: -25, frac: 0.3851 },
{ db: -20, frac: 0.4783 },
{ db: -15, frac: 0.6087 },
{ db: -10, frac: 0.7391 },
{ db: -5, frac: 0.8696 },
{ db: 0, frac: 1.0000 },
];
const TP_EXTRA_MINOR_TICKS = [-47, -19, -18, -17, -16, -14, -13, -12, -11, -9, -8, -7, -6, -4, -3, -2, -1];
const PPM_EBU_MAJOR_TICKS = [-12, -8, -4, 0, +4, +8, +12];
const PPM_EBU_MINOR_TICKS = [-10, -6, -2, +2, +6, +9, +10];
const PPM_DIN_MAJOR_TICKS = [-50, -40, -30, -20, -10, -5, 0, +5];
const PPM_DIN_MINOR_TICKS = [
{ db: -35 },
{ db: -25 },
{ db: -21, color: 'warn' },
{ db: -15 },
{ db: -9 },
{ db: -8 },
{ db: -7 },
{ db: -6 },
{ db: -4 },
{ db: -3 },
{ db: -2 },
{ db: -1 },
{ db: +1 },
{ db: +2 },
{ db: +3 },
{ db: +4 },
];
export function init() {
return {
staticLayers: new Map(),
smooth: { L: null, R: null },
goal: { L: null, R: null },
vel: { L: 0, R: 0 },
lastTs: (typeof performance !== 'undefined' ? performance.now() : Date.now()),
};
}
export function resize() {}
export function destroy(state) {
if (state) state.staticLayers = null;
}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, meters, audio } = env;
const meterId = resolveNeedleMeterId(env);
if (!meterId) {
drawCachedStaticLayer(state, g, 'classic-empty', 'empty', rect, (cg) => {
cg.fillStyle = PANEL_BG;
cg.fillRect(0, 0, rect.w, rect.h);
cg.fillStyle = '#bcd';
cg.textAlign = 'left';
cg.font = 'bold 16px ui-monospace, monospace';
cg.fillText('Classic Needles', 12, 44);
cg.fillStyle = '#9aa';
cg.font = '14px ui-monospace, monospace';
cg.fillText('Slot leer — wähle ein Meter im HUD.', 12, 66);
});
return;
}
const scale = getNeedleScaleDescriptor(meterId, CONFIG);
const meterState = meters?.getState?.(meterId) || null;
const raw = readMeterDisplayLR(meterId, meterState, CONFIG, scale, audio);
const targets = { L: scale.valueToNorm(raw.L), R: scale.valueToNorm(raw.R) };
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
smoothNeedle(state, targets, now);
// Box-Abmessungen an den Real-Time-Analyzer anlehnen (gleiche Offsets)
const BOX_LEFT = 0;
const BOX_TOP = Number.isFinite(env?.topInset) ? Number(env.topInset) : 70;
const BOX_RIGHT = 0;
const BOX_BOTTOM = 0;
const outerRect = {
x: rect.x + BOX_LEFT,
y: rect.y + BOX_TOP,
w: Math.max(140, rect.w - BOX_LEFT - BOX_RIGHT),
h: Math.max(140, rect.h - BOX_TOP - BOX_BOTTOM),
};
const INNER_INSET_X = Math.round(Math.min(140, outerRect.w * 0.08));
const innerRect = {
x: outerRect.x + INNER_INSET_X,
y: outerRect.y,
w: Math.max(140, outerRect.w - INNER_INSET_X * 2),
h: outerRect.h,
};
const innerGap = Math.max(56, innerRect.w * 0.12);
const availableW = Math.max(100, innerRect.w - innerGap);
const meterW = availableW / 2;
const meterH = outerRect.h;
const baseY = outerRect.y;
const decorMetrics = createDecorMetrics(meterW, innerGap);
const gapCenterX = innerRect.x + meterW + innerGap / 2;
const leftBoxClamp = decorMetrics.ownBounds
? {
x: outerRect.x,
y: outerRect.y,
w: Math.max(0, Math.floor(gapCenterX - outerRect.x)),
h: outerRect.h,
}
: outerRect;
const rightBoxClamp = decorMetrics.ownBounds
? {
x: Math.ceil(gapCenterX),
y: outerRect.y,
w: Math.max(0, (outerRect.x + outerRect.w) - Math.ceil(gapCenterX)),
h: outerRect.h,
}
: outerRect;
const metersRects = [
{ x: innerRect.x, y: baseY, w: meterW, h: meterH, label: 'L', norm: state.smooth.L, raw: raw.L, boxClamp: leftBoxClamp },
{ x: innerRect.x + meterW + innerGap, y: baseY, w: meterW, h: meterH, label: 'R', norm: state.smooth.R, raw: raw.R, boxClamp: rightBoxClamp },
];
drawCachedStaticLayer(
state,
g,
'classic-shell',
[
meterId,
rect.w,
rect.h,
outerRect.x,
outerRect.y,
outerRect.w,
outerRect.h,
innerRect.x,
innerRect.w,
innerGap,
scale.kind,
scale.bottom,
scale.top,
scale.redStart,
getFooterText(meterId, CONFIG),
JSON.stringify(scale.majorTicks || []),
JSON.stringify(scale.minorTicks || []),
].join('|'),
rect,
(cg) => {
cg.fillStyle = PANEL_BG;
cg.fillRect(0, 0, rect.w, rect.h);
cg.save();
cg.translate(-rect.x, -rect.y);
cg.strokeStyle = FRAME_STROKE;
cg.lineWidth = 2;
cg.strokeRect(outerRect.x, outerRect.y, outerRect.w, outerRect.h);
metersRects.forEach((m) => {
drawClassicMeter(cg, m, scale, decorMetrics);
drawMeterBox(cg, m, m.boxClamp || outerRect, decorMetrics);
drawRefLabel(cg, m, meterId, CONFIG, decorMetrics);
drawNeedleCap(cg, m);
});
cg.restore();
},
);
metersRects.forEach((m) => {
drawNeedle(g, m, m.norm, getNeedleColor(m.norm, scale));
});
}
function createDecorMetrics(meterW, innerGap) {
const widthT = clamp((meterW - 150) / 170, 0, 1);
const gapT = clamp((innerGap - 56) / 36, 0, 1);
const t = Math.min(widthT, gapT);
return {
ownBounds: meterW < 300 || innerGap < 72,
refFont: roundLerp(10, 12, t),
refOffsetY: roundLerp(7, 10, t),
boxOuterExtra: roundLerp(30, 48, t),
boxPadX: roundLerp(4, 10, t),
boxPadTop: roundLerp(6, 10, t),
boxPadBottom: roundLerp(10, 16, t),
channelFont: roundLerp(18, 26, t),
channelPad: roundLerp(5, 8, t),
majorFont: roundLerp(9, 13, t),
majorTickWarn: roundLerp(11, 16, t),
majorTickNormal: roundLerp(9, 14, t),
majorLabelOffset: roundLerp(14, 20, t),
unitFont: roundLerp(9, 12, t),
unitAngleOffset: lerp(0.10, 0.16, t),
unitOffset: roundLerp(24, 34, t),
pctFont: roundLerp(10, 16, t),
pctInset: roundLerp(30, 48, t),
pct100YOffset: roundLerp(5, 10, t),
pctSignFont: roundLerp(11, 18, t),
pctSignOffsetX: roundLerp(10, 18, t),
pctSignOffsetY: roundLerp(14, 28, t),
};
}
function drawRefLabel(g, rect, meterId, CONFIG, metrics) {
const modeText = getFooterText(meterId, CONFIG);
const refFont = metrics?.refFont ?? 12;
const refOffsetY = metrics?.refOffsetY ?? 10;
g.save();
g.fillStyle = 'rgba(207,233,255,0.7)';
g.font = `${refFont}px ui-monospace, monospace`;
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
g.fillText(modeText, rect.x + rect.w / 2, rect.y + rect.h - refOffsetY);
g.restore();
}
function drawMeterBox(g, rect, clampRect, metrics) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
// Include labels (+20) and "- dB" (+34) plus some safety margin
const outerR = radius + (metrics?.boxOuterExtra ?? 48);
const xSpan = Math.max(Math.abs(Math.cos(ANGLE_START)), Math.abs(Math.cos(ANGLE_END))) * outerR;
const padX = metrics?.boxPadX ?? 10;
const padTop = metrics?.boxPadTop ?? 10;
const padBottom = metrics?.boxPadBottom ?? 16;
let x = cx - xSpan - padX;
let y = cy - outerR - padTop;
let w = (xSpan + padX) * 2;
let h = (cy + NEEDLE_CAP_RADIUS + padBottom) - y;
const clampSource = clampRect || rect;
// Clamp to the available panel area. In compact layouts keep each box inside its own half.
const minX = (clampSource?.x ?? rect.x) + 2;
const minY = (clampSource?.y ?? rect.y) + 2;
const maxX = (clampSource?.x ?? rect.x) + (clampSource?.w ?? rect.w) - 2;
const maxY = (clampSource?.y ?? rect.y) + (clampSource?.h ?? rect.h) - 2;
if (x < minX) { w -= (minX - x); x = minX; }
if (y < minY) { h -= (minY - y); y = minY; }
if (x + w > maxX) w = maxX - x;
if (y + h > maxY) h = maxY - y;
if (w <= 2 || h <= 2) return;
g.save();
g.strokeStyle = FIELD_DIVIDER_STROKE;
g.lineWidth = 1;
g.setLineDash(FIELD_DIVIDER_DASH);
g.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, Math.round(w) - 1, Math.round(h) - 1);
g.setLineDash([]);
const chan = rect?.label;
if (chan === 'L' || chan === 'R') {
g.fillStyle = 'rgba(207,233,255,0.7)';
g.font = `bold ${metrics?.channelFont ?? 26}px ui-monospace, monospace`;
g.textBaseline = 'top';
const pad = metrics?.channelPad ?? 8;
if (chan === 'L') {
g.textAlign = 'left';
g.fillText('L', x + pad, y + pad - 2);
} else {
g.textAlign = 'right';
g.fillText('R', x + w - pad, y + pad - 2);
}
}
g.restore();
}
function getNeedleColor(norm, scale) {
const warn = scale?.warnColor || '#ff3b3b';
const normal = scale?.normalColor || '#ffe066';
if (Number.isFinite(scale?.redStart) && typeof scale?.valueToNorm === 'function') {
const redNorm = scale.valueToNorm(scale.redStart);
if (Number.isFinite(redNorm) && Number.isFinite(norm) && norm >= redNorm) return warn;
}
return normal;
}
function resolveNeedleMeterId(env) {
const slotList = env?.slots?.(id);
const selected = Array.isArray(slotList) ? slotList[0] : null;
if (selected === 'none') return null;
return selected || 'vu';
}
function getFooterText(meterId, CONFIG) {
if (meterId === 'vu') {
return Number.isFinite(CONFIG?.VU_DBFS_REF) ? `Ref ${CONFIG.VU_DBFS_REF.toFixed(1)} dBFS` : 'VU';
}
if (meterId === 'ppm-ebu') return 'PPM (EBU)';
if (meterId === 'ppm-din') return 'PPM (DIN)';
if (meterId === 'tp') return 'dBTP';
if (meterId === 'rms') {
const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu');
return isDBU ? 'RMS (dBu)' : 'RMS (dBFS RMS)';
}
if (meterId === 'lufs') return 'LUFS (M)';
return String(meterId || 'meter');
}
function getNeedleScaleDescriptor(meterId, CONFIG) {
if (meterId === 'ppm-ebu') {
const bottom = Number.isFinite(CONFIG?.PPM_EBU_BOTTOM) ? CONFIG.PPM_EBU_BOTTOM : -14;
const top = Number.isFinite(CONFIG?.PPM_EBU_TOP) ? CONFIG.PPM_EBU_TOP : +14;
const redStart = Number.isFinite(CONFIG?.PPM_EBU_RED_START) ? CONFIG.PPM_EBU_RED_START : +9;
const warnColor = CONFIG?.PPM_EBU_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.PPM_EBU_COLOR_NORMAL || '#ffe066';
const valueToNorm = (v) => {
const clamped = clamp(v, bottom, top);
return clamp01((clamped - bottom) / (top - bottom || 1));
};
return {
id: meterId,
kind: 'ppm-ebu',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'dB',
majorTicks: PPM_EBU_MAJOR_TICKS,
minorTicks: PPM_EBU_MINOR_TICKS,
formatMajor: (db) => (db === 0 ? 'TEST' : (db > 0 ? `+${db}` : `${db}`)),
valueToNorm,
};
}
if (meterId === 'ppm-din') {
const bottom = Number.isFinite(CONFIG?.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
const top = Number.isFinite(CONFIG?.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5;
const redStart = Number.isFinite(CONFIG?.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0;
const warnColor = CONFIG?.PPM_DIN_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.PPM_DIN_COLOR_NORMAL || '#ffe066';
const mapRaw = (db) => {
const clamped = clamp(db, bottom, top);
let prev = DIN_SCALE[0];
for (let i = 1; i < DIN_SCALE.length; i++) {
const curr = DIN_SCALE[i];
if (clamped <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (clamped - prev.db) / span;
return prev.pos + t * (curr.pos - prev.pos);
}
prev = curr;
}
return DIN_SCALE[DIN_SCALE.length - 1].pos;
};
const normBottom = mapRaw(bottom);
const normSpan = Math.max(1e-6, 1 - normBottom);
const valueToNorm = (v) => {
const n = mapRaw(v);
return clamp01((n - normBottom) / normSpan);
};
const showAl = CONFIG?.AL_MARKERS_ENABLED !== false;
const dynWarnDb = showAl ? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9) : null;
const minors = PPM_DIN_MINOR_TICKS.slice().map((t) => ({
value: t.db,
color: (t.color === 'warn' || (dynWarnDb !== null && t.db === dynWarnDb)) ? 'warn' : null,
}));
return {
id: meterId,
kind: 'ppm-din',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'dB',
majorTicks: PPM_DIN_MAJOR_TICKS,
minorTicks: minors,
formatMajor: (db) => (db > 0 ? `+${db}` : `${db}`),
valueToNorm,
};
}
if (meterId === 'tp') {
const bottom = -60;
const top = 0;
const redStart = Number.isFinite(CONFIG?.TP_RED_START) ? CONFIG.TP_RED_START : -1;
const warnColor = CONFIG?.TP_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.TP_COLOR_NORMAL || '#ffe066';
const mapRaw = (db) => {
const clamped = clamp(db, bottom, top);
if (clamped <= TP_PERCENT_SCALE[0].db) return TP_PERCENT_SCALE[0].frac;
if (clamped >= TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].db) return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
for (let i = 1; i < TP_PERCENT_SCALE.length; i++) {
const prev = TP_PERCENT_SCALE[i - 1];
const curr = TP_PERCENT_SCALE[i];
if (clamped <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (clamped - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return TP_PERCENT_SCALE[TP_PERCENT_SCALE.length - 1].frac;
};
const valueToNorm = (v) => clamp01(mapRaw(v));
return {
id: meterId,
kind: 'tp',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'dBTP',
majorTicks: TP_PERCENT_SCALE.map((p) => p.db),
minorTicks: TP_EXTRA_MINOR_TICKS,
formatMajor: (db) => {
const v = Math.abs(db);
return Number.isInteger(v) ? String(v) : v.toFixed(1);
},
valueToNorm,
};
}
if (meterId === 'rms') {
const isDBU = (String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase() === 'dbu');
const bottom = -60;
const top = isDBU ? +24 : 0;
const redStart = Number.isFinite(CONFIG?.RMS_RED_START) ? CONFIG.RMS_RED_START : (isDBU ? +20 : 0);
const warnColor = CONFIG?.RMS_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.RMS_COLOR_NORMAL || '#34d399';
const valueToNorm = (db) => {
const v = clamp(db, bottom, top);
if (!isDBU) return clamp01((v - bottom) / (top - bottom || 1));
if (v <= -20) {
const t = (v + 60) / 40;
return clamp01(Math.pow(t, 1.6) * 0.45);
}
if (v <= 0) {
const t = (v + 20) / 20;
return clamp01(0.45 + t * 0.25);
}
if (v <= 20) {
const t = v / 20;
return clamp01(0.70 + t * 0.25);
}
if (v <= 24) {
const t = (v - 20) / 4;
return clamp01(0.95 + t * 0.05);
}
return 1;
};
const majors = isDBU
? [24, 20, 10, 0, -10, -20, -30, -40, -50, -60]
: [0, -6, -12, -18, -24, -30, -40, -60];
const minors = isDBU ? [] : [-3, -9, -15, -21, -27, -33, -36, -42, -48, -54];
return {
id: meterId,
kind: 'rms',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: isDBU ? 'dBu' : 'dBFS',
majorTicks: majors,
minorTicks: minors,
formatMajor: (v) => (v > 0 ? `+${v}` : `${v}`),
valueToNorm,
rmsMode: isDBU ? 'dbu' : 'dbfs',
};
}
if (meterId === 'lufs') {
const bottom = -50;
const top = -5;
const redStart = Number.isFinite(CONFIG?.LUFS_RED_START)
? CONFIG.LUFS_RED_START
: (Number.isFinite(CONFIG?.LUFS_YELLOW_START) ? CONFIG.LUFS_YELLOW_START : -2);
const warnColor = CONFIG?.LUFS_COLOR_RED || '#ff3b3b';
const normalColor = CONFIG?.LUFS_COLOR_YELLOW || '#ffe066';
const valueToNorm = (v) => {
const clamped = clamp(v, bottom, top);
return clamp01((clamped - bottom) / (top - bottom || 1));
};
return {
id: meterId,
kind: 'lufs',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: 'LUFS',
majorTicks: [-50, -40, -30, -23, -18, -10, -5],
minorTicks: [-45, -35, -25, -20, -15],
formatMajor: (v) => `${v}`,
valueToNorm,
};
}
// Default: VU (Classic-Needles Look)
{
const bottom = -20;
const top = +3;
const redStart = Number.isFinite(CONFIG?.VU_RED_START) ? CONFIG.VU_RED_START : 0;
const warnColor = CONFIG?.VU_COLOR_WARN || '#ff3b3b';
const normalColor = CONFIG?.VU_COLOR_NORMAL || '#ffe066';
const mapRaw = (dB) => {
const db = clamp(dB, bottom, top);
const table = VU_DEFLECTION;
if (db <= table[0].db) return table[0].frac;
if (db >= table[table.length - 1].db) return table[table.length - 1].frac;
for (let i = 1; i < table.length; i++) {
const prev = table[i - 1];
const curr = table[i];
if (db <= curr.db) {
const span = curr.db - prev.db || 1;
const t = (db - prev.db) / span;
return prev.frac + t * (curr.frac - prev.frac);
}
}
return table[table.length - 1].frac;
};
const valueToNorm = (v) => clamp01(mapRaw(v));
return {
id: 'vu',
kind: 'vu',
bottom,
top,
redStart,
warnColor,
normalColor,
unitLabel: '- dB',
majorTicks: [-20, -10, -7, -5, -3, -1, 0, 1, 2, 3],
minorTicks: [-15, -12, -9, -8, -6, -4, -2],
formatMajor: (db) => {
if (db < 0) return String(Math.abs(db));
if (db === 3) return '*';
return String(db);
},
valueToNorm,
vuPercentMarks: true,
};
}
}
function readMeterDisplayLR(meterId, shared, CONFIG, scale, audio) {
const fallback = () => scale?.bottom ?? -60;
if (meterId === 'lufs') {
const v = Number.isFinite(shared?.momentary) ? shared.momentary : fallback();
return { L: v, R: v };
}
const hasLR = Number.isFinite(shared?.values?.L) || Number.isFinite(shared?.values?.R);
if (!shared || !hasLR) return { L: fallback(), R: fallback() };
let rawL = Number.isFinite(shared.values.L) ? shared.values.L : fallback();
let rawR = Number.isFinite(shared.values.R) ? shared.values.R : fallback();
if (meterId === 'vu') {
const effOff = Number.isFinite(CONFIG?.VU_OFFSET_DB) ? CONFIG.VU_OFFSET_DB : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'tp') {
const effOff = Number.isFinite(CONFIG?.TP_OFFSET_DB) ? CONFIG.TP_OFFSET_DB : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'ppm-ebu') {
const effOff = Number.isFinite(CONFIG?.PPM_EBU_OFFSET) ? CONFIG.PPM_EBU_OFFSET : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'ppm-din') {
const baseMode = (CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
const effOff = baseMode + (Number(CONFIG?.PPM_DIN_TRIM_DB) || 0);
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
} else if (meterId === 'rms') {
const effOff = Number.isFinite(CONFIG?.RMS_OFFSET_DB) ? CONFIG.RMS_OFFSET_DB : 0;
const offCorr = effOff - (shared.offset || 0);
rawL += offCorr;
rawR += offCorr;
const mode = String(CONFIG?.RMS_MODE ?? 'dbfs').toLowerCase();
const isDBU = (mode === 'dbu');
if (isDBU) {
const refDbfs = Number.isFinite(CONFIG?.RMS_REF_DBFS_FOR_REF_DBU) ? CONFIG.RMS_REF_DBFS_FOR_REF_DBU : -18;
const refDbu = Number.isFinite(CONFIG?.RMS_REF_DBU) ? CONFIG.RMS_REF_DBU : +4;
rawL = (rawL - refDbfs) + refDbu;
rawR = (rawR - refDbfs) + refDbu;
}
}
return { L: rawL, R: rawR };
}
function smoothNeedle(state, target, now) {
const prevTs = state.lastTs || now;
const dtFull = Math.max(1e-3, Math.min(0.20, (now - prevTs) / 1000));
const maxStep = 1 / 120;
const steps = Math.max(1, Math.ceil(dtFull / maxStep));
const dt = dtFull / steps;
if (!state.vel) state.vel = { L: 0, R: 0 };
if (!state.goal) state.goal = { L: null, R: null };
const step = (ch) => {
const goal = target[ch];
if (!Number.isFinite(goal)) return;
if (!Number.isFinite(state.smooth[ch])) {
state.smooth[ch] = goal;
state.vel[ch] = 0;
state.goal[ch] = goal;
return;
}
let x = state.smooth[ch];
let v = Number.isFinite(state.vel[ch]) ? state.vel[ch] : 0;
let g = Number.isFinite(state.goal[ch]) ? state.goal[ch] : goal;
for (let i = 0; i < steps; i++) {
const alphaGoal = 1 - Math.exp(-dt / NEEDLE_GOAL_TAU_S);
g = g + alphaGoal * (goal - g);
const rising = g > x;
const hz = rising ? NEEDLE_SPRING_HZ_UP : NEEDLE_SPRING_HZ_DOWN;
const omega = 2 * Math.PI * hz;
const zeta = NEEDLE_SPRING_DAMPING;
// Gedämpfter Feder-Masse-Dämpfer (semi-implicit Euler): x'' + 2ζω x' + ω²(x-goal)=0
const a = (omega * omega) * (g - x) - (2 * zeta * omega) * v;
v = v + a * dt;
x = x + v * dt;
}
state.goal[ch] = g;
state.vel[ch] = v;
state.smooth[ch] = x;
};
step('L');
step('R');
state.lastTs = now;
}
function drawClassicMeter(g, rect, scale, metrics) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
drawScale(g, cx, cy, radius, scale, metrics);
}
function drawScale(g, cx, cy, radius, scale, metrics) {
const mapValueToAngle = (v) => lerp(ANGLE_START, ANGLE_END, scale.valueToNorm(v));
const redStart = scale.redStart;
const warnCol = scale.warnColor || '#ff3b3b';
const majors = Array.isArray(scale.majorTicks) ? scale.majorTicks : [];
const minors = Array.isArray(scale.minorTicks) ? scale.minorTicks : [];
// Skalenbogen
g.save();
g.lineWidth = 3;
g.strokeStyle = SCALE_ARC_STROKE;
g.beginPath();
g.arc(cx, cy, radius, ANGLE_START, ANGLE_END);
g.stroke();
// Major ticks + Beschriftung
g.font = `bold ${metrics?.majorFont ?? 13}px ui-monospace, monospace`;
g.textAlign = 'center';
g.textBaseline = 'middle';
for (const v of majors) {
const ang = mapValueToAngle(v);
const inRed = Number.isFinite(redStart) && v >= redStart;
const tickCol = inRed ? warnCol : '#cfe9ff';
g.lineWidth = inRed ? 2.5 : 2;
g.strokeStyle = tickCol;
g.fillStyle = tickCol;
drawTick(g, cx, cy, radius, ang, inRed ? (metrics?.majorTickWarn ?? 16) : (metrics?.majorTickNormal ?? 14));
const labelOffset = metrics?.majorLabelOffset ?? 20;
const tx = cx + Math.cos(ang) * (radius + labelOffset);
const ty = cy + Math.sin(ang) * (radius + labelOffset);
const label = scale.formatMajor ? scale.formatMajor(v) : String(v);
g.fillText(label, tx, ty);
}
// Minor dots
const dotRad = Math.max(0, radius - 16);
const dotR = 2.2;
for (const tick of minors) {
const v = (typeof tick === 'number') ? tick : tick?.value;
if (!Number.isFinite(v)) continue;
const isWarn = (typeof tick === 'object' && tick?.color === 'warn');
g.fillStyle = isWarn ? warnCol : 'rgba(207,233,255,0.65)';
const ang = mapValueToAngle(v);
const x = cx + Math.cos(ang) * dotRad;
const y = cy + Math.sin(ang) * dotRad;
g.beginPath();
g.arc(x, y, dotR, 0, Math.PI * 2);
g.fill();
}
// Roter Bereich
if (Number.isFinite(redStart)) {
const redAng = mapValueToAngle(redStart);
g.strokeStyle = warnCol;
g.lineWidth = 4;
g.beginPath();
g.arc(cx, cy, radius, redAng, ANGLE_END);
g.stroke();
}
// Unit label oben rechts
if (scale.unitLabel) {
g.fillStyle = '#cfe9ff';
g.font = `bold ${metrics?.unitFont ?? 12}px ui-monospace, monospace`;
g.textBaseline = 'alphabetic';
const dbLabelAng = ANGLE_END - (metrics?.unitAngleOffset ?? 0.16);
const unitOffset = metrics?.unitOffset ?? 34;
g.fillText(scale.unitLabel, cx + Math.cos(dbLabelAng) * (radius + unitOffset), cy + Math.sin(dbLabelAng) * (radius + unitOffset));
}
// VU-Percent-Scale (wie Foto)
if (scale.vuPercentMarks) {
g.font = `bold ${metrics?.pctFont ?? 16}px ui-monospace, monospace`;
g.fillStyle = 'rgba(207,233,255,0.8)';
g.textBaseline = 'middle';
const pctR = Math.max(0, radius - (metrics?.pctInset ?? 48));
const posAtValue = (v) => {
const a = mapValueToAngle(v);
return { x: cx + Math.cos(a) * pctR, y: cy + Math.sin(a) * pctR };
};
const p10 = posAtValue(-20); // 10% ≙ -20 dB
const p50 = posAtValue(-6); // 50% ≙ -6 dB
const p100 = posAtValue(0); // 100% ≙ 0 dB
g.fillText('10', p10.x, p10.y);
g.fillText('50', p50.x, p50.y);
g.fillText('100', p100.x, p100.y + (metrics?.pct100YOffset ?? 10));
g.font = `bold ${metrics?.pctSignFont ?? 18}px ui-monospace, monospace`;
g.fillText('%', p100.x + (metrics?.pctSignOffsetX ?? 18), p100.y + (metrics?.pctSignOffsetY ?? 28));
}
// Glaslinie
g.strokeStyle = 'rgba(255,255,255,0.07)';
g.lineWidth = 8;
g.beginPath();
g.arc(cx, cy, radius - 12, ANGLE_START + 0.04, ANGLE_END - 0.04);
g.stroke();
g.restore();
}
function drawNeedle(g, rect, norm, color) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const radius = Math.min(rect.w, rect.h * 1.3) * METER_RADIUS_FRAC;
const needleLen = radius * 0.9;
if (!Number.isFinite(norm)) return;
const t = clamp01(norm);
const ang = lerp(ANGLE_START, ANGLE_END, t);
const tipX = cx + Math.cos(ang) * needleLen;
const tipY = cy + Math.sin(ang) * needleLen;
const baseX = cx + Math.cos(ang + Math.PI) * (needleLen * 0.08);
const baseY = cy + Math.sin(ang + Math.PI) * (needleLen * 0.08);
g.save();
g.strokeStyle = color;
g.lineWidth = 3;
g.beginPath();
g.moveTo(baseX, baseY);
g.lineTo(tipX, tipY);
g.stroke();
g.restore();
}
function drawNeedleCap(g, rect) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h * METER_PIVOT_Y_FRAC;
const capRadius = NEEDLE_CAP_RADIUS;
g.save();
const grad = g.createRadialGradient(cx, cy, 2, cx, cy, capRadius * 1.5);
grad.addColorStop(0, '#0b111c');
grad.addColorStop(1, 'rgba(0,231,255,0.35)');
g.fillStyle = grad;
g.strokeStyle = '#00e7ff';
g.lineWidth = 2;
g.beginPath();
g.arc(cx, cy, capRadius, 0, Math.PI * 2);
g.fill();
g.stroke();
g.restore();
}
function drawTick(g, cx, cy, radius, ang, len) {
const x1 = cx + Math.cos(ang) * (radius - len);
const y1 = cy + Math.sin(ang) * (radius - len);
const x2 = cx + Math.cos(ang) * (radius + 2);
const y2 = cy + Math.sin(ang) * (radius + 2);
g.beginPath();
g.moveTo(x1, y1);
g.lineTo(x2, y2);
g.stroke();
}
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v));
}
function clamp01(v) {
return Math.min(1, Math.max(0, v));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function roundLerp(a, b, t) {
return Math.round(lerp(a, b, t));
}
function degToRad(d) {
return (d * Math.PI) / 180;
}
+449
View File
@@ -0,0 +1,449 @@
// views/clock.js — Vollbild-Studiosclock mit "Zurück"-Button
const DOT_FONT = {
'0': ['01110','10001','10011','10101','11001','10001','01110'],
'1': ['00100','01100','00100','00100','00100','00100','01110'],
'2': ['01110','10001','00001','00110','01000','10000','11111'],
'3': ['11110','00001','00001','01110','00001','00001','11110'],
'4': ['00010','00110','01010','10010','11111','00010','00010'],
'5': ['11111','10000','11110','00001','00001','10001','01110'],
'6': ['00110','01000','10000','11110','10001','10001','01110'],
'7': ['11111','00001','00010','00100','01000','01000','01000'],
'8': ['01110','10001','10001','01110','10001','10001','01110'],
'9': ['01110','10001','10001','01111','00001','00010','01100'],
':': ['0','1','0','0','1','0','0'],
'.': ['000','000','000','000','000','000','010'],
};
const DOT_COLS = DOT_FONT['0'][0].length;
const DIN_SCALE = [
{ db: -50, pos: 0.0205 },
{ db: -40, pos: 0.0564 },
{ db: -35, pos: 0.0974 },
{ db: -30, pos: 0.1538 },
{ db: -25, pos: 0.2308 },
{ db: -20, pos: 0.3179 },
{ db: -15, pos: 0.4359 },
{ db: -10, pos: 0.5538 },
{ db: -5, pos: 0.7026 },
{ db: 0, pos: 0.8513 },
{ db: +5, pos: 1.0000 },
];
export const id = 'clock';
export function init(env) {
const btn = document.createElement('button');
btn.textContent = 'Vollbild';
btn.className = 'btn btn-compact';
Object.assign(btn.style, {
position: 'fixed',
top: '10px',
right: '10px',
zIndex: 12000,
padding: '4px 8px',
cursor: 'pointer',
});
btn.onclick = () => {
toggleFullscreen(env, btn);
};
document.body.appendChild(btn);
return { btn, fullscreen: false };
}
export function destroy(state) {
if (state?.btn && state.btn.parentNode) state.btn.parentNode.removeChild(state.btn);
}
export function resize() {}
export async function render(env, state) {
const { ctx: g, rect } = env;
const styleCfg = env.config?.CLOCK_STYLE;
if (styleCfg === 'digital') {
drawClockDigital(g, rect, env, { showPpm: false });
} else if (styleCfg === 'digital-ppm') {
drawClockDigital(g, rect, env, { showPpm: true });
} else if (styleCfg === 'analog-ppm') {
drawClockAnalog(g, rect, env, { showPpm: true });
} else {
drawClockAnalog(g, rect, env, { showPpm: false });
}
}
function toggleFullscreen(env, btn) {
const hud = document.querySelector('.hud');
const options = document.getElementById('optionsPanelNew');
const isFs = btn.dataset.fs === '1';
if (isFs) {
if (hud) hud.style.display = '';
if (options) options.style.display = (env.style === 'options-panel') ? 'block' : 'none';
btn.textContent = 'Vollbild';
btn.dataset.fs = '0';
} else {
if (hud) hud.style.display = 'none';
if (options) options.style.display = 'none';
btn.textContent = '↩';
btn.dataset.fs = '1';
}
}
function drawClockAnalog(ctx, rect, env, opts = {}) {
const showPpm = !!opts.showPpm;
const d = new Date();
const hours = d.getHours();
const mins = d.getMinutes();
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
const radius = Math.min(rect.w, rect.h) * 0.48;
const baseDot = Math.max(1.5, Math.round(radius * 0.03));
const ringDot = baseDot * 0.7;
const glyphDot = baseDot * 1.3;
const glow = env.config?.CLOCK_LED_GLOW !== false;
const color = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
ctx.save();
ctx.fillStyle = 'rgba(8,8,12,1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.translate(centerX, centerY);
for (let i = 0; i < 60; i++) {
const ang = (Math.PI * 2 * i) / 60 - Math.PI / 2;
const r = radius * 0.88;
const filled = i <= secs;
const cx = Math.cos(ang) * r;
const cy = Math.sin(ang) * r;
if (filled) {
drawLed(ctx, cx, cy, ringDot, glow, color);
} else {
ctx.fillStyle = 'rgba(120,120,120,0.25)';
ctx.beginPath();
ctx.arc(cx, cy, ringDot, 0, Math.PI * 2);
ctx.fill();
}
if (i % 5 === 0) {
const outerR = r + ringDot * 3.2;
drawLed(ctx, Math.cos(ang) * outerR, Math.sin(ang) * outerR, ringDot, glow, color);
}
}
const timeStr = `${pad2(hours)}:${pad2(mins)}`;
const glyphSize = radius * 0.32;
const timeSpacing = 0.9; // wie Screensaver
const timeY = -(glyphSize * 1.2) / 2; // vertikal zentriert
const blinkOn = isSecondPulseActive(d);
const extraGaps = [0, 3]; // wie Screensaver (Luft zwischen Blöcken)
const metrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
const timeX = -(metrics.colonCenter ?? metrics.width / 2); // Doppelpunkt auf Mitte
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot * 1.1, timeSpacing, blinkOn ? (glyphDot * 1.1) : 0, extraGaps, glow, color);
if (showPpm) {
const ppmWidth = radius * 1.2;
const ppmY = glyphSize * 1.0; // etwas mehr Abstand zur Uhrzeit
const ppmLevels = {
L: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
R: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
};
drawMiniPpm(ctx, { cx: 0, cy: ppmY }, ppmWidth, glyphDot, env, ppmLevels);
}
ctx.restore();
}
function drawClockDigital(ctx, rect, env, opts = {}) {
const showPpm = !!opts.showPpm;
const d = new Date();
const hours = d.getHours();
const mins = d.getMinutes();
const secs = d.getSeconds() + d.getMilliseconds() / 1000;
const day = d.getDate();
const month = d.getMonth() + 1;
const year = d.getFullYear();
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
const radius = Math.min(rect.w, rect.h) * 0.48;
const baseDot = Math.max(1.5, Math.round(radius * 0.028));
const glyphDot = baseDot * 1.3;
const glyphSize = radius * 0.32;
const timeSpacing = 1.2;
const blinkOn = isSecondPulseActive(d);
const extraGaps = [0, 3, 6]; // nach HH, MM, SS
const color = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
const glow = env.config?.CLOCK_LED_GLOW !== false;
ctx.save();
ctx.fillStyle = 'rgba(8,8,12,1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.translate(centerX, centerY);
const timeStr = `${pad2(hours)}:${pad2(mins)}:${pad2(Math.floor(secs))}`;
const dateStr = `${pad2(day)}.${pad2(month)}.${year}`;
const dateSize = glyphSize * 0.7;
const dateSpacing = 1.1;
const dateGaps = [1, 4];
const timeMetrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
const dateMetrics = measureTextLayout(dateStr, dateSize, dateSpacing, dateGaps);
const lineGap = glyphSize * 0.6;
const totalH = glyphSize * 1.2 + dateSize * 1.2 + lineGap;
const startY = -totalH / 2;
const timeX = -timeMetrics.width / 2;
const timeY = startY;
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot, timeSpacing, blinkOn ? glyphDot : 0, extraGaps, glow, color);
const dateX = -dateMetrics.width / 2;
const dateY = startY + glyphSize * 1.2 + lineGap;
drawDotText(ctx, dateStr, dateX, dateY, dateSize, glyphDot * 0.9, dateSpacing, null, dateGaps, glow, color, dateSize * 0.8);
if (showPpm) {
const ppmLevels = {
L: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
R: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
};
const ppmWidth = dateMetrics.width;
const dateBottom = dateY + dateSize * 1.2;
const bottom = rect.h / 2;
const ppmY = dateBottom + (bottom - dateBottom) * 0.5;
drawMiniPpm(ctx, { cx: 0, cy: ppmY }, ppmWidth, glyphDot, env, ppmLevels);
}
ctx.restore();
}
function drawMiniPpm(ctx, pos, width, dotSize, env, levels) {
const normalCol = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
const warnCol = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
const bgCol = 'rgba(255,255,255,0.08)';
// Feste Farbe wie die %‑Beschriftung im PPM-DIN-Meter, etwas dunkler
const tickCol = '#ff9922';
const minDb = DIN_SCALE[0].db;
const maxDb = DIN_SCALE[DIN_SCALE.length - 1].db;
const lvl = [
Number.isFinite(levels?.L) ? levels.L : mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
Number.isFinite(levels?.R) ? levels.R : mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
];
const h = Math.max(4, dotSize * 1.4);
const gap = h * 0.6;
const halfW = width / 2;
const ticks = [-50, -40, -30, -20, -10, -5, 0, +5];
const norm = (db) => {
const clamped = Math.min(maxDb, Math.max(minDb, db));
for (let i = 0; i < DIN_SCALE.length - 1; i++) {
const a = DIN_SCALE[i];
const b = DIN_SCALE[i + 1];
if (clamped >= a.db && clamped <= b.db) {
const t = (clamped - a.db) / (b.db - a.db || 1);
return a.pos + t * (b.pos - a.pos);
}
}
return clamped >= maxDb ? DIN_SCALE[DIN_SCALE.length - 1].pos : DIN_SCALE[0].pos;
};
ctx.save();
ctx.translate(pos.cx, pos.cy);
for (let ch = 0; ch < 2; ch++) {
const y = (ch === 0 ? - (h + gap) * 0.5 : (h + gap) * 0.5);
ctx.fillStyle = bgCol;
ctx.fillRect(-halfW, y - h / 2, width, h);
const val = norm(lvl[ch]);
const fillW = width * val;
const over = lvl[ch] > 0;
const colNorm = normalCol;
const colWarn = normalizeColor('#ff5050');
const norm0 = norm(0);
const warnStartX = -halfW + norm0 * width;
const ledMode = env.config?.CLOCK_PPM_LED_MODE === true;
const drawTicksAndLabels = () => {
ctx.fillStyle = tickCol;
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
ticks.forEach((t) => {
const x = -halfW + norm(t) * width;
const tickLen = (ch === 1) ? h * 1.8 : h * 1.2; // unterer Balken: längere Ticks
ctx.fillRect(x - 0.5, y - h * 0.6, 1, tickLen);
if (ch === 1) {
ctx.textAlign = (t === -50 ? 'right' : 'left');
ctx.textBaseline = 'bottom';
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
ctx.fillText(String(t), labelX, y + tickLen + h * 0.15);
}
});
};
if (ledMode) {
// Nur untere Label (ch==1) zeichnen, keine Tick-Striche über den LEDs
if (ch === 1) {
ticks.forEach((t) => {
const x = -halfW + norm(t) * width;
const tickLen = h * 1.8;
const labelY = y + tickLen + h * 0.15;
// Verbindungsstrich nur unterhalb des Meters (bis zum Meter-Anfang)
const lineTop = y + h * 0.5;
ctx.fillStyle = tickCol;
ctx.fillRect(x - 0.5, lineTop, 1, Math.max(0, labelY - lineTop));
ctx.fillStyle = tickCol;
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
ctx.textAlign = (t === -50 ? 'right' : 'left');
ctx.textBaseline = 'bottom';
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
ctx.fillText(String(t), labelX, labelY);
});
}
const step = dotSize * 1.6;
const dotR = dotSize * 0.55;
for (let x = -halfW; x <= -halfW + fillW; x += step) {
const isWarn = x >= warnStartX;
const col = isWarn ? colWarn : colNorm;
drawLed(ctx, x, y, dotR, env.config?.CLOCK_LED_GLOW !== false, col);
}
} else {
// Norm-Anteil
const normW = over ? Math.max(0, warnStartX - (-halfW)) : fillW;
if (normW > 0) {
const glowH = h * 1.1;
ctx.fillStyle = colorWithAlpha(colNorm, 0.12);
ctx.fillRect(-halfW, y - glowH / 2, normW, glowH);
ctx.fillStyle = colorWithAlpha(colNorm, 0.08);
ctx.fillRect(-halfW, y - glowH, normW, glowH * 2);
ctx.fillStyle = colNorm;
ctx.fillRect(-halfW, y - h / 2, normW, h);
}
// Warn-Anteil
if (over) {
const warnW = Math.max(0, fillW - (warnStartX - (-halfW)));
if (warnW > 0) {
const glowH = h * 1.1;
ctx.fillStyle = colorWithAlpha(colWarn, 0.15);
ctx.fillRect(warnStartX, y - glowH / 2, warnW, glowH);
ctx.fillStyle = colorWithAlpha(colWarn, 0.1);
ctx.fillRect(warnStartX, y - glowH, warnW, glowH * 2);
ctx.fillStyle = colWarn;
ctx.fillRect(warnStartX, y - h / 2, warnW, h);
}
}
// Ticks/Labels obenauf im Balken-Modus (Farben wie PPM-DIN Prozent-Marks)
ctx.save();
const tickColOver = colorWithAlpha(tickCol, ledMode ? 1.0 : 0.6);
ctx.fillStyle = tickColOver;
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
ticks.forEach((t) => {
const x = -halfW + norm(t) * width;
const tickLen = (ch === 1) ? h * 1.8 : h * 1.2; // unterer Balken: längere Ticks
ctx.fillRect(x - 0.5, y - h * 0.6, 1, tickLen);
if (ch === 1) {
ctx.textAlign = (t === -50 ? 'right' : 'left');
ctx.textBaseline = 'bottom';
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
ctx.fillText(String(t), labelX, y + tickLen + h * 0.15);
}
});
ctx.restore();
}
}
ctx.restore();
}
function mapPpmRawToDin(raw, cfg) {
const minDb = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : DIN_SCALE[0].db;
const maxDb = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : DIN_SCALE[DIN_SCALE.length - 1].db;
if (!Number.isFinite(raw)) return minDb;
const base = Number.isFinite(cfg?.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? cfg.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15;
const effOff = (cfg?.PPM_DIN_MODE === 'al_minus6' ? -6 : -9) + (Number(cfg?.PPM_DIN_TRIM_DB) || 0);
const mapped = (raw - base) + effOff;
return Math.max(minDb, Math.min(maxDb, mapped));
}
function drawDotText(ctx, text, x, y, glyphSize, dotRadius, spacingFactor = 0.9, colonDotRadiusOverride = null, extraGapIndices = [], glow = true, color = 'rgb(255,0,0)', glyphHeightOverride = null) {
let cursorX = x;
const glyphW = glyphSize;
const glyphH = (glyphHeightOverride || glyphSize) * 1.2;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const useDot = (ch === ':' && colonDotRadiusOverride !== null) ? colonDotRadiusOverride : dotRadius;
drawDotChar(ctx, ch, cursorX, y, glyphW, glyphH, useDot, glow, color);
cursorX += glyphW * spacingFactor;
if (extraGapIndices.includes(i)) cursorX += glyphW / DOT_COLS;
}
}
function measureTextLayout(text, glyphSize, spacingFactor, extraGapIndices = []) {
const glyphW = glyphSize;
let cursor = 0;
let colonCenter = null;
for (let i = 0; i < text.length; i++) {
if (text[i] === ':') colonCenter = cursor + glyphW * 0.5;
cursor += glyphW * spacingFactor;
if (extraGapIndices.includes(i)) cursor += glyphW / DOT_COLS;
}
if (colonCenter === null) colonCenter = cursor / 2;
return { width: cursor, colonCenter };
}
function drawDotChar(ctx, ch, x, y, w, h, dotRadius, glow = true, color = 'rgb(255,0,0)') {
if (!dotRadius) return;
const rows = DOT_FONT[ch] || DOT_FONT['0'];
const cols = rows[0].length;
const r = Math.min(dotRadius, Math.max(1, Math.min(w, h) * 0.04));
const cellW = w / cols;
const cellH = h / rows.length;
for (let row = 0; row < rows.length; row++) {
for (let col = 0; col < cols; col++) {
if (rows[row][col] === '1') {
const cx = x + col * cellW + cellW / 2;
const cy = y + row * cellH + cellH / 2;
drawLed(ctx, cx, cy, r, glow, color);
}
}
}
}
function drawLed(ctx, cx, cy, r, glow = true, color = '#ff0000') {
if (r <= 0) return;
if (glow) {
const haloR = r * 2.2;
const midR = r * 1.4;
const col = normalizeColor(color);
ctx.fillStyle = colorWithAlpha(col, 0.08);
ctx.beginPath();
ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = colorWithAlpha(col, 0.35);
ctx.beginPath();
ctx.arc(cx, cy, midR, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = normalizeColor(color);
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
function pad2(n) {
return n < 10 ? `0${n}` : String(n);
}
function isSecondPulseActive(date, pulseMs = 500) {
return date.getMilliseconds() < pulseMs;
}
function normalizeColor(val) {
if (typeof val !== 'string' || !val.trim()) return '#ff0000';
return val.trim();
}
function colorWithAlpha(color, alpha) {
const c = normalizeColor(color);
if (/^#([0-9a-fA-F]{6})$/.test(c)) {
const r = parseInt(c.slice(1, 3), 16);
const g = parseInt(c.slice(3, 5), 16);
const b = parseInt(c.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
if (/^rgb\(/i.test(c)) {
const nums = c.match(/(\d+\.?\d*)/g)?.slice(0, 3) || [255, 0, 0];
return `rgba(${nums[0]},${nums[1]},${nums[2]},${alpha})`;
}
return `rgba(255,0,0,${alpha})`;
}
+905
View File
@@ -0,0 +1,905 @@
// views/goniometer_rtw.js — XY-Goniometer (RTW-Look) + 3-Meter-Panel + Korrelation
import { DEFAULT_TOP_INSET, FRAME_COLOR, LABEL_COLOR, MID_COLOR, OK_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
export const id = 'goniometer-rtw';
const ALIGN_PEAK = Math.pow(10, -15 / 20); // −15 dBFS Peak ≈ −18 dBFS RMS Sine
const INNER_MARGIN = 0.05; // 5 % Innenabstand im Scope
const MIN_TRACE_POINTS = 128;
const MAX_TRACE_POINTS = 4096;
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
const METER_WIDTH = 420;
const METER_GAP = 8;
const METER_SLOTS = 3;
const METER_SLOT_SHRINK = 24;
const METER_PAD_TOP = 1;
const METER_PAD_BOTTOM = -7;
const METER_EXTRA_BOTTOM_PAD = 6;
const CORR_ATTACK_S = 1.5;
const CORR_RELEASE_S = 2.5;
const GONIO_GAIN_MIN_DB = -35;
const GONIO_GAIN_MAX_DB = 35;
const BASE_TARGET = 1.0;
const BASE_HEADROOM_DB = 0;
const BASE_ZOOM = 1.0;
const BASE_GONIO_SCALE = (BASE_TARGET / ALIGN_PEAK) * Math.pow(10, -BASE_HEADROOM_DB / 20) * BASE_ZOOM;
const AGC_TARGET_DB = linearToDb(ALIGN_PEAK);
const CORR_SILENCE_THRESHOLD_DEFAULT = -75;
const CORR_SILENCE_HOLD_MS = 30000;
const CORR_SILENCE_DRIFT_MS = 60000;
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS, slotGap = 12) {
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
const avail = Math.max(200, canvasWidth);
const totalGap = (n - 1) * Math.max(2, slotGap);
const usable = avail - totalGap;
return Math.floor(usable / n);
}
export function init() {
return {
corrDisplayed: 0,
corrMeter: 0,
corrLastTs: 0,
corrLastValid: 0,
corrSilenceSince: 0,
gateLevel: 1,
gateLastTs: 0,
agcEnv: 1e-3,
agcGainDb: 0,
agcLastTs: 0,
traceBuffer: new Float32Array(0),
lineTrails: [],
staticLayerCanvas: null,
staticLayerCtx: null,
staticLayerKey: '',
};
}
export function resize() {}
export function destroy() {}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
const frameNow = getNow();
const wakeTs = Number.isFinite(env?.screensaverWakeTs) ? Number(env.screensaverWakeTs) : 0;
if (wakeTs && state?._lastWakeTs !== wakeTs) {
// Nach Screensaver-Wake: Korrelation sauber zurücksetzen, damit kein "alter" Wert (z.B. +1) stehen bleibt.
state._lastWakeTs = wakeTs;
state.corrDisplayed = 0;
state.corrMeter = 0;
state.corrLastTs = 0;
state.corrLastValid = 0;
state.corrSilenceSince = frameNow;
}
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
const slots = slotsRaw.filter((v) => v && v !== 'none');
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : FRAME.top;
const layout = computeGoniometerLayout(rect, CONFIG, slots.length, topInset);
const settings = resolveScopeSettings(CONFIG);
const style = CONFIG.XY_STYLE === 'points' ? 'points' : 'lines';
const gate = resolveSilenceGate(env, CONFIG, state);
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio);
const lastSampleTs = Number.isFinite(env?.audio?.lastSampleTs) ? Number(env.audio.lastSampleTs) : 0;
const audioAgeMs = lastSampleTs ? (frameNow - lastSampleTs) : Infinity;
const audioFresh = Number.isFinite(audioAgeMs) && audioAgeMs >= 0 && audioAgeMs <= 250;
const xyReady = xyData.ready && audioFresh;
const agc = (CONFIG.GONIO_AGC_ENABLED && xyReady)
? computeAgcGain(state, xyData, frameNow)
: { gainDb: settings.gainDb, gain: dbToLinear(settings.gainDb) };
const appliedScale = BASE_GONIO_SCALE * agc.gain * gate.level;
const trace = xyReady
? buildTrace(state, xyData, layout.scope, appliedScale, CONFIG)
: null;
renderTrace(g, state, trace, layout.scope, style, xyReady && gate.active, settings);
const silenceGateEnabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
const corrThreshold = resolveCorrThreshold(CONFIG);
const rmsL = Number.isFinite(env.audio?.rmsDb?.L) ? env.audio.rmsDb.L : -120;
const rmsR = Number.isFinite(env.audio?.rmsDb?.R) ? env.audio.rmsDb.R : -120;
const activeL = audioFresh && (!silenceGateEnabled || (rmsL >= corrThreshold));
const activeR = audioFresh && (!silenceGateEnabled || (rmsR >= corrThreshold));
let corrTarget = 0;
const canCorr = xyReady && utils?.correlation;
const zeroOnSilence = !!CONFIG?.CORR_ZERO_ON_SILENCE;
const bothSilent = !activeL && !activeR;
if (activeL && activeR && canCorr) {
// Beide Kanäle aktiv → echte Korrelation aus L/R-Samples
const corrRaw = utils.correlation(
xyData.xyL,
xyData.xyR,
Number.isFinite(state.corrDisplayed) ? state.corrDisplayed : 0,
CONFIG.CORR_SMOOTH,
);
if (Number.isFinite(corrRaw)) {
state.corrDisplayed = corrRaw;
state.corrLastValid = corrRaw;
state.corrSilenceSince = 0;
corrTarget = corrRaw;
}
} else if (activeL !== activeR) {
// Nur ein Kanal aktiv → hart auf 0, keine Hold-/Decay-Logik
state.corrSilenceSince = 0;
corrTarget = 0;
} else {
if (zeroOnSilence && bothSilent) {
// Beide still → zügig Richtung 0 (ohne Hold/Drift)
state.corrLastValid = 0;
state.corrSilenceSince = frameNow;
corrTarget = 0;
} else {
// Beide still → Hold + Drift zur Mitte
if (!state.corrSilenceSince) state.corrSilenceSince = frameNow;
corrTarget = resolveCorrTarget(state, frameNow);
}
}
const fastZero = zeroOnSilence && bothSilent;
const corrVisual = updateCorrelationDisplay(state, corrTarget, frameNow, fastZero ? true : gate.active);
if (fastZero) {
// Damit die nächste echte Korrelation nicht noch an einem alten Glättungswert "hängt".
state.corrDisplayed = corrVisual;
}
drawCorrelationBar(
g,
layout.plot.x + layout.plot.w / 2,
layout.plot.y + layout.plot.h - 28,
Math.round(Math.min(layout.scope.w * 0.75, layout.plot.w - 50)),
18,
corrVisual
);
if (slots.length) {
const panelSlotWidth = computePanelSlotWidth(
layout.meter.w,
slots.length,
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
);
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
drawMeterOutline(g, layout.meter);
}
}
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
if (!layer) {
drawPlotBackdrop(g, layout.plot);
drawFramework(g, layout, CONFIG);
drawScopeBackground(g, layout.scope);
drawCrosshair(g, layout.scope);
drawAxisLabels(g, layout.scope);
drawMeterPanelDividers(g, layout.meter, slotCount, CONFIG, computePanelSlotWidth(
layout.meter.w,
slotCount,
Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12))
));
return;
}
g.drawImage(layer, 0, 0, rect.w, rect.h);
}
function ensureStaticLayer(state, rect, layout, CONFIG, slotCount) {
const w = Math.max(1, rect.w | 0);
const h = Math.max(1, rect.h | 0);
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const key = [
w, h,
layout.plot.x, layout.plot.y, layout.plot.w, layout.plot.h,
layout.scope.x, layout.scope.y, layout.scope.w, layout.scope.h,
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
slotCount,
slotGap,
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? CONFIG.AXIS_GUTTER_LEFT : 14,
].join('|');
if (state.staticLayerCanvas && state.staticLayerKey === key) {
return state.staticLayerCanvas;
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return null;
ctx.textBaseline = 'alphabetic';
ctx.font = 'bold 14px ui-monospace, monospace';
drawPlotBackdrop(ctx, layout.plot);
drawFramework(ctx, layout, CONFIG);
drawScopeBackground(ctx, layout.scope);
drawCrosshair(ctx, layout.scope);
drawAxisLabels(ctx, layout.scope);
drawMeterPanelDividers(
ctx,
layout.meter,
slotCount,
CONFIG,
computePanelSlotWidth(layout.meter.w, slotCount, slotGap)
);
state.staticLayerCanvas = canvas;
state.staticLayerCtx = ctx;
state.staticLayerKey = key;
return canvas;
}
async function drawRealtimeSlot(g, rect, slotId, CONFIG, meters) {
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const innerRect = {
x: rect.x,
y: rect.y + METER_PAD_TOP + innerOffset,
w: rect.w,
h: innerHeight,
};
try {
await meters.draw(g, innerRect, slotId, CONFIG);
} catch (e) {
console.warn(`Meter ${slotId} draw error:`, e);
}
}
// -----------------------------------------------------------------------------
// Layout & drawing helpers
function computeGoniometerLayout(rect, CONFIG = {}, slotCount = METER_SLOTS, topInset = FRAME.top) {
const plotX = FRAME.left;
const plotY = Number.isFinite(topInset) ? topInset : FRAME.top;
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
const minPlotW = 200;
const slotGap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const panelSlotWidth = 100; // fixed width for better control
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
const panelSlotsWidth = n > 0 ? (panelSlotWidth * n + (n - 1) * slotGap) : 0;
let meterW = n > 0 ? Math.max(panelSlotsWidth, (n === 1 ? 160 : (n === 2 ? 280 : METER_WIDTH))) : 0;
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
if (plotW < minPlotW) {
plotW = minPlotW;
meterW = n > 0 ? (innerWidth - plotW - METER_GAP) : 0;
}
if (n > 0) meterW = Math.max(meterW, panelSlotsWidth);
if (plotW < 80) plotW = 80;
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
const plot = { x: plotX, y: plotY, w: plotW, h: plotH };
const scope = computeScopeBox(plot);
const meter = {
x: plot.x + plot.w + (n > 0 ? METER_GAP : 0),
y: plot.y,
w: meterW,
h: plotH,
};
return { plot, scope, meter };
}
function computeScopeBox(plot) {
const padding = 24;
const minDim = Math.min(plot.w, plot.h);
const tightSize = Math.max(120, minDim - padding * 2);
const size = Math.min(Math.max(140, tightSize), minDim);
const w = size;
const h = size;
const x = Math.round(plot.x + (plot.w - w) / 2);
const y = Math.round(plot.y + (plot.h - h) / 2);
const halfLimit = Math.max(24, Math.min(w, h) / 2 - 6);
const halfBase = Math.max(60, Math.min(w, h) / 2 - 18);
const half = Math.max(24, Math.min(halfBase, halfLimit));
const cx = x + w / 2;
const cy = y + h / 2;
const reach = Math.max(24, Math.min(half - 4, half * (1 - INNER_MARGIN)));
const diag = half * 0.9;
return { x, y, w, h, cx, cy, half, reach, diag };
}
function drawFramework(g, layout, CONFIG) {
drawPlotRails(g, layout.plot, CONFIG);
if (layout.meter?.w > 0) {
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h);
g.restore();
}
}
function drawPlotBackdrop(g, plot) {
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(plot.x, plot.y, plot.w, plot.h);
g.restore();
}
function drawPlotRails(g, plot, CONFIG) {
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
: 14;
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.beginPath();
g.moveTo(plot.x - gutterL, plot.y);
g.lineTo(plot.x + plot.w, plot.y);
g.stroke();
g.beginPath();
g.moveTo(plot.x - gutterL, plot.y + plot.h);
g.lineTo(plot.x + plot.w, plot.y + plot.h);
g.stroke();
g.beginPath();
g.moveTo(plot.x, plot.y);
g.lineTo(plot.x, plot.y + plot.h);
g.stroke();
g.beginPath();
g.moveTo(plot.x + plot.w, plot.y);
g.lineTo(plot.x + plot.w, plot.y + plot.h);
g.stroke();
g.restore();
}
function drawScopeBackground(g, scope) {
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(scope.x, scope.y, scope.w, scope.h);
g.restore();
}
function drawCrosshair(g, scope) {
g.save();
g.globalAlpha = 0.45;
g.strokeStyle = 'rgb(0,0,255)';
g.lineWidth = 1.5;
g.beginPath();
g.moveTo(scope.cx - scope.diag, scope.cy - scope.diag);
g.lineTo(scope.cx + scope.diag, scope.cy + scope.diag);
g.moveTo(scope.cx - scope.diag, scope.cy + scope.diag);
g.lineTo(scope.cx + scope.diag, scope.cy - scope.diag);
g.moveTo(scope.cx, scope.cy - scope.half);
g.lineTo(scope.cx, scope.cy + scope.half);
g.moveTo(scope.cx - scope.half, scope.cy);
g.lineTo(scope.cx + scope.half, scope.cy);
g.stroke();
g.restore();
}
function drawAxisLabels(g, scope) {
g.save();
g.fillStyle = FRAME_COLOR;
g.fillText('R', scope.cx + scope.diag - 10, scope.cy - scope.diag - 4);
g.fillText('L', scope.cx - scope.diag - 14, scope.cy - scope.diag - 4);
const labelOffset = scope.half + 6;
g.fillText('M', scope.cx - 6, scope.cy - labelOffset - 6);
g.fillText('S', scope.cx - labelOffset - 16, scope.cy + 6);
g.restore();
}
function drawIdleMessage(g, scope) {
g.save();
g.fillStyle = '#bcd';
g.fillText('Warte auf Audio …', scope.x + 12, scope.y + 20);
g.restore();
}
async function renderMeterPanel(g, meterLayout, slots, CONFIG, meters, panelSlotWidth) {
const slotCount = Array.isArray(slots) ? slots.length : 0;
if (!slotCount || meterLayout?.w <= 0) return;
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const slotW = panelSlotWidth;
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
const slotHeight = Math.max(40, meterLayout.h - slotTopPadding - slotBottomPadding);
const outerPad = 6;
for (let i = 0; i < slotCount; i++) {
const rectM = {
x: startX + i * (slotW + gap),
y: meterLayout.y + slotTopPadding,
w: slotW,
h: slotHeight,
};
await drawRealtimeSlot(g, rectM, slots[i], CONFIG, meters);
}
}
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
const gap = Math.max(2, Math.min(24, Number(CONFIG?.GONI_METER_GAP) || 12));
const slotW = panelSlotWidth;
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
const outerPad = 6;
for (let i = 1; i < slotCount; i++) {
const dividerX = startX + i * slotW + (i - 1) * gap + gap / 2;
g.save();
g.strokeStyle = 'rgba(0,231,255,0.4)';
g.lineWidth = 1;
g.setLineDash([4, 3]);
g.beginPath();
g.moveTo(dividerX, meterLayout.y + slotTopPadding - outerPad);
g.lineTo(dividerX, meterLayout.y + meterLayout.h - slotBottomPadding + outerPad);
g.stroke();
g.restore();
}
}
// -----------------------------------------------------------------------------
// Trace extraction & rendering
function extractXYData(audio) {
const xyL = audio?.xyL;
const xyR = audio?.xyR;
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
return {
ready,
xyL,
xyR,
length: ready ? Math.min(xyL.length, xyR.length) : 0,
};
}
function resolveScopeSettings(CONFIG) {
let gainDb = Number.isFinite(CONFIG?.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0;
gainDb = Math.max(GONIO_GAIN_MIN_DB, Math.min(GONIO_GAIN_MAX_DB, Math.round(gainDb / 5) * 5));
const lineFadeMs = Number.isFinite(CONFIG?.GONIO_LINE_FADE_MS) ? CONFIG.GONIO_LINE_FADE_MS : 300;
const rtwClassic = true;
return {
gainDb,
lineFadeMs,
rtwClassic,
traceColor: rtwClassic ? 'rgba(255,220,40,0.98)' : 'rgba(255,240,170,0.72)',
trailBaseAlpha: rtwClassic ? 0.18 : 0.18,
trailBoostAlpha: rtwClassic ? 0.42 : 0.42,
lineAlpha: rtwClassic ? 0.82 : 0.72,
lineWidth: 1.8,
pointBaseAlpha: rtwClassic ? 0.14 : 0.16,
pointBoostAlpha: rtwClassic ? 0.34 : 0.36,
pointSize: rtwClassic ? 1.4 : 2.2,
curveSmoothing: rtwClassic,
};
}
function resolveSilenceGate(env, CONFIG, state) {
const enabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false;
const threshold = Number.isFinite(CONFIG?.XY_SILENCE_THRESHOLD_RMS_DBFS)
? CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS
: CORR_SILENCE_THRESHOLD_DEFAULT;
const rmsMono = Number.isFinite(env.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120;
const targetActive = enabled ? (rmsMono >= threshold) : true;
const now = getNow();
const dt = state.gateLastTs ? Math.max(0, (now - state.gateLastTs) / 1000) : 0;
state.gateLastTs = now;
if (!Number.isFinite(state.gateLevel)) state.gateLevel = targetActive ? 1 : 0;
const tau = targetActive ? 0.06 : 0.35; // schneller rein, sanft raus
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
state.gateLevel += alpha * ((targetActive ? 1 : 0) - state.gateLevel);
state.gateLevel = Math.max(0, Math.min(1, state.gateLevel));
const active = state.gateLevel > 0.02;
return { active, level: state.gateLevel, rmsMono, threshold };
}
function buildTrace(state, xyData, scope, scale, CONFIG) {
if (xyData.length <= 1) {
return { buffer: null, count: 0 };
}
const targetPoints = resolveSampleTarget(CONFIG);
const total = xyData.length;
if (targetPoints <= 1 || total <= 1) {
return { buffer: null, count: 0 };
}
const sampleCount = Math.max(2, Math.min(targetPoints, MAX_TRACE_POINTS));
if (sampleCount <= 1) {
return { buffer: null, count: 0 };
}
const coords = ensureTraceBuffer(state, sampleCount * 2);
const reach = scope.reach;
const cx = scope.cx;
const cy = scope.cy;
const sourceLast = total - 1;
let write = 0;
for (let i = 0; i < sampleCount; i++) {
const pos = sampleCount <= 1 ? 0 : (i * sourceLast) / (sampleCount - 1);
const idx0 = Math.floor(pos);
const idx1 = Math.min(sourceLast, idx0 + 1);
const frac = pos - idx0;
const l0 = xyData.xyL[idx0];
const l1 = xyData.xyL[idx1];
const r0 = xyData.xyR[idx0];
const r1 = xyData.xyR[idx1];
const l = l0 + (l1 - l0) * frac;
const r = r0 + (r1 - r0) * frac;
const M = 0.5 * (l + r);
const S = 0.5 * (l - r);
const xNorm = clamp1(S * scale);
const yNorm = clamp1(M * scale);
coords[write++] = cx - xNorm * reach;
coords[write++] = cy - yNorm * reach;
if (write >= coords.length) break;
}
if (write < 4) {
return { buffer: null, count: 0 };
}
return { buffer: coords, count: write / 2 };
}
function renderTrace(g, state, trace, scope, style, xyReady, settings) {
const trails = state.lineTrails || (state.lineTrails = []);
const now = getNow();
const hasTrace = !!(trace && trace.buffer && trace.count);
const lineFadeMs = settings?.lineFadeMs ?? 0;
if (style === 'lines') {
if (lineFadeMs <= 0) {
trails.length = 0;
if (hasTrace && trace.count > 1) {
drawImmediateLine(g, trace, scope, settings);
} else if (!xyReady) {
drawIdleMessage(g, scope);
}
} else {
if (hasTrace && trace.count > 1) {
addLineTrail(trails, trace, now);
}
const rendered = drawLineTrails(g, trails, scope, now, lineFadeMs, settings);
if (!rendered && !xyReady) drawIdleMessage(g, scope);
}
} else {
if (lineFadeMs <= 0) {
trails.length = 0;
if (hasTrace) {
drawPointTrace(g, trace, scope, settings);
} else if (!xyReady) {
drawIdleMessage(g, scope);
}
} else {
if (hasTrace && trace.count > 0) {
addLineTrail(trails, trace, now);
}
const rendered = drawPointTrails(g, trails, scope, now, lineFadeMs, settings);
if (!rendered && !xyReady) {
drawIdleMessage(g, scope);
}
}
}
}
function addLineTrail(trails, trace, timestamp) {
const coords = new Float32Array(trace.count * 2);
coords.set(trace.buffer.subarray(0, trace.count * 2));
trails.push({ coords, count: trace.count, time: timestamp });
}
function drawLineTrails(g, trails, scope, now, fadeMs, settings) {
if (!trails.length) return false;
const cutoff = now - fadeMs;
const keep = [];
let drawn = false;
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
for (const trail of trails) {
if (trail.time < cutoff) continue;
const age = now - trail.time;
const fade = Math.max(0, 1 - age / fadeMs);
if (fade <= 0) continue;
drawn = true;
keep.push(trail);
g.save();
g.globalAlpha = (settings?.trailBaseAlpha ?? 0.35) + (settings?.trailBoostAlpha ?? 0.65) * fade;
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
g.lineWidth = settings?.lineWidth ?? 1.3;
beginTracePath(g, trail.coords, trail.count, settings);
g.stroke();
g.restore();
}
g.restore();
trails.length = 0;
Array.prototype.push.apply(trails, keep);
return drawn;
}
function drawImmediateLine(g, trace, scope, settings) {
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
g.globalAlpha = settings?.lineAlpha ?? 0.92;
g.strokeStyle = settings?.traceColor || 'rgba(255,231,74,0.92)';
g.lineWidth = settings?.lineWidth ?? 1.3;
beginTracePath(g, trace.buffer, trace.count, settings);
g.stroke();
g.restore();
}
function beginTracePath(g, coords, count, settings) {
g.beginPath();
if (!coords || count <= 0) return;
g.moveTo(coords[0], coords[1]);
if (!(settings?.curveSmoothing) || count < 3) {
for (let i = 1; i < count; i++) {
const idx = i * 2;
g.lineTo(coords[idx], coords[idx + 1]);
}
return;
}
for (let i = 1; i < count - 1; i++) {
const idx = i * 2;
const nextIdx = (i + 1) * 2;
const xc = (coords[idx] + coords[nextIdx]) * 0.5;
const yc = (coords[idx + 1] + coords[nextIdx + 1]) * 0.5;
g.quadraticCurveTo(coords[idx], coords[idx + 1], xc, yc);
}
const last = (count - 1) * 2;
const prev = (count - 2) * 2;
g.quadraticCurveTo(coords[prev], coords[prev + 1], coords[last], coords[last + 1]);
}
function drawPointTrails(g, trails, scope, now, fadeMs, settings) {
if (!trails.length) return false;
const cutoff = now - fadeMs;
const keep = [];
let drawn = false;
const pointSize = settings?.pointSize ?? 1.4;
const half = pointSize / 2;
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
for (const trail of trails) {
if (trail.time < cutoff) continue;
const age = now - trail.time;
const fade = Math.max(0, 1 - age / fadeMs);
if (fade <= 0) continue;
drawn = true;
keep.push(trail);
for (let i = 0; i < trail.count; i++) {
const idx = i * 2;
const localAge = trail.count > 1 ? i / (trail.count - 1) : 0;
const alpha = ((settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - localAge)) * fade;
g.fillStyle = `rgba(255,231,74,${alpha})`;
g.fillRect(trail.coords[idx] - half, trail.coords[idx + 1] - half, pointSize, pointSize);
}
}
g.restore();
trails.length = 0;
Array.prototype.push.apply(trails, keep);
return drawn;
}
function drawPointTrace(g, trace, scope, settings) {
g.save();
g.beginPath();
g.rect(scope.x, scope.y, scope.w, scope.h);
g.clip();
const pointSize = settings?.pointSize ?? 1.4;
const half = pointSize / 2;
for (let i = 0; i < trace.count; i++) {
const idx = i * 2;
const age = trace.count > 1 ? i / (trace.count - 1) : 0;
const alpha = (settings?.pointBaseAlpha ?? 0.25) + (settings?.pointBoostAlpha ?? 0.55) * (1 - age);
g.fillStyle = `rgba(255,231,74,${alpha})`;
g.fillRect(trace.buffer[idx] - half, trace.buffer[idx + 1] - half, pointSize, pointSize);
}
g.restore();
}
function resolveSampleTarget(CONFIG) {
const target = Number(CONFIG?.XY_POINTS);
if (!Number.isFinite(target)) return 512;
return clampInt(target, MIN_TRACE_POINTS, MAX_TRACE_POINTS);
}
function ensureTraceBuffer(state, capacity) {
if (!(state.traceBuffer instanceof Float32Array) || state.traceBuffer.length < capacity) {
state.traceBuffer = new Float32Array(capacity);
}
return state.traceBuffer;
}
function clamp1(v) {
return v < -1 ? -1 : (v > 1 ? 1 : v);
}
function clampInt(value, min, max) {
if (!Number.isFinite(value)) return min;
const v = Math.round(value);
return Math.min(max, Math.max(min, v));
}
function getNow() {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
function updateCorrelationDisplay(state, target, nowTs, gateActive = true) {
const prev = Number.isFinite(state.corrMeter) ? state.corrMeter : target;
const lastTs = Number.isFinite(state.corrLastTs) ? state.corrLastTs : nowTs;
const dt = Math.max(0, (nowTs - lastTs) / 1000);
if (!dt) {
state.corrMeter = clamp1(target);
state.corrLastTs = nowTs;
return state.corrMeter;
}
const rising = Math.abs(target) > Math.abs(prev);
// Schneller einrasten, wenn Gate aktiv ist; etwas zäher auf Null auslaufen, wenn Stille.
const tauAttack = gateActive ? 0.08 : CORR_ATTACK_S;
const tauRelease = gateActive ? 0.35 : CORR_RELEASE_S;
const tau = rising ? tauAttack : tauRelease;
const alpha = 1 - Math.exp(-dt / Math.max(0.001, tau));
const next = clamp1(prev + (target - prev) * alpha);
state.corrMeter = next;
state.corrLastTs = nowTs;
return next;
}
function resolveCorrThreshold(CONFIG = {}) {
if (Number.isFinite(CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS)) {
return CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS;
}
if (Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)) {
return CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS;
}
return CORR_SILENCE_THRESHOLD_DEFAULT;
}
function resolveCorrTarget(state, nowTs) {
const lastValid = Number.isFinite(state.corrLastValid) ? state.corrLastValid : 0;
if (!state.corrSilenceSince) {
return lastValid;
}
const holdMs = CORR_SILENCE_HOLD_MS;
const driftMs = CORR_SILENCE_DRIFT_MS;
const elapsed = Math.max(0, nowTs - state.corrSilenceSince);
if (elapsed <= holdMs) {
return lastValid;
}
const t = Math.min(1, (elapsed - holdMs) / Math.max(1, driftMs));
return lastValid * (1 - t);
}
function computeAgcGain(state, xyData, nowTs) {
const attackTau = 0.001; // 1 ms
const releaseDbPerS = 10;
const dt = state.agcLastTs ? Math.max(0, (nowTs - state.agcLastTs) / 1000) : 0;
state.agcLastTs = nowTs;
const peak = measureGoniometerPeak(xyData);
let env = Number.isFinite(state.agcEnv) && state.agcEnv > 0 ? state.agcEnv : 1e-3;
if (peak >= env) {
const attackAlpha = attackTau > 0 ? (1 - Math.exp(-Math.max(dt, 0) / attackTau)) : 1;
env = env + (peak - env) * Math.min(1, attackAlpha || 1);
} else {
const releaseFactor = Math.pow(10, -releaseDbPerS * Math.max(dt, 0) / 20);
env = Math.max(peak, env * releaseFactor);
}
env = Math.max(1e-6, env);
state.agcEnv = env;
const envDb = linearToDb(env);
let gainDb = AGC_TARGET_DB - envDb;
if (gainDb > GONIO_GAIN_MAX_DB) gainDb = GONIO_GAIN_MAX_DB;
if (gainDb < GONIO_GAIN_MIN_DB) gainDb = GONIO_GAIN_MIN_DB;
state.agcGainDb = gainDb;
return {
gainDb,
gain: dbToLinear(gainDb),
};
}
function measureGoniometerPeak(xyData) {
if (!xyData || !xyData.ready) return 1e-3;
let peak = 1e-6;
const len = xyData.length;
for (let i = 0; i < len; i++) {
const l = xyData.xyL[i];
const r = xyData.xyR[i];
const M = 0.5 * (l + r);
const S = 0.5 * (l - r);
const sample = Math.max(Math.abs(M), Math.abs(S));
if (sample > peak) peak = sample;
}
return peak;
}
function linearToDb(value) {
const v = Math.max(1e-9, value);
return 20 * Math.log10(v);
}
function dbToLinear(db) {
return Math.pow(10, db / 20);
}
// -----------------------------------------------------------------------------
// Correlation bar helper
function drawCorrelationBar(g, centerX, y, w, h, val) {
const x = Math.floor(centerX - w / 2);
g.save();
g.strokeStyle = FRAME_COLOR; g.lineWidth = 2; g.strokeRect(x, y, w, h);
const mid = x + w / 2;
g.fillStyle = LABEL_COLOR;
g.textAlign = 'right'; g.fillText('-1', x - 6, y + h / 2 + 5);
g.textAlign = 'left'; g.fillText('+1', x + w + 6, y + h / 2 + 5);
const padding = 3;
const cubeSize = Math.max(10, Math.min(h - padding * 2, w - padding * 2));
const xVal = mid + (val * 0.5) * w;
const clampCenter = (v) => {
const minC = x + padding + cubeSize * 0.5;
const maxC = x + w - padding - cubeSize * 0.5;
return Math.max(minC, Math.min(maxC, v));
};
const cubeX = clampCenter(xVal) - cubeSize / 2;
const cubeY = y + (h - cubeSize) / 2;
const cubeColor = resolveCorrColor(val);
g.fillStyle = 'rgba(255,255,255,0.08)';
g.fillRect(x + 1, y + 1, w - 2, h - 2);
g.fillStyle = cubeColor;
g.fillRect(cubeX, cubeY, cubeSize, cubeSize);
g.strokeStyle = '#0ff';
g.lineWidth = 1;
g.strokeRect(cubeX, cubeY, cubeSize, cubeSize);
g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke();
g.restore();
}
function resolveCorrColor(val) {
const t = Math.max(-1, Math.min(1, val));
if (t <= -0.33) return WARN_COLOR;
if (t >= 0.33) return OK_COLOR;
return MID_COLOR;
}
function drawMeterOutline(g, meterRect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
g.restore();
}
+169
View File
@@ -0,0 +1,169 @@
// views/panel.js — Zentrales Mehrmeter-Panel (wie in deinem Original)
// Nutzt bis zu fünf HUD-Slots und zeichnet mittig ein breites Panel mit Rahmen.
import { DEFAULT_TOP_INSET, FRAME_COLOR, FRAME_COLOR_DIM, LABEL_COLOR } from '../core/theme.js';
export const id = 'panel';
export function init() {
return { staticLayer: null };
}
export function resize() {}
export function destroy(state) {
state.staticLayer = null;
}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, meters } = env;
const W = rect.w, H = rect.h;
const mWidth = Math.max(200, Math.floor(W * 1));
const mPX = 0;
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
const mTop = Number.isFinite(env?.topInset) ? Math.max(0, Math.floor(topInset + 10)) : 80;
const mBot = H - 10;
const labelY = mTop - 25; // aktuell ungenutzt, Meter zeichnen eigene Labels
// Slots nebeneinander (leere Slots werden ausgeblendet)
const inner = 0;
const gap = 12;
const meterPadTop = 18;
const meterPadBottom = 10;
const slotsRaw = env.slots?.('panel') || ['vu','ppm-ebu','tp','rms','lufs'];
const slots = slotsRaw.filter((v) => v && v !== 'none');
const n = slots.length;
const setX0 = mPX + inner;
if (!n) {
g.save();
g.fillStyle = LABEL_COLOR;
g.textAlign = 'left';
g.font = 'bold 16px ui-monospace, monospace';
g.fillText('Keine Slots aktiv', setX0 + 10, mTop + 20);
g.restore();
return;
}
const setW = Math.floor((mWidth - (n - 1) * gap - inner * 2) / n);
const staticLayout = {
mPX,
mTop,
mWidth,
H,
gap,
slots: n,
setX0,
setW,
meterPadTop,
meterPadBottom,
dividersEnabled: !!CONFIG.PANEL_DIVIDERS_ENABLED,
hasSlots: n > 0,
};
for (let i = 0; i < n; i++) {
const x = setX0 + i * (setW + gap);
const rectM = {
x,
y: mTop + meterPadTop,
w: setW,
h: Math.max(0, (mBot - mTop) - meterPadTop - meterPadBottom)
};
if (i > 0 && CONFIG.PANEL_DIVIDERS_ENABLED) {
const dividerX = x - gap / 2;
g.save();
g.strokeStyle = FRAME_COLOR_DIM;
g.lineWidth = 1;
g.setLineDash([4, 3]);
g.beginPath();
g.moveTo(dividerX, mTop + meterPadTop - 6);
g.lineTo(dividerX, mBot - meterPadBottom + 6);
g.stroke();
g.restore();
}
try {
await meters.draw(g, rectM, slots[i], CONFIG);
} catch (e) {
// Slot neutral markieren, damit die anderen nicht leiden
g.save();
g.strokeStyle = 'rgba(200,80,80,.8)';
g.setLineDash([6,4]);
g.strokeRect(rectM.x+0.5, rectM.y+0.5, rectM.w-1, rectM.h-1);
g.setLineDash([]);
g.restore();
}
}
drawCachedPanelStaticLayer(g, state, staticLayout);
}
function drawCachedPanelStaticLayer(g, state, layout) {
const {
mPX,
mTop,
mWidth,
H,
gap,
slots,
setX0,
setW,
meterPadTop,
meterPadBottom,
dividersEnabled,
} = layout;
const key = [
Math.round(mPX),
Math.round(mTop),
Math.round(mWidth),
Math.round(H),
gap,
slots,
Math.round(setX0),
Math.round(setW),
meterPadTop,
meterPadBottom,
dividersEnabled ? 1 : 0,
].join('|');
const strokePad = 2;
const width = Math.max(1, Math.round(mWidth + strokePad * 2));
const height = Math.max(1, Math.round((H - (mTop - 10)) + strokePad * 2));
const needsRebuild = !state.staticLayer
|| state.staticLayer.key !== key
|| state.staticLayer.width !== width
|| state.staticLayer.height !== height;
if (needsRebuild) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const cg = canvas.getContext('2d');
if (cg) {
cg.save();
cg.translate(strokePad - mPX, strokePad - (mTop - 10));
cg.strokeStyle = FRAME_COLOR;
cg.lineWidth = 2;
cg.strokeRect(mPX, mTop - 10, mWidth, H - (mTop - 10));
if (dividersEnabled) {
const mBot = H - 10;
for (let i = 1; i < slots; i++) {
const x = setX0 + i * (setW + gap);
const dividerX = x - gap / 2;
cg.save();
cg.strokeStyle = FRAME_COLOR_DIM;
cg.lineWidth = 1;
cg.setLineDash([4, 3]);
cg.beginPath();
cg.moveTo(dividerX, mTop + meterPadTop - 6);
cg.lineTo(dividerX, mBot - meterPadBottom + 6);
cg.stroke();
cg.restore();
}
}
cg.restore();
}
state.staticLayer = { key, canvas, width, height };
}
g.drawImage(state.staticLayer.canvas, mPX - strokePad, (mTop - 10) - strokePad);
}
File diff suppressed because it is too large Load Diff
+971
View File
@@ -0,0 +1,971 @@
// IN USE
// views/phase_wheel.js — Phase/Frequency Wheel (polar phase display) + meter panel
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
export const id = 'phase-wheel';
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
const WHEEL_METER_GAP = 8;
const METER_WIDTH = 420;
const METER_GAP = 8;
const METER_SLOTS = 3;
const METER_PAD_TOP = 30;
const METER_PAD_BOTTOM = 20;
const METER_EXTRA_BOTTOM_PAD = 6;
const TARGET_POINTS = 1024;
const COLOR_STOPS = [
{ t: 0.0, color: [0, 0, 50] },
{ t: 0.3, color: [0, 120, 180] },
{ t: 0.6, color: [0, 205, 120] },
{ t: 0.8, color: [210, 220, 0] },
{ t: 1.0, color: [255, 120, 0] },
];
const RING_DBFS = [0, -8, -16, -24, -32, -40];
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
const PHASE_GAIN_MIN_DB = -35;
const PHASE_GAIN_MAX_DB = 35;
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
const PHASE_AGC_BASE_GAIN = 1 / PHASE_ALIGN_TARGET;
const PHASE_AGC_ATTACK_S = 0.02;
const PHASE_AGC_RELEASE_DB_PER_S = 12;
const PHASE_LEVEL_THRESHOLD_DB = -60;
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
const PHASE_IDLE_DECAY = 0.85;
const PHASE_PHASE_SMOOTH_ALPHA = 0.08;
const PHASE_RADIUS_SMOOTH_ALPHA = 0.18;
const PHASE_BANDPASS_LOW_HZ = 300;
const PHASE_BANDPASS_HIGH_HZ = 5000;
const PHASE_TRAIL_FADE_MS = 2000;
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
const PHASE_SECTORS = [
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' },
{ startDeg: -60, endDeg: -30, color: 'rgba(255,214,120,0.25)' },
{ startDeg: 60, endDeg: 180, color: 'rgba(255,120,120,0.25)' },
{ startDeg: -180, endDeg: -60, color: 'rgba(255,120,120,0.25)' },
];
// Numerisch stabilere Hilbert-Kernel-Erstellung
const HILBERT_KERNEL = buildHilbertKernel(33);
const HILBERT_HALF = (HILBERT_KERNEL.length - 1) / 2;
// Lookup-Tables für häufig verwendete Werte
const ANGLE_COS = new Float32Array(360);
const ANGLE_SIN = new Float32Array(360);
for (let i = 0; i < 360; i++) {
const rad = (i * Math.PI) / 180;
ANGLE_COS[i] = Math.cos(rad);
ANGLE_SIN[i] = Math.sin(rad);
}
export function init() {
return {
traceBuffer: new Float32Array(0),
ampBuffer: new Float32Array(0),
filteredL: new Float32Array(0),
filteredR: new Float32Array(0),
currentPhase: null,
currentRadius: 0,
smoothPhase: null,
smoothRadius: 0,
phaseAgcEnv: 1e-3,
phaseAgcGainDb: 0,
phaseAgcLastTs: 0,
bandpass: createBandpassState(),
bufferGrowthCount: 0,
maxBufferSize: 0,
phaseTrail: [],
staticLayerCanvas: null,
staticLayerKey: '',
};
}
export function destroy() {
// Cleanup - Setze Referenzen für Garbage Collection frei
return null;
}
export function resize() {}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
const slots = slotsRaw.filter((v) => v && v !== 'none');
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : FRAME.top;
const layout = computeLayout(rect, slots.length, topInset);
const now = getNow();
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio);
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
if (xyData.ready) {
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
renderWheel(g, trace, layout.wheel, state, CONFIG, now);
} else {
decayPhasePointer(state);
trimPhaseTrail(state, CONFIG, now);
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
drawPhasePointer(g, layout.wheel, state);
drawIdleMessage(g, layout.wheel);
}
if (slots.length) {
const panelSlotWidth = computePanelSlotWidth(layout.meter.w, slots.length);
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
drawMeterOutline(g, layout.meter);
}
drawPhaseAngleLabel(g, layout.wheelPanel, layout.wheel, state, now);
}
function computeLayout(rect, slotCount = METER_SLOTS, topInset = FRAME.top) {
const plotX = FRAME.left;
const plotY = Number.isFinite(topInset) ? topInset : FRAME.top;
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
const minPlotW = 200;
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
let meterW = n > 0 ? resolveMeterWidth(rect.w, n) : 0;
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
if (plotW < minPlotW) {
plotW = minPlotW;
meterW = n > 0 ? Math.max(0, innerWidth - plotW - METER_GAP) : 0;
}
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
const wheelSize = Math.min(plotW, plotH);
const wheelPanel = {
x: plotX,
y: plotY,
w: plotW,
h: plotH,
};
const meterX = plotX + plotW + (n > 0 ? METER_GAP : 0);
const wheel = {
x: wheelPanel.x + (wheelPanel.w - wheelSize) / 2,
y: plotY + (plotH - wheelSize) / 2,
w: wheelSize,
h: wheelSize,
cx: wheelPanel.x + wheelPanel.w / 2,
cy: plotY + plotH / 2,
radius: wheelSize / 2 - 8,
};
const meter = {
x: meterX,
y: plotY,
w: meterW,
h: plotH,
};
return { wheelPanel, wheel, meter };
}
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
if (!layer) {
drawWheelBackground(g, layout.wheelPanel, layout.wheel, CONFIG);
if (slotCount) drawMeterBackground(g, layout.meter);
return;
}
g.drawImage(layer, 0, 0, rect.w, rect.h);
}
function ensureStaticLayer(state, rect, layout, CONFIG, slotCount) {
const w = Math.max(1, rect.w | 0);
const h = Math.max(1, rect.h | 0);
const key = [
w, h,
layout.wheelPanel.x, layout.wheelPanel.y, layout.wheelPanel.w, layout.wheelPanel.h,
layout.wheel.x, layout.wheel.y, layout.wheel.w, layout.wheel.h, layout.wheel.radius,
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
slotCount,
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
CONFIG?.PHASE_AMPLITUDE_MODE || 'bandpass',
].join('|');
if (state.staticLayerCanvas && state.staticLayerKey === key) {
return state.staticLayerCanvas;
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return null;
ctx.textBaseline = 'alphabetic';
ctx.font = 'bold 14px ui-monospace, monospace';
drawWheelBackground(ctx, layout.wheelPanel, layout.wheel, CONFIG);
if (slotCount) {
drawMeterBackground(ctx, layout.meter);
drawMeterPanelDividers(ctx, layout.meter, slotCount, CONFIG, computePanelSlotWidth(layout.meter.w, slotCount));
}
state.staticLayerCanvas = canvas;
state.staticLayerKey = key;
return canvas;
}
function resolveMeterWidth(totalWidth, slotCount) {
if (slotCount >= 3) return Math.max(METER_WIDTH, Math.round(totalWidth * 0.35));
return slotCount === 2 ? 280 : 160;
}
function drawWheelBackground(g, panel, wheel, CONFIG) {
const ringDbValues = getRingDbValues(CONFIG);
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(panel.x, panel.y, panel.w, panel.h);
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(panel.x, panel.y, panel.w, panel.h);
g.translate(wheel.cx, wheel.cy);
drawPhaseSectors(g, wheel);
const rings = ringDbValues.length;
for (let i = 0; i < rings; i++) {
const frac = ringFraction(i, rings);
const r = wheel.radius * frac;
g.strokeStyle = 'rgba(0,231,255,0.2)';
g.lineWidth = i === 0 ? 1.5 : 1;
g.setLineDash(i === 0 ? [] : [4, 4]);
g.beginPath();
g.arc(0, 0, r, 0, Math.PI * 2);
g.stroke();
}
g.setLineDash([]);
g.strokeStyle = 'rgba(0,231,255,0.35)';
for (let i = 0; i < 4; i++) {
const angle = (Math.PI / 2) * i;
g.beginPath();
g.moveTo(0, 0);
g.lineTo(Math.cos(angle) * wheel.radius, Math.sin(angle) * wheel.radius);
g.stroke();
}
drawAmplitudeScale(g, wheel, ringDbValues, CONFIG);
g.fillStyle = '#bcd';
g.font = '12px ui-monospace, monospace';
g.textAlign = 'center';
g.textBaseline = 'middle';
const labels = [
{ text: '0°', angle: -Math.PI / 2 },
{ text: '-90°', angle: Math.PI },
{ text: '+90°', angle: 0 },
{ text: '180°', angle: Math.PI / 2 },
];
for (const lbl of labels) {
const inset = (lbl.text === '+90°' || lbl.text === '-90°') ? 34 : 24;
const x = Math.cos(lbl.angle) * (wheel.radius - inset);
const y = Math.sin(lbl.angle) * (wheel.radius - inset);
g.fillText(lbl.text, x, y);
}
g.restore();
}
function drawPhaseSectors(g, wheel) {
const radius = Math.max(10, wheel.radius - 6);
const width = 12;
for (const sector of PHASE_SECTORS) {
g.save();
g.strokeStyle = sector.color;
g.lineWidth = width;
g.beginPath();
g.arc(
0,
0,
radius,
degToCanvas(sector.startDeg),
degToCanvas(sector.endDeg),
false
);
g.stroke();
g.restore();
}
}
function drawIdleMessage(g, wheel) {
g.save();
g.fillStyle = '#bcd';
g.textAlign = 'center';
g.textBaseline = 'middle';
g.font = 'bold 16px ui-monospace, monospace';
g.fillText('Waiting for audio…', wheel.cx, wheel.cy);
g.restore();
}
function drawAmplitudeScale(g, wheel, ringDbValues, CONFIG) {
const axisAngle = (3 * Math.PI) / 4;
const mode = getPhaseAmplitudeMode(CONFIG);
g.save();
g.fillStyle = '#9fe';
g.font = '11px ui-monospace, monospace';
g.textAlign = 'center';
g.textBaseline = 'middle';
const rings = Array.isArray(ringDbValues) ? ringDbValues.length : 0;
for (let i = 0; i < rings; i++) {
const db = ringDbValues[i];
const r = wheel.radius * ringFraction(i, rings);
const x = Math.cos(axisAngle) * (r + 14);
const y = Math.sin(axisAngle) * (r + 14);
g.fillText(formatAmplitudeLabel(db, mode), x, y);
}
const titleX = Math.cos(axisAngle) * (wheel.radius + 34);
const titleY = Math.sin(axisAngle) * (wheel.radius + 34);
g.fillStyle = 'rgba(170,220,220,0.9)';
g.font = 'bold 11px ui-monospace, monospace';
g.fillText(mode === 'ppm-din' ? 'PPM DIN' : 'dBFS', titleX, titleY);
g.restore();
}
function degToCanvas(deg) {
return (deg * Math.PI) / 180 - Math.PI / 2;
}
function radToDeg(rad) {
return (rad * 180) / Math.PI;
}
function extractXYData(audio) {
const xyL = audio?.xyL;
const xyR = audio?.xyR;
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
return {
ready,
xyL,
xyR,
length: ready ? Math.min(xyL.length, xyR.length) : 0,
sampleRate: audio?.sampleRate || 48000,
};
}
function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
if (!xyData.ready || !xyData.length) {
decayPhasePointer(state);
return null;
}
const filtered = preparePhaseFilteredBuffers(state, xyData);
const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
const ringDbValues = getRingDbValues(CONFIG);
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
: 0;
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
const radius = wheel.radius;
let ampIdx = 0;
let sumSin = 0;
let sumCos = 0;
let sumRadius = 0;
let levelAcc = 0;
const gainLinear = Number.isFinite(gain) ? gain : 1;
for (let i = 0; i < xyData.length; i += step) {
const lRe = clamp1(filtered.L[i]);
const rRe = clamp1(filtered.R[i]);
const lIm = hilbertAt(filtered.L, i);
const rIm = hilbertAt(filtered.R, i);
const phaseL = Math.atan2(lIm, lRe);
const phaseR = Math.atan2(rIm, rRe);
let phaseDiff = phaseL - phaseR;
if (!Number.isFinite(phaseDiff)) continue;
phaseDiff = wrapAngle(phaseDiff);
const angle = phaseDiff - Math.PI / 2;
const magL = Math.min(1, Math.hypot(lRe, lIm));
const magR = Math.min(1, Math.hypot(rRe, rIm));
const amp = Math.min(1, 0.5 * (magL + magR));
const ampScaled = Math.min(1, amp * gainLinear);
const radiusNorm = amplitudeMode === 'ppm-din'
? ppmRadiusNorm
: linearToRadiusNorm(ampScaled, ringDbValues);
ampIdx++;
sumSin += Math.sin(angle);
sumCos += Math.cos(angle);
sumRadius += radiusNorm;
levelAcc += amp * amp;
}
if (ampIdx > 0) {
const invCount = 1 / ampIdx;
const avgAngle = Math.atan2(sumSin * invCount, sumCos * invCount);
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount);
const blockLevel = Math.sqrt(levelAcc * invCount);
if (blockLevel >= PHASE_LEVEL_THRESHOLD) {
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
state.currentPhase = avgAngle;
state.currentRadius = avgRadius;
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
} else {
decayPhasePointer(state);
}
} else {
decayPhasePointer(state);
}
return { count: ampIdx, radius };
}
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
g.save();
g.lineWidth = 1.5;
g.globalAlpha = 0.95;
if (trace && trace.count > 1) {
// reserved for future trace rendering
}
g.restore();
updatePhaseTrail(state, wheel, CONFIG, timestamp);
drawPhaseTrail(g, wheel, state, CONFIG, timestamp);
drawPhasePointer(g, wheel, state);
}
function drawPhasePointer(g, wheel, state) {
if (!Number.isFinite(state?.smoothPhase) || !Number.isFinite(state?.smoothRadius)) return;
const phase = state.smoothPhase;
const radiusNorm = Math.max(0, state.smoothRadius);
if (radiusNorm <= 0.002) return;
const length = radiusNorm * wheel.radius;
g.save();
g.translate(wheel.cx, wheel.cy);
g.strokeStyle = '#ffe36e';
g.fillStyle = '#ffe36e';
g.lineWidth = 3;
g.beginPath();
g.moveTo(0, 0);
g.lineTo(Math.cos(phase) * length, Math.sin(phase) * length);
g.stroke();
g.beginPath();
g.arc(0, 0, 4, 0, Math.PI * 2);
g.fill();
g.restore();
}
function drawPhaseAngleLabel(g, wheelPanel, wheel, state, nowMs) {
const hasPhase = Number.isFinite(state?.smoothPhase) && Number.isFinite(state?.smoothRadius) && state.smoothRadius > 0.002;
const now = Number.isFinite(nowMs) ? nowMs : getNow();
let degText = '--°';
if (hasPhase) {
const prevTs = Number.isFinite(state?._phaseLabelLastTs) ? state._phaseLabelLastTs : now;
const dt = Math.max(0, Math.min(0.25, (now - prevTs) / 1000));
// Die Zahl soll dem Zeiger folgen. Der Zeiger ist bereits geglättet (smoothPhase).
// Hier nur eine leichte, adaptive Glättung + Snap bei großen Sprüngen, damit Zahl und Zeiger
// bei schnellen Änderungen nicht auseinanderlaufen.
const targetPhase = state.smoothPhase;
const prevPhase = Number.isFinite(state?._phaseLabelPhase) ? state._phaseLabelPhase : targetPhase;
const diff = wrapAngle(targetPhase - prevPhase);
const absDiff = Math.abs(diff);
const snap = absDiff > (Math.PI / 3); // >60°: sofort folgen
const tau = 0.18; // schneller als vorher, damit der Wert "mitkommt"
const alpha = (snap || dt <= 0) ? 1 : (1 - Math.exp(-dt / tau));
const labelPhase = smoothAngle(prevPhase, targetPhase, alpha);
state._phaseLabelPhase = labelPhase;
state._phaseLabelLastTs = now;
let deg = radToDeg(labelPhase) + 90;
while (deg <= -180) deg += 360;
while (deg > 180) deg -= 360;
const minIntervalMs = 1000 / 30; // bis zu 30 updates/s (sonst wirkt es "hinterher")
const prevText = typeof state?._phaseLabelText === 'string' ? state._phaseLabelText : null;
const prevDisplayTs = Number.isFinite(state?._phaseLabelDisplayTs) ? state._phaseLabelDisplayTs : 0;
const nextText = `${deg > 0 ? '+' : ''}${deg.toFixed(0)}°`;
if (!prevText || snap || (now - prevDisplayTs) >= minIntervalMs) {
degText = nextText;
state._phaseLabelText = degText;
state._phaseLabelDisplayTs = now;
} else {
degText = prevText;
}
} else {
if (state) {
state._phaseLabelPhase = null;
state._phaseLabelLastTs = now;
state._phaseLabelText = '--°';
state._phaseLabelDisplayTs = now;
}
}
const boxPadding = 7;
const boxW = 96;
const boxH = 42;
const panelX = Number.isFinite(wheelPanel?.x) ? wheelPanel.x : 0;
const panelY = Number.isFinite(wheelPanel?.y) ? wheelPanel.y : wheel.y;
const boxX = panelX + 12;
const boxY = panelY + 12;
g.save();
g.fillStyle = 'rgba(0, 0, 0, 0.55)';
g.strokeStyle = 'rgba(0, 231, 255, 0.65)';
g.lineWidth = 1;
g.fillRect(boxX, boxY, boxW, boxH);
g.strokeRect(boxX, boxY, boxW, boxH);
g.fillStyle = '#ffe36e';
g.font = 'bold 15px ui-monospace, monospace';
g.textAlign = 'center';
g.textBaseline = 'middle';
g.fillText('Phase', boxX + boxW / 2, boxY + boxPadding + 4);
g.font = 'bold 19px ui-monospace, monospace';
g.fillText(degText, boxX + boxW / 2, boxY + boxH - boxPadding - 2);
g.restore();
}
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS) {
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
const avail = Math.max(200, canvasWidth);
const totalGap = (n - 1) * 12;
const usable = avail - totalGap;
return Math.floor(usable / n);
}
async function renderMeterPanel(g, meterLayout, slots, CONFIG, meters, panelSlotWidth) {
const slotCount = Array.isArray(slots) ? slots.length : 0;
if (!slotCount || meterLayout?.w <= 0) return;
const gap = 12;
const slotW = panelSlotWidth;
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
for (let i = 0; i < slotCount; i++) {
const rectM = {
x: startX + i * (slotW + gap),
y: meterLayout.y + METER_PAD_TOP,
w: slotW,
h: Math.max(40, meterLayout.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_EXTRA_BOTTOM_PAD),
};
try {
await meters.draw(g, rectM, slots[i], CONFIG);
} catch (e) {
console.warn(`Meter ${slots[i]} draw error:`, e);
}
}
}
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
const gap = 12;
const slotW = panelSlotWidth;
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
for (let i = 1; i < slotCount; i++) {
const dividerX = startX + i * slotW + (i - 1) * gap + gap / 2;
g.save();
g.strokeStyle = 'rgba(0,231,255,0.4)';
g.lineWidth = 1;
g.setLineDash([4, 3]);
g.beginPath();
g.moveTo(dividerX, meterLayout.y + 6);
g.lineTo(dividerX, meterLayout.y + meterLayout.h - 6);
g.stroke();
g.restore();
}
}
function drawMeterOutline(g, meterRect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
g.restore();
}
function drawMeterBackground(g, meterRect) {
g.save();
g.fillStyle = PANEL_BG;
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
g.restore();
}
function updatePhaseTrail(state, wheel, CONFIG, now) {
if (!state.phaseTrail) state.phaseTrail = [];
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
state.phaseTrail.length = 0;
return;
}
const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
const length = radiusNorm * wheel.radius;
const x = wheel.cx + Math.cos(state.smoothPhase) * length;
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
const color = trailColorForAngle(state.smoothPhase);
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
if (!last) {
state.phaseTrail.push({ x, y, t: now, color });
} else {
const dt = now - last.t;
const dx = x - last.x;
const dy = y - last.y;
const dist2 = dx * dx + dy * dy;
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== color) {
state.phaseTrail.push({ x, y, t: now, color });
} else {
last.x = x;
last.y = y;
last.t = now;
last.color = color;
}
}
}
trimPhaseTrail(state, CONFIG, now);
}
function trimPhaseTrail(state, CONFIG, now) {
if (!state.phaseTrail) state.phaseTrail = [];
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
state.phaseTrail.length = 0;
return;
}
const cutoff = now - PHASE_TRAIL_FADE_MS;
let write = 0;
for (let i = 0; i < state.phaseTrail.length; i++) {
const pt = state.phaseTrail[i];
if (pt.t >= cutoff) {
state.phaseTrail[write++] = pt;
}
}
state.phaseTrail.length = write;
}
function drawPhaseTrail(g, wheel, state, CONFIG, now) {
if (!CONFIG?.PHASE_TRAIL_ENABLED) return;
const pts = state.phaseTrail;
if (!pts || pts.length < 2) return;
g.save();
g.lineCap = 'round';
g.lineWidth = 2.5;
for (let i = 1; i < pts.length; i++) {
const prev = pts[i - 1];
const curr = pts[i];
const age = Math.max(0, now - curr.t);
const alpha = Math.max(0, 1 - age / PHASE_TRAIL_FADE_MS);
if (alpha <= 0) continue;
g.globalAlpha = alpha;
g.strokeStyle = curr.color;
g.beginPath();
g.moveTo(prev.x, prev.y);
g.lineTo(curr.x, curr.y);
g.stroke();
}
g.restore();
}
function trailColorForAngle(angle) {
let deg = radToDeg(angle) + 90;
while (deg <= -180) deg += 360;
while (deg > 180) deg -= 360;
const centered = deg;
if (Math.abs(centered) <= 30) return '#48d296';
if (Math.abs(centered) <= 60) return '#ffd678';
return '#ff7a78';
}
function clamp1(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function ringFraction(index, ringCount) {
const n = Math.max(1, ringCount | 0);
const idx = Math.max(0, Math.min(n - 1, index | 0));
return (n - idx) / n;
}
function getPhaseAmplitudeMode(CONFIG) {
return CONFIG?.PHASE_AMPLITUDE_MODE === 'ppm-din' ? 'ppm-din' : 'bandpass';
}
function getRingDbValues(CONFIG) {
return getPhaseAmplitudeMode(CONFIG) === 'ppm-din' ? RING_PPM_DIN : RING_DBFS;
}
function formatAmplitudeLabel(db, mode) {
const num = Number(db);
if (!Number.isFinite(num)) return '';
if (mode === 'ppm-din') return num > 0 ? `+${num}` : `${num}`;
return `${num} dB`;
}
function dbToRadiusNorm(db, ringDbValues) {
if (!Array.isArray(ringDbValues) || !ringDbValues.length) return 0;
const n = ringDbValues.length;
const maxDb = ringDbValues[0];
const minDb = ringDbValues[n - 1];
if (!Number.isFinite(db)) return ringFraction(n - 1, n);
if (db >= maxDb) return ringFraction(0, n);
if (db <= minDb) return ringFraction(n - 1, n);
for (let i = 0; i < n - 1; i++) {
const hiDb = ringDbValues[i];
const loDb = ringDbValues[i + 1];
if (db <= hiDb && db >= loDb) {
const span = Math.max(1e-10, hiDb - loDb);
const t = (db - loDb) / span;
const hiFrac = ringFraction(i, n);
const loFrac = ringFraction(i + 1, n);
return loFrac + (hiFrac - loFrac) * t;
}
}
return ringFraction(n - 1, n);
}
function linearToRadiusNorm(amp, ringDbValues) {
const ampDb = linearToDb(Math.max(1e-10, Math.min(1, amp)));
return dbToRadiusNorm(ampDb, ringDbValues);
}
function mapPpmRawToDin(raw, cfg) {
const minDb = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : -50;
const maxDb = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : +5;
if (!Number.isFinite(raw)) return minDb;
const base = Number.isFinite(cfg?.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? cfg.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15;
const effOff = (cfg?.PPM_DIN_MODE === 'al_minus6' ? -6 : -9) + (Number(cfg?.PPM_DIN_TRIM_DB) || 0);
const mapped = (raw - base) + effOff;
return Math.max(minDb, Math.min(maxDb, mapped));
}
function computePpmDinRadiusNorm(audio, cfg, ringDbValues) {
const rawL = Number.isFinite(audio?.ppmDinL) ? audio.ppmDinL : audio?.ppmL;
const rawR = Number.isFinite(audio?.ppmDinR) ? audio.ppmDinR : audio?.ppmR;
const l = mapPpmRawToDin(rawL, cfg);
const r = mapPpmRawToDin(rawR, cfg);
const db = Math.max(l, r);
return dbToRadiusNorm(db, ringDbValues);
}
function createBandpassState() {
return {
sampleRate: 0,
hpAlpha: 0,
lpAlpha: 0,
channels: {
L: { hpX: 0, hpY: 0, lpY: 0 },
R: { hpX: 0, hpY: 0, lpY: 0 },
},
};
}
function ensureBandpassCoeffs(state, sampleRate) {
if (!state.bandpass) state.bandpass = createBandpassState();
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
if (state.bandpass.sampleRate === sr) return;
state.bandpass.sampleRate = sr;
state.bandpass.hpAlpha = computeHighpassAlpha(sr, PHASE_BANDPASS_LOW_HZ);
state.bandpass.lpAlpha = computeLowpassAlpha(sr, PHASE_BANDPASS_HIGH_HZ);
}
function computeHighpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, rc / (rc + dt)));
}
function computeLowpassAlpha(sampleRate, cutoff) {
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
const dt = 1 / Math.max(1, sampleRate);
return Math.max(0, Math.min(1, dt / (rc + dt)));
}
function ensureFilteredBuffers(state, length) {
const neededLength = Math.ceil(length * 1.1); // 10% Puffer für Stabilität
if (!state.filteredL || state.filteredL.length < neededLength) {
state.filteredL = new Float32Array(neededLength);
}
if (!state.filteredR || state.filteredR.length < neededLength) {
state.filteredR = new Float32Array(neededLength);
}
return { L: state.filteredL, R: state.filteredR };
}
function applyBandpassSample(sample, channelState, bandpassState) {
const hpAlpha = bandpassState.hpAlpha;
const lpAlpha = bandpassState.lpAlpha;
if (!Number.isFinite(sample)) sample = 0;
const hpY = hpAlpha * (channelState.hpY + sample - channelState.hpX);
channelState.hpY = Number.isFinite(hpY) ? hpY : 0;
channelState.hpX = sample;
const lpY = lpAlpha * hpY + (1 - lpAlpha) * channelState.lpY;
channelState.lpY = Number.isFinite(lpY) ? lpY : 0;
return lpY;
}
function preparePhaseFilteredBuffers(state, xyData) {
ensureBandpassCoeffs(state, xyData.sampleRate || 48000);
const filtered = ensureFilteredBuffers(state, xyData.length);
// Reset channel states if they contain NaN/Infinity
if (!Number.isFinite(state.bandpass.channels.L.hpY)) {
state.bandpass.channels.L = { hpX: 0, hpY: 0, lpY: 0 };
}
if (!Number.isFinite(state.bandpass.channels.R.hpY)) {
state.bandpass.channels.R = { hpX: 0, hpY: 0, lpY: 0 };
}
for (let i = 0; i < xyData.length; i++) {
filtered.L[i] = applyBandpassSample(
xyData.xyL[i],
state.bandpass.channels.L,
state.bandpass
);
filtered.R[i] = applyBandpassSample(
xyData.xyR[i],
state.bandpass.channels.R,
state.bandpass
);
}
return filtered;
}
function decayPhasePointer(state) {
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;
const decayedRadius = prevRadius * PHASE_IDLE_DECAY;
state.currentPhase = prevPhase;
state.currentRadius = decayedRadius;
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
state.smoothPhase = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5);
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA);
}
function resolvePhaseGain(state, xyData, CONFIG) {
const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
if (allowAgc && xyData?.ready) {
const auto = computePhaseAgcGain(state, xyData);
return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN };
}
state.phaseAgcGainDb = gainDb;
return { gainDb, gain: dbToLinear(gainDb) };
}
function wrapAngle(rad) {
if (!Number.isFinite(rad)) return 0;
// Verbesserte Winkel-Normalisierung mit besserer numerischer Stabilität
const TWO_PI = Math.PI * 2;
const normalized = ((rad % TWO_PI) + TWO_PI) % TWO_PI;
// Sicherstellen, dass der Winkel im Bereich [-π, π] liegt
return normalized > Math.PI ? normalized - TWO_PI : normalized;
}
function smoothAngle(prev, next, alpha) {
if (!Number.isFinite(prev)) return next;
if (!Number.isFinite(next)) return prev;
const diff = wrapAngle(next - prev);
return wrapAngle(prev + diff * Math.max(0, Math.min(1, alpha)));
}
function lerp(a, b, t) {
if (!Number.isFinite(a)) return b;
if (!Number.isFinite(b)) return a;
const clampedT = Math.max(0, Math.min(1, t));
return a + (b - a) * clampedT;
}
function hilbertAt(buffer, idx) {
if (!buffer || idx < 0 || idx >= buffer.length) return 0;
let acc = 0;
for (let k = 0; k < HILBERT_KERNEL.length; k++) {
const src = idx + k - HILBERT_HALF;
if (src < 0 || src >= buffer.length) continue;
const sample = buffer[src];
const kernel = HILBERT_KERNEL[k];
if (Number.isFinite(sample) && Number.isFinite(kernel)) {
acc += sample * kernel;
}
}
return Number.isFinite(acc) ? acc : 0;
}
function buildHilbertKernel(size = 33) {
const taps = size % 2 === 0 ? size + 1 : size;
const mid = (taps - 1) / 2;
const kernel = new Float32Array(taps);
for (let n = 0; n < taps; n++) {
const k = n - mid;
// Verbesserte numerische Stabilität + even k = 0 wie idealer Hilbert-Kernel
if (Math.abs(k) < 1e-10 || k % 2 === 0) {
kernel[n] = 0;
continue;
}
const window = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / Math.max(1, taps - 1));
const value = (2 / (Math.PI * k)) * window;
// Sicherstellen, dass der Wert finite ist
kernel[n] = Number.isFinite(value) ? value : 0;
}
return kernel;
}
function computePhaseAgcGain(state, xyData) {
const now = getNow();
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
state.phaseAgcLastTs = now;
const peak = measurePhasePeak(xyData);
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
if (peak >= env) {
const alpha = 1 - Math.exp(-Math.max(dt, 0) / Math.max(0.001, PHASE_AGC_ATTACK_S));
env = env + (peak - env) * Math.min(1, alpha || 1);
} else {
const releaseFactor = Math.pow(10, -PHASE_AGC_RELEASE_DB_PER_S * Math.max(dt, 0) / 20);
env = Math.max(peak, env * releaseFactor);
}
env = Math.max(1e-8, env); // Verbesserter minimaler Wert
state.phaseAgcEnv = env;
const envDb = linearToDb(env);
let gainDb = PHASE_AGC_TARGET_DB - envDb;
if (gainDb > PHASE_GAIN_MAX_DB) gainDb = PHASE_GAIN_MAX_DB;
if (gainDb < PHASE_GAIN_MIN_DB) gainDb = PHASE_GAIN_MIN_DB;
state.phaseAgcGainDb = gainDb;
return { gainDb, gain: dbToLinear(gainDb) };
}
function measurePhasePeak(xyData) {
if (!xyData || !xyData.ready) return 1e-4; // Verbesserter Default-Wert
let peak = 1e-8; // Höhere Präzision
const len = Math.min(xyData.length, 1000); // Begrenzung für Performance
for (let i = 0; i < len; i++) {
const sample = Math.max(
Math.abs(xyData.xyL[i] || 0),
Math.abs(xyData.xyR[i] || 0)
);
if (sample > peak) peak = sample;
}
return Math.max(1e-8, peak); // Sicherstellen, dass nicht 0 zurückgegeben wird
}
function clampPhaseGain(db) {
let val = Number(db);
if (!Number.isFinite(val)) val = 0;
if (val < PHASE_GAIN_MIN_DB) val = PHASE_GAIN_MIN_DB;
if (val > PHASE_GAIN_MAX_DB) val = PHASE_GAIN_MAX_DB;
return val;
}
function getNow() {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
function linearToDb(value) {
if (value <= 1e-10) return -200; // Explizite Behandlung sehr kleiner Werte
const result = 20 * Math.log10(value);
return Number.isFinite(result) ? result : -200;
}
function dbToLinear(db) {
if (!Number.isFinite(db)) return 0;
if (db < -200) return 0;
const result = Math.pow(10, db / 20);
return Number.isFinite(result) ? result : 0;
}
+588
View File
@@ -0,0 +1,588 @@
// views/quad_view.js — Quad-View: vier Plots (2x2) + optionales Meter-Panel (wie Split-View)
import * as viewGoni from './goniometer_rtw.js';
import * as viewPhaseWheel from './phase_wheel.js';
import * as viewPanel from './panel.js';
import * as viewRealtime from './realtime.js';
import * as viewClassicNeedles from './classic_needles.js';
import * as viewPeakHistory from './peak_history.js';
import * as viewClock from './clock.js';
import * as viewWaveform from './waveform.js';
import * as viewSpectrogram from './spectrogram.js';
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
export const id = 'quad-view';
const CONTENT_TOP = DEFAULT_TOP_INSET;
const CONTENT_BOTTOM = 0;
const CHILD_VIEWS = {
'realtime': viewRealtime,
'classic-needles': viewClassicNeedles,
'peak-history': viewPeakHistory,
'goniometer-rtw': viewGoni,
'phase-wheel': viewPhaseWheel,
'panel': viewPanel,
'clock': viewClock,
'waveform': viewWaveform,
'spectrogram': viewSpectrogram,
};
const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
const ALLOWED_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']);
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
const OUTER_GAP = 10;
const SIDE_INNER_GAP = 8;
const ROW_GAP = 10;
const METER_GAP = 12;
const METER_W_DEFAULT = 140;
const METER_W_MIN = 90;
const MIN_PLOT_W = 240;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
function sanitizeChildId(val, fallback) {
return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback;
}
function sanitizePlotId(val, fallback) {
return ALLOWED_PLOT_IDS.has(val) ? val : fallback;
}
function sanitizeMeterId(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_IDS.has(id) ? id : fallback;
}
function sanitizeMeterPos(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
}
function clampMeterCount(val) {
const n = Number(val);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(3, n | 0));
}
async function withClippedSubRect(g, rect, fn) {
g.save();
g.translate(rect.x, rect.y);
g.beginPath();
g.rect(0, 0, rect.w, rect.h);
g.clip();
try { return await fn(); } finally { g.restore(); }
}
function drawSubframe(g, rect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
g.restore();
}
function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx, rect);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
function drawEmptyPlot(state, g, rect, label) {
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
lg.fillStyle = '#9aa';
lg.textAlign = 'left';
lg.font = 'bold 14px ui-monospace, monospace';
lg.fillText(label || '(leer)', 12, 32);
lg.textAlign = 'start';
});
}
function readQuadMeters(CONFIG) {
const count = clampMeterCount(CONFIG?.QUAD_VIEW_METER_COUNT);
const slots = [
{
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_1, 'vu'),
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_1_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_2, 'ppm-din'),
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_2_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_3, 'lufs'),
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_3_POS, 'right'),
},
].slice(0, count);
return slots;
}
function groupMetersByPosition(slots) {
const out = { left: [], center: [], right: [] };
for (const s of slots || []) {
if (!s) continue;
if (s.pos === 'left') out.left.push(s.id);
else if (s.pos === 'center') out.center.push(s.id);
else out.right.push(s.id);
}
return out;
}
function computeBaseLayout(rect, slots) {
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
const hasContent = contentH >= 140;
const BLOCK_GAP = SIDE_INNER_GAP;
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
while (true) {
const grouped = groupMetersByPosition(effectiveSlots);
const leftCount = grouped.left.length;
const centerCount = grouped.center.length;
const rightCount = grouped.right.length;
const totalSlots = leftCount + centerCount + rightCount;
const hasLeftMeters = leftCount > 0;
const hasCenterMeters = centerCount > 0;
const hasRightMeters = rightCount > 0;
const interBlockGaps =
(hasLeftMeters ? BLOCK_GAP : 0) +
(hasRightMeters ? BLOCK_GAP : 0) +
(hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP);
const internalGaps =
Math.max(0, leftCount - 1) * METER_GAP +
Math.max(0, centerCount - 1) * METER_GAP +
Math.max(0, rightCount - 1) * METER_GAP;
const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps;
const remainingForMeters = rect.w - minRequired;
let slotW = 0;
if (totalSlots > 0) {
slotW = Math.floor(remainingForMeters / totalSlots);
slotW = Math.min(METER_W_DEFAULT, slotW);
}
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0;
const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0;
const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0;
const metersTotalW = leftW + centerW + rightW;
const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW);
const leftPlotW = Math.floor(plotAvail / 2);
const rightPlotW = plotAvail - leftPlotW;
const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W;
if (!plotFits && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
let x = 0;
const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null;
if (leftMetersRect) x += leftW + BLOCK_GAP;
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
x += leftPlotW;
let centerMetersRect = null;
if (hasCenterMeters) {
x += BLOCK_GAP;
centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH };
x += centerW + BLOCK_GAP;
} else {
x += OUTER_GAP;
}
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
x += rightPlotW;
let rightMetersRect = null;
if (hasRightMeters) {
x += BLOCK_GAP;
rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH };
}
return {
plots: { left: leftPlotRect, right: rightPlotRect },
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
meterIds: grouped,
slotW,
effectiveSlots,
};
}
}
function splitRectToRows(rect) {
const w = Math.max(0, rect?.w || 0);
const h = Math.max(0, rect?.h || 0);
const gap = Math.max(0, ROW_GAP);
const topH = Math.max(0, Math.floor((h - gap) / 2));
const bottomH = Math.max(0, h - gap - topH);
return {
top: { x: rect.x, y: rect.y, w, h: topH },
bottom: { x: rect.x, y: rect.y + topH + gap, w, h: bottomH },
};
}
function unionRectHoriz(a, b) {
if (!a && !b) return null;
if (!a) return { ...b };
if (!b) return { ...a };
const left = Math.min(a.x, b.x);
const right = Math.max(a.x + a.w, b.x + b.w);
const top = Math.min(a.y, b.y);
const bottom = Math.max(a.y + a.h, b.y + b.h);
return { x: left, y: top, w: Math.max(0, right - left), h: Math.max(0, bottom - top) };
}
function ensureChildRectSize(env, rect, child) {
if (!child || child.id === 'none' || !rect) return;
const sig = `${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
if (child._lastSizeSig === sig) return;
child._lastSizeSig = sig;
resizeChild(env, { x: 0, y: 0, w: rect.w, h: rect.h }, child);
}
async function drawMetersPanel(env, state, rect, meterIds, slotW) {
const { ctx: g, meters, config: CONFIG } = env;
if (!rect || rect.w <= 0 || rect.h <= 0) return;
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
const count = ids.length;
if (!count) return;
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
});
const gap = METER_GAP;
const n = Math.max(1, Math.min(3, count));
const usedW = n * slotW + (n - 1) * gap;
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const slotY = rect.y + METER_PAD_TOP + innerOffset;
const slotH = innerHeight;
for (let i = 0; i < n; i++) {
const id = sanitizeMeterId(ids[i], 'none');
const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH };
if (id === 'none') {
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => {
lg.strokeStyle = 'rgba(0,231,255,0.25)';
lg.setLineDash([6, 5]);
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
lg.setLineDash([]);
lg.fillStyle = '#9aa';
lg.textAlign = 'center';
lg.font = '12px ui-monospace, monospace';
lg.fillText('(leer)', r.w / 2, 22);
lg.textAlign = 'start';
});
} else {
try {
g.save();
g.beginPath();
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
g.clip();
await meters.draw(g, r, id, CONFIG);
g.restore();
} catch (e) {
g.restore();
console.warn('Quad meter draw error:', e);
}
}
}
}
function destroyChild(child) {
if (!child || child.id === 'none') return;
const mod = CHILD_VIEWS[child.id];
if (mod && typeof mod.destroy === 'function') {
try { mod.destroy(child.state); } catch (e) { console.warn('Quad child destroy error:', e); }
}
}
function initChild(env, childId) {
const id = sanitizeChildId(childId, 'none');
if (id === 'none') return { id: 'none', state: {} };
const mod = CHILD_VIEWS[id];
const state = (mod && typeof mod.init === 'function') ? (mod.init(env) || {}) : {};
return { id, state };
}
function resizeChild(env, rect, child) {
if (!child || child.id === 'none') return;
const mod = CHILD_VIEWS[child.id];
if (!mod || typeof mod.resize !== 'function') return;
try { mod.resize({ rect }, child.state); } catch (e) { console.warn('Quad child resize error:', e); }
}
async function renderChild(env, rect, child, opts = {}) {
const { ctx: g } = env;
if (!child || child.id === 'none') return;
const mod = CHILD_VIEWS[child.id];
if (!mod || typeof mod.render !== 'function') return;
const topInset = Number.isFinite(Number(opts.topInset)) ? Number(opts.topInset) : null;
await withClippedSubRect(g, rect, async () => {
const plotOnlySlots = (viewId) => {
if (viewId === 'peak-history' || viewId === 'classic-needles' || viewId === 'panel') {
const configured = env?.slots?.(viewId);
if (Array.isArray(configured) && configured.length) return configured;
}
if (viewId === 'peak-history') return ['ppm-din'];
if (viewId === 'classic-needles') return ['vu'];
if (viewId === 'panel') return ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms'];
return ['none'];
};
const subEnv = Object.assign({}, env, {
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
embedded: true,
containerView: 'quad-view',
slots: plotOnlySlots,
embeddedOffsetX: rect.x,
embeddedOffsetY: rect.y,
});
if (topInset !== null) subEnv.topInset = topInset;
await mod.render(subEnv, child.state);
});
}
export function init(env) {
const tlId = sanitizePlotId(env?.config?.QUAD_VIEW_TL, 'phase-wheel');
const trId = sanitizePlotId(env?.config?.QUAD_VIEW_TR, 'realtime');
const blId = sanitizePlotId(env?.config?.QUAD_VIEW_BL, 'goniometer-rtw');
const brId = sanitizePlotId(env?.config?.QUAD_VIEW_BR, 'none');
const state = {
staticLayers: new Map(),
tl: initChild(env, tlId),
tr: initChild(env, trId),
bl: initChild(env, blId),
br: initChild(env, brId),
popup: initChild(env, 'none'),
lastTlId: tlId,
lastTrId: trId,
lastBlId: blId,
lastBrId: brId,
lastPopupId: 'none',
lastRectSig: '',
};
return state;
}
export function destroy(state) {
if (state) state.staticLayers = null;
destroyChild(state?.tl);
destroyChild(state?.tr);
destroyChild(state?.bl);
destroyChild(state?.br);
destroyChild(state?.popup);
}
export function resize({ rect }, state) {
if (!state || !rect) return;
const sig = `${rect.w}x${rect.h}`;
if (state.lastRectSig === sig) return;
state.lastRectSig = sig;
const w = Math.max(0, rect.w);
const h = Math.max(0, rect.h);
const contentY = CONTENT_TOP;
const contentH = Math.max(0, h - CONTENT_TOP - CONTENT_BOTTOM);
const gap = OUTER_GAP;
const half = Math.floor((w - gap) / 2);
const leftCol = { x: 0, y: contentY, w: Math.max(0, half), h: contentH };
const rightCol = { x: Math.max(0, half + gap), y: contentY, w: Math.max(0, w - (half + gap)), h: contentH };
const leftRows = splitRectToRows(leftCol);
const rightRows = splitRectToRows(rightCol);
resizeChild(null, { x: 0, y: 0, w: leftRows.top.w, h: leftRows.top.h }, state.tl);
resizeChild(null, { x: 0, y: 0, w: rightRows.top.w, h: rightRows.top.h }, state.tr);
resizeChild(null, { x: 0, y: 0, w: leftRows.bottom.w, h: leftRows.bottom.h }, state.bl);
resizeChild(null, { x: 0, y: 0, w: rightRows.bottom.w, h: rightRows.bottom.h }, state.br);
resizeChild(null, { x: 0, y: 0, w, h }, state.popup);
}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG } = env;
const contentY = CONTENT_TOP;
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
const popupCfg = env?.quadPopup;
const popupWanted = popupCfg && popupCfg.open ? sanitizeChildId(popupCfg.viewId, 'none') : 'none';
// Popup-Modus: rendere die View in Originalgröße (vollflächig),
// ohne Quad-Hintergrund/Layout. So sieht es exakt wie die Einzel-View aus.
if (popupWanted !== 'none' && state) {
if (state.lastPopupId !== popupWanted) {
destroyChild(state.popup);
state.popup = initChild(env, popupWanted);
state.lastPopupId = popupWanted;
resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup);
}
state._quadHit = {
contentY: 0,
quadrants: [],
meters: [],
popupBox: { x: 0, y: 0, w: rect.w, h: rect.h },
};
const mod = CHILD_VIEWS[state.popup.id];
if (mod && typeof mod.render === 'function') {
const subEnv = Object.assign({}, env, {
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
embedded: false,
});
await mod.render(subEnv, state.popup.state);
}
return;
}
if (state && state.lastPopupId !== 'none') {
destroyChild(state.popup);
state.popup = initChild(env, 'none');
state.lastPopupId = 'none';
}
const desiredTlId = sanitizePlotId(CONFIG?.QUAD_VIEW_TL, 'phase-wheel');
const desiredTrId = sanitizePlotId(CONFIG?.QUAD_VIEW_TR, 'realtime');
const desiredBlId = sanitizePlotId(CONFIG?.QUAD_VIEW_BL, 'goniometer-rtw');
const desiredBrId = sanitizePlotId(CONFIG?.QUAD_VIEW_BR, 'none');
if (state.lastTlId !== desiredTlId) {
destroyChild(state.tl);
state.tl = initChild(env, desiredTlId);
state.lastTlId = desiredTlId;
}
if (state.lastTrId !== desiredTrId) {
destroyChild(state.tr);
state.tr = initChild(env, desiredTrId);
state.lastTrId = desiredTrId;
}
if (state.lastBlId !== desiredBlId) {
destroyChild(state.bl);
state.bl = initChild(env, desiredBlId);
state.lastBlId = desiredBlId;
}
if (state.lastBrId !== desiredBrId) {
destroyChild(state.br);
state.br = initChild(env, desiredBrId);
state.lastBrId = desiredBrId;
}
const slots = readQuadMeters(CONFIG);
const layout = computeBaseLayout(rect, slots);
const leftColRaw = layout?.plots?.left || { x: 0, y: 0, w: Math.floor(rect.w / 2), h: rect.h };
const rightColRaw = layout?.plots?.right || { x: Math.floor(rect.w / 2), y: 0, w: rect.w - Math.floor(rect.w / 2), h: rect.h };
const leftCol = { x: leftColRaw.x, y: contentY, w: leftColRaw.w, h: contentH };
const rightCol = { x: rightColRaw.x, y: contentY, w: rightColRaw.w, h: contentH };
const leftRows = splitRectToRows(leftCol);
const rightRows = splitRectToRows(rightCol);
const canRowMerge = !layout?.meters?.center;
const topLeftActive = desiredTlId !== 'none';
const topRightActive = desiredTrId !== 'none';
const bottomLeftActive = desiredBlId !== 'none';
const bottomRightActive = desiredBrId !== 'none';
let tlRect = { ...leftRows.top };
let trRect = { ...rightRows.top };
let blRect = { ...leftRows.bottom };
let brRect = { ...rightRows.bottom };
if (canRowMerge) {
if (topLeftActive && !topRightActive) {
tlRect = unionRectHoriz(tlRect, trRect);
trRect = null;
} else if (!topLeftActive && topRightActive) {
trRect = unionRectHoriz(tlRect, trRect);
tlRect = null;
}
if (bottomLeftActive && !bottomRightActive) {
blRect = unionRectHoriz(blRect, brRect);
brRect = null;
} else if (!bottomLeftActive && bottomRightActive) {
brRect = unionRectHoriz(blRect, brRect);
blRect = null;
}
}
if (state) {
state._quadHit = {
contentY,
quadrants: [
{ key: 'tl', rect: tlRect, viewId: desiredTlId },
{ key: 'tr', rect: trRect, viewId: desiredTrId },
{ key: 'bl', rect: blRect, viewId: desiredBlId },
{ key: 'br', rect: brRect, viewId: desiredBrId },
],
meters: [layout?.meters?.left, layout?.meters?.center, layout?.meters?.right].filter(Boolean).map((r) => ({ ...r })),
popupBox: null,
};
}
if (tlRect) {
if (state.tl?.id === 'none') drawEmptyPlot(state, g, tlRect, 'OL: (leer)');
else {
ensureChildRectSize(null, tlRect, state.tl);
await renderChild(env, tlRect, state.tl, { topInset: 0 });
}
}
if (trRect) {
if (state.tr?.id === 'none') drawEmptyPlot(state, g, trRect, 'OR: (leer)');
else {
ensureChildRectSize(null, trRect, state.tr);
await renderChild(env, trRect, state.tr, { topInset: 0 });
}
}
if (blRect) {
if (state.bl?.id === 'none') drawEmptyPlot(state, g, blRect, 'UL: (leer)');
else {
ensureChildRectSize(null, blRect, state.bl);
await renderChild(env, blRect, state.bl, { topInset: 0 });
}
}
if (brRect) {
if (state.br?.id === 'none') drawEmptyPlot(state, g, brRect, 'UR: (leer)');
else {
ensureChildRectSize(null, brRect, state.br);
await renderChild(env, brRect, state.br, { topInset: 0 });
}
}
if (layout?.meters?.left) await drawMetersPanel(env, state, layout.meters.left, layout.meterIds.left, layout.slotW);
if (layout?.meters?.center) await drawMetersPanel(env, state, layout.meters.center, layout.meterIds.center, layout.slotW);
if (layout?.meters?.right) await drawMetersPanel(env, state, layout.meters.right, layout.meterIds.right, layout.slotW);
}
+941
View File
@@ -0,0 +1,941 @@
import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js';
import { DEFAULT_TOP_INSET, FRAME_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
// views/realtime.js — IEC-konformer Real-Time Analyzer
const METER_WIDTH = 140;
const METER_GAP = 8;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24; // reduziert nutzbare Höhe leicht, damit Slot niedriger wirkt
const METER_EXTRA_BOTTOM_PAD = 5;
const FLOOR_DB = -120;
const RTA_X_TICKS = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const DEFAULT_RTA_BAR_BASE_COLOR = MID_COLOR;
const RTA_PEAK_COLOR = WARN_COLOR;
const EMBEDDED_RTA_HIDE_20HZ_WIDTH = 420;
const STATIC_LABEL_PAD_LEFT = 40;
const STATIC_LABEL_PAD_BOTTOM = 28;
const STATIC_STROKE_PAD = 2;
const PEAK_HOLD_MAP = new Map(Object.entries({
off: 0,
auto: null,
'1s': 1,
'2s': 2,
'4s': 4,
'10s': 10,
'20s': 20,
'30s': 30,
manual: Infinity,
}));
const INTEGRATION_TAU = {
impulse: 0.035,
fast: 0.125,
slow: 1.0,
peak: 0.01,
};
export const id = 'realtime';
export function init() {
return {
bandKey: null,
bands: [],
mapping: [],
ewma: new Float32Array(0),
peakHold: new Float32Array(0),
lastPeakTime: [],
displayLevels: new Float32Array(0),
levelBuffer: new Float32Array(0),
rtBarLevels: new Float32Array(0),
rtBarPeakTimes: [],
lastRtBarTs: 0,
lastDisplayUpdate: 0,
holdSampleTs: 0,
lastIntegrationTs: 0,
configSig: '',
staticLayers: new Map(),
};
}
export function resize() {}
export function destroy(state) {
if (state) state.staticLayers = null;
}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
const nyq = audio.nyq || 24000;
const analyser = audio.getAnalyser?.();
const buf = audio.getFreqBuffer?.();
const useIirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir';
const rtaData = typeof audio.getRtaData === 'function'
? audio.getRtaData()
: null;
const useNativeFftEngine = !useIirEngine && rtaData && rtaData.engine === 'fft';
const nativeRtaPacket = (useIirEngine || useNativeFftEngine) ? rtaData : null;
const displayRtaPacket = (nativeRtaPacket && CONFIG.RTA_BAR_LAYOUT === 'rtw')
? selectLocalRtwPacket(nativeRtaPacket, CONFIG.RTA_BPO_MODE || '1_6')
: nativeRtaPacket;
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : DEFAULT_TOP_INSET;
const PLOT = { left: 43, top: topInset, right: 0, bottom: 18 };
const plotX = PLOT.left;
const plotY = PLOT.top;
const slotList = env.slots ? env.slots('realtime') : null;
const activeMeter = (slotList && slotList[0]) || 'vu';
const showMeter = activeMeter !== 'none';
const plotW = Math.max(0, (rect.w - PLOT.right) - (showMeter ? (METER_WIDTH + METER_GAP) : 0) - plotX);
const plotH = rect.h - PLOT.top - PLOT.bottom;
const range = getDbRange();
const freqBounds = getFreqBounds(CONFIG, nyq, displayRtaPacket);
const hide20HzTick = shouldHide20HzTick(env, plotW);
const freqTicks = RTA_X_TICKS.filter((f) => {
if (hide20HzTick && f === 20) return false;
return f >= freqBounds.min && f <= freqBounds.max * 1.05;
});
const gridConfig = Object.assign({}, CONFIG, {
DBFS_TOP: range.top,
DBFS_BOTTOM: range.bottom,
Y_TICKS: range.ticks,
});
const alignBase = Number.isFinite(CONFIG.DIGITAL_REF_DBFS_RMS)
? CONFIG.DIGITAL_REF_DBFS_RMS
: -18;
const gainOffset = useIirEngine
? Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB || 0)
: 0;
const showAlignMarker = CONFIG.AL_MARKERS_ENABLED !== false;
const refDb = showAlignMarker ? alignBase + gainOffset : null;
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
: 14;
const plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT;
const plotPadBottom = STATIC_LABEL_PAD_BOTTOM;
drawCachedStaticLayer(
state,
g,
'rta-plot',
[
plotW,
plotH,
range.top,
range.bottom,
freqBounds.min,
freqBounds.max,
freqTicks.join(','),
refDb ?? 'none',
hide20HzTick ? 1 : 0,
plotPadLeft,
plotPadBottom,
].join('|'),
{ x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom },
(lg) => {
utils.drawDbfsGridRect(
lg,
plotPadLeft,
0,
plotW,
plotH,
gridConfig,
freqBounds.max,
{
freqTicks,
freqMin: freqBounds.min,
refDb,
refStyle: 'rgba(0,231,255,0.35)',
refDash: [3, 4],
},
);
redrawPlotEdges(lg, plotPadLeft, 0, plotW, plotH, CONFIG);
},
);
const configSig = buildConfigSignature(CONFIG, nyq, buf?.length || 0);
if (state.configSig !== configSig) {
resetState(state);
state.configSig = configSig;
}
if (!audio.alive) {
drawWaiting(g, plotX, plotY);
} else {
const binCount = buf?.length || analyser?.frequencyBinCount || 2048;
ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, displayRtaPacket);
if (!state.mapping.length) {
drawWaiting(g, plotX, plotY);
} else if (useIirEngine) {
const ballisticsData = mapIirLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
const baseLevels = ballisticsData?.primary;
if (!baseLevels || !baseLevels.length) {
drawWaiting(g, plotX, plotY);
} else {
const integrated = baseLevels;
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
: displayBase;
applyPeakHold(state, integrated, CONFIG, range);
state.displayLevels = display;
state.currentRange = range;
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
renderSpectrum(
g,
state,
display,
plotX,
plotY,
plotW,
plotH,
CONFIG,
range,
freqBounds,
ballisticsData?.overlay || null,
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
);
}
} else if (useNativeFftEngine) {
const nativeLevels = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
if (!nativeLevels || !nativeLevels.length) {
drawWaiting(g, plotX, plotY);
} else {
const integrated = applyIntegration(state, nativeLevels, CONFIG, range);
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
: displayBase;
applyPeakHold(state, integrated, CONFIG, range);
state.displayLevels = display;
state.currentRange = range;
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
renderSpectrum(
g,
state,
display,
plotX,
plotY,
plotW,
plotH,
CONFIG,
range,
freqBounds,
null,
CONFIG.RTA_BALLISTICS_MODE || 'average'
);
}
} else if (!analyser || !buf) {
drawWaiting(g, plotX, plotY);
} else {
analyser.getFloatFrequencyData(buf);
const levels = computeBandLevels(state, utils, buf, CONFIG, range);
const integrated = applyIntegration(state, levels, CONFIG, range);
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
: displayBase;
applyPeakHold(state, integrated, CONFIG, range);
state.displayLevels = display;
state.currentRange = range;
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
renderSpectrum(
g,
state,
display,
plotX,
plotY,
plotW,
plotH,
CONFIG,
range,
freqBounds,
null,
CONFIG.RTA_BALLISTICS_MODE || 'average'
);
}
}
if (showMeter) {
await drawMeter(env, state, plotX, plotY, plotW, plotH);
}
}
function drawWaiting(g, plotX, plotY) {
g.fillStyle = '#9aa';
const y = plotY >= 40 ? (plotY - 26) : (plotY + 22);
g.fillText('Warte auf Audio …', plotX + 10, y);
}
function shouldHide20HzTick(env, plotW) {
const containerView = env?.containerView;
const embeddedInMultiView = env?.embedded === true
&& (containerView === 'split-view' || containerView === 'quad-view');
return embeddedInMultiView && plotW < EMBEDDED_RTA_HIDE_20HZ_WIDTH;
}
function buildConfigSignature(CONFIG, nyq, binCount) {
return [
CONFIG.RTA_FREQ_RANGE,
CONFIG.RTA_BPO_MODE,
CONFIG.RTA_WEIGHTING,
CONFIG.RTA_BAR_LAYOUT,
CONFIG.RTA_INTEGRATION,
CONFIG.RTA_PEAK_HOLD_MODE,
CONFIG.RTA_PEAK_HOLD_SEC,
CONFIG.RTA_PEAK_DECAY_DB_PER_S,
CONFIG.RTA_DISPLAY_HOLD_SEC,
CONFIG.REALTIME_RENDER_STYLE,
CONFIG.REALTIME_BAR_HOLD_MS,
CONFIG.REALTIME_BAR_DECAY_DB_PER_S,
CONFIG.RTA_ENGINE,
CONFIG.RTA_IIR_ORDER,
CONFIG.RTA_IIR_TAU_FAST,
CONFIG.RTA_IIR_TAU_SLOW,
CONFIG.RTA_DISPLAY_GAIN_FFT_DB,
CONFIG.RTA_DISPLAY_GAIN_IIR_DB,
nyq,
binCount,
].join('|');
}
function resetState(state) {
state.bandKey = null;
state.bands = [];
state.mapping = [];
state.ewma = new Float32Array(0);
state.peakHold = new Float32Array(0);
state.lastPeakTime = [];
state.displayLevels = new Float32Array(0);
state.levelBuffer = new Float32Array(0);
state.rtBarLevels = new Float32Array(0);
state.rtBarPeakTimes = [];
state.lastRtBarTs = 0;
state.lastDisplayUpdate = 0;
state.holdSampleTs = 0;
state.lastIntegrationTs = 0;
}
function ensureBands(state, utils, CONFIG, nyq, binCount, freqBounds, range, rtaPacket) {
const freqRange = CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm';
const bpo = CONFIG.RTA_BPO_MODE || '1_6';
const engine = CONFIG.RTA_ENGINE || 'fft';
const layoutMode = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
const rtaCenters = (layoutMode === 'rtw') ? getRtwCenters(bpo) : null;
const centerKey = rtaCenters
? `${rtaCenters.length}:${rtaCenters[0]}:${rtaCenters[rtaCenters.length - 1]}`
: 'static';
const key = `${layoutMode}|${freqRange}|${bpo}|${nyq}|${binCount}|${engine}|${centerKey}`;
if (state.bandKey === key && state.mapping.length) return;
let bands;
if (layoutMode === 'rtw') {
bands = buildFixedRtwBands(bpo, freqBounds, nyq, rtaCenters);
if (!bands.length) {
bands = utils.makeFractionalOctaveBands(nyq, bpo, {
fMin: freqBounds.min,
fMax: freqBounds.max,
});
}
} else {
bands = utils.makeFractionalOctaveBands(nyq, bpo, {
fMin: freqBounds.min,
fMax: freqBounds.max,
});
}
const mapping = utils.buildBandBinMapping(bands, nyq, binCount);
state.bandKey = key;
state.bands = bands;
state.mapping = mapping;
const len = mapping.length;
state.ewma = new Float32Array(len);
state.ewma.fill(range.bottom);
state.peakHold = new Float32Array(len);
state.peakHold.fill(range.bottom);
state.lastPeakTime = new Array(len).fill(performance.now());
state.displayLevels = new Float32Array(len);
state.displayLevels.fill(range.bottom);
state.rtBarLevels = new Float32Array(len);
state.rtBarLevels.fill(range.bottom);
state.rtBarPeakTimes = new Array(len).fill(performance.now());
}
function computeBandLevels(state, utils, buf, CONFIG, range) {
const weighting = CONFIG.RTA_WEIGHTING || 'z';
const weightFn = (freq) => utils.weightingDb(freq, weighting);
const raw = utils.computeBandLevels(state.mapping, buf, {
floor: FLOOR_DB,
weightingFn: weightFn,
});
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
if (!state.levelBuffer || state.levelBuffer.length !== raw.length) {
state.levelBuffer = new Float32Array(raw.length);
}
const out = state.levelBuffer;
for (let i = 0; i < raw.length; i++) {
out[i] = clamp(raw[i] + gain, range.bottom, range.top);
}
return out;
}
function applyIntegration(state, levels, CONFIG, range) {
const mode = (CONFIG.RTA_INTEGRATION && INTEGRATION_TAU[CONFIG.RTA_INTEGRATION])
? CONFIG.RTA_INTEGRATION
: 'fast';
const tau = INTEGRATION_TAU[mode] || 0.125;
const now = performance.now();
const last = state.lastIntegrationTs || now;
const dt = Math.max(1 / 240, (now - last) / 1000);
state.lastIntegrationTs = now;
const alpha = (tau > 0 && dt > 0) ? 1 - Math.exp(-dt / tau) : 1;
if (!state.ewma || state.ewma.length !== levels.length) {
state.ewma = new Float32Array(levels.length);
state.ewma.fill(range.bottom);
}
const buffer = state.ewma;
for (let i = 0; i < levels.length; i++) {
const prev = buffer[i];
buffer[i] = prev + (levels[i] - prev) * alpha;
}
return buffer;
}
function applyPeakHold(state, levels, CONFIG, range) {
const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'auto';
const len = levels.length;
const mapped = PEAK_HOLD_MAP.get(mode);
const holdSec = Number.isFinite(mapped)
? Math.max(0, mapped)
: Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0);
const decayRate = Math.max(0, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 0);
const now = performance.now();
const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000);
state.holdSampleTs = now;
if (!state.peakHold || state.peakHold.length !== len) {
state.peakHold = new Float32Array(len);
state.lastPeakTime = new Array(len).fill(now);
}
const buffer = state.peakHold;
if (mode === 'off') {
for (let i = 0; i < len; i++) buffer[i] = clamp(levels[i], range.bottom, range.top);
state.lastPeakTime = new Array(len).fill(now);
return buffer;
}
for (let i = 0; i < len; i++) {
const current = clamp(levels[i], range.bottom, range.top);
if (current >= buffer[i] - 0.05) {
buffer[i] = current;
state.lastPeakTime[i] = now;
continue;
}
if (mapped === Infinity || mode === 'manual') continue;
let allowDecay = mode === 'off';
if (mode === 'auto') {
allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000;
}
if (allowDecay && decayRate > 0) {
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
}
buffer[i] = clamp(buffer[i], range.bottom, range.top);
}
return buffer;
}
function applyDisplayHold(state, levels, CONFIG, range) {
const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0);
const len = levels.length;
const now = performance.now();
if (!state.displayLevels || state.displayLevels.length !== len) {
state.displayLevels = new Float32Array(len);
state.lastDisplayUpdate = 0;
}
const buffer = state.displayLevels;
if (holdSec <= 0) {
for (let i = 0; i < len; i++) {
buffer[i] = clamp(levels[i], range.bottom, range.top);
}
state.lastDisplayUpdate = now;
return buffer;
}
const dt = (now - (state.lastDisplayUpdate || 0)) / 1000;
if (dt >= holdSec) {
for (let i = 0; i < len; i++) {
buffer[i] = clamp(levels[i], range.bottom, range.top);
}
state.lastDisplayUpdate = now;
return buffer;
}
for (let i = 0; i < len; i++) {
const incoming = clamp(levels[i], range.bottom, range.top);
buffer[i] = Math.max(buffer[i], incoming);
}
return buffer;
}
function applyRealtimeBarBallistics(state, levels, CONFIG, range) {
const holdMs = Math.max(0, Number(CONFIG.REALTIME_BAR_HOLD_MS) || 0);
const decayRate = Math.max(0, Number(CONFIG.REALTIME_BAR_DECAY_DB_PER_S) || 0);
const len = levels.length;
const now = performance.now();
const dt = Math.max(0, (now - (state.lastRtBarTs || now)) / 1000);
state.lastRtBarTs = now;
if (!state.rtBarLevels || state.rtBarLevels.length !== len) {
state.rtBarLevels = new Float32Array(len);
state.rtBarLevels.fill(range.bottom);
state.rtBarPeakTimes = new Array(len).fill(now);
}
const buffer = state.rtBarLevels;
if (!Array.isArray(state.rtBarPeakTimes) || state.rtBarPeakTimes.length !== len) {
state.rtBarPeakTimes = new Array(len).fill(now);
}
for (let i = 0; i < len; i++) {
const incoming = clamp(levels[i], range.bottom, range.top);
if (incoming >= buffer[i] - 0.05) {
buffer[i] = incoming;
state.rtBarPeakTimes[i] = now;
continue;
}
const heldLongEnough = (now - (state.rtBarPeakTimes[i] || 0)) >= holdMs;
if (!heldLongEnough) continue;
if (decayRate > 0) {
buffer[i] = Math.max(incoming, buffer[i] - decayRate * dt);
} else {
buffer[i] = incoming;
}
buffer[i] = clamp(buffer[i], range.bottom, range.top);
}
return buffer;
}
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode) {
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length)
? overlayPeaks
: null;
if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') {
renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
} else if (layout === 'rtw') {
renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlay);
} else {
renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
}
}
function renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks) {
const baseY = plotY + plotH;
const zeroY = mapDbToY(0, range, plotY, plotH);
const logMin = Math.log10(freqBounds.min);
const logSpan = Math.max(1e-6, Math.log10(freqBounds.max) - logMin);
const hasOverlay = isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length;
for (let i = 0; i < state.mapping.length; i++) {
const band = state.mapping[i];
const level = levels[i];
const xLeft = mapLogX(band.fLo, plotX, plotW, logMin, logSpan);
const xRight = mapLogX(band.fHi, plotX, plotW, logMin, logSpan);
const width = Math.max(2, xRight - xLeft - 1);
drawBar(g, xLeft, width, level, zeroY, baseY, plotY, plotH, range, CONFIG);
if (hasOverlay && Number.isFinite(overlayPeaks[i])) {
drawPeakOverlay(g, xLeft, width, overlayPeaks[i], plotY, plotH, range);
}
if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') {
drawHold(g, xLeft, width, state.peakHold[i], plotY, plotH, range);
}
}
}
function renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlayPeaks) {
const baseY = plotY + plotH;
const zeroY = mapDbToY(0, range, plotY, plotH);
const slotW = plotW / Math.max(1, state.mapping.length);
const hasOverlay = isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length;
for (let i = 0; i < state.mapping.length; i++) {
const level = levels[i];
const x = plotX + i * slotW + slotW * 0.15;
const width = Math.max(2, slotW * 0.7);
drawBar(g, x, width, level, zeroY, baseY, plotY, plotH, range, CONFIG);
if (hasOverlay && Number.isFinite(overlayPeaks[i])) {
drawPeakOverlay(g, x, width, overlayPeaks[i], plotY, plotH, range);
}
if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') {
drawHold(g, x, width, state.peakHold[i], plotY, plotH, range);
}
}
}
function renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks) {
const logMin = Math.log10(freqBounds.min);
const logSpan = Math.max(1e-6, Math.log10(freqBounds.max) - logMin);
g.save();
g.strokeStyle = '#36bdf8';
g.lineWidth = 1.6;
g.beginPath();
let moved = false;
for (let i = 0; i < state.mapping.length; i++) {
const band = state.mapping[i];
const x = mapLogX(band.center, plotX, plotW, logMin, logSpan);
const y = mapDbToY(levels[i], range, plotY, plotH);
if (!moved) {
g.moveTo(x, y);
moved = true;
} else {
g.lineTo(x, y);
}
}
g.stroke();
g.restore();
if (isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length) {
const markerWidth = Math.max(2, plotW / Math.max(40, state.mapping.length * 4));
for (let i = 0; i < state.mapping.length; i++) {
if (!Number.isFinite(overlayPeaks[i])) continue;
const band = state.mapping[i];
const x = mapLogX(band.center, plotX, plotW, logMin, logSpan) - markerWidth / 2;
drawPeakOverlay(g, x, markerWidth, overlayPeaks[i], plotY, plotH, range);
}
}
// Peak markers
const barWidth = Math.max(2, plotW / Math.max(10, state.mapping.length * 2));
if (CONFIG.RTA_PEAK_HOLD_MODE !== 'off') {
for (let i = 0; i < state.mapping.length; i++) {
const band = state.mapping[i];
const x = mapLogX(band.center, plotX, plotW, logMin, logSpan) - barWidth / 2;
drawHold(g, x, barWidth, state.peakHold[i], plotY, plotH, range);
}
}
}
function drawBar(g, x, width, level, zeroY, baseY, plotY, plotH, range, CONFIG) {
const baseColor = CONFIG?.RTA_BAR_BASE_COLOR || DEFAULT_RTA_BAR_BASE_COLOR;
const y = mapDbToY(level, range, plotY, plotH);
if (level <= 0) {
g.fillStyle = baseColor;
g.fillRect(x, y, width, Math.max(0, baseY - y));
} else {
g.fillStyle = baseColor;
g.fillRect(x, zeroY, width, Math.max(0, baseY - zeroY));
g.fillStyle = RTA_PEAK_COLOR;
g.fillRect(x, y, width, Math.max(0, zeroY - y));
}
g.globalAlpha = 0.12;
g.fillStyle = '#fff';
g.fillRect(x, y, width, 2);
g.globalAlpha = 1;
}
function drawHold(g, x, width, value, plotY, plotH, range) {
if (!Number.isFinite(value)) return;
if (value <= (range.bottom + 0.05)) return;
const yHold = mapDbToY(value, range, plotY, plotH);
g.fillStyle = '#fff';
g.fillRect(x, yHold - 1, width, 2);
}
function drawPeakOverlay(g, x, width, value, plotY, plotH, range) {
if (!Number.isFinite(value)) return;
const y = mapDbToY(value, range, plotY, plotH);
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 1.4;
g.beginPath();
g.moveTo(x, y);
g.lineTo(x + width, y);
g.stroke();
g.restore();
}
function redrawPlotEdges(g, x, y, w, h, CONFIG) {
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
: 14;
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.beginPath();
g.moveTo(x - gutterL, y);
g.lineTo(x + w, y);
g.stroke();
g.beginPath();
g.moveTo(x - gutterL, y + h);
g.lineTo(x + w, y + h);
g.stroke();
}
async function drawMeter(env, state, plotX, plotY, plotW, plotH) {
const meterRect = { x: plotX + plotW + METER_GAP, y: plotY, w: METER_WIDTH, h: plotH };
const { ctx: g, config: CONFIG, meters } = env;
drawCachedStaticLayer(
state,
g,
'rta-meter-shell',
`${meterRect.w}x${meterRect.h}`,
{ x: meterRect.x - STATIC_STROKE_PAD, y: meterRect.y - STATIC_STROKE_PAD, w: meterRect.w + STATIC_STROKE_PAD * 2, h: meterRect.h + STATIC_STROKE_PAD * 2 },
(lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(STATIC_STROKE_PAD, STATIC_STROKE_PAD, meterRect.w, meterRect.h);
lg.strokeStyle = FRAME_COLOR;
lg.lineWidth = 2;
lg.strokeRect(STATIC_STROKE_PAD + 0.5, STATIC_STROKE_PAD + 0.5, meterRect.w - 1, meterRect.h - 1);
});
const slotList = env.slots ? env.slots('realtime') : null;
const active = (slotList && slotList[0]) || 'vu';
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.lineWidth = 2;
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();
}
function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
function mapLogX(freq, x0, w, logMin, logSpan) {
const clamped = Math.max(5, freq);
const frac = (Math.log10(clamped) - logMin) / (logSpan || 1);
return x0 + Math.max(0, Math.min(1, frac)) * w;
}
function mapDbToY(value, range, plotY, plotH) {
const top = range.top;
const bottom = range.bottom;
const clamped = Math.max(bottom, Math.min(top, value));
const t = (clamped - bottom) / (top - bottom || 1);
return plotY + plotH - t * plotH;
}
function getDbRange() {
const top = 9;
const bottom = -36;
const ticks = [];
for (let v = bottom; v <= top + 1e-6; v += 9) {
ticks.push(v);
}
if (!ticks.includes(top)) ticks.push(top);
return { top, bottom, ticks };
}
function getFreqBounds(CONFIG, nyq, rtaData) {
let min = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5 : 20;
let max = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5000 : 20000;
if (rtaData) {
if (Number.isFinite(rtaData.freqMin)) min = Math.max(min, rtaData.freqMin);
if (Number.isFinite(rtaData.freqMax)) max = Math.min(max, rtaData.freqMax);
}
return { min, max: Math.min(max, nyq) };
}
function buildFixedRtwBands(bpoMode, freqBounds, nyq, overrideCenters) {
const centers = isVectorLike(overrideCenters) && overrideCenters.length
? overrideCenters.slice()
: getRtwCenters(bpoMode);
if (!centers.length) return [];
const n = resolveRtwBpoValue(bpoMode);
const factor = Math.pow(2, 1 / (2 * n));
const minF = freqBounds.min;
const maxF = Math.min(freqBounds.max, nyq);
const bands = [];
for (const fc of centers) {
if (!Number.isFinite(fc)) continue;
if (fc < minF || fc > maxF) continue;
const fLo = Math.max(fc / factor, minF);
const fHi = Math.min(fc * factor, maxF);
if (fHi <= fLo) continue;
bands.push({ center: fc, fLo, fHi });
}
return bands;
}
function selectLocalRtwPacket(packet, bpoMode) {
if (!packet || !isVectorLike(packet.centers) || !packet.centers.length) return packet;
const desiredCenters = getRtwCenters(bpoMode);
if (!desiredCenters.length) return packet;
if (packet.centers.length === desiredCenters.length) return packet;
const keyFor = (value) => Number(value).toFixed(1);
const indexByCenter = new Map();
for (let i = 0; i < packet.centers.length; i++) {
indexByCenter.set(keyFor(packet.centers[i]), i);
}
const pick = [];
const selectedCenters = [];
const used = new Set();
for (const center of desiredCenters) {
let idx = indexByCenter.get(keyFor(center));
if (!Number.isInteger(idx)) {
let bestIdx = -1;
let bestErr = Infinity;
for (let i = 0; i < packet.centers.length; i++) {
if (used.has(i)) continue;
const candidate = Number(packet.centers[i]);
if (!Number.isFinite(candidate)) continue;
const relErr = Math.abs(candidate - center) / Math.max(center, 1);
if (relErr < bestErr) {
bestErr = relErr;
bestIdx = i;
}
}
if (bestIdx >= 0 && bestErr <= 0.04) {
idx = bestIdx;
}
}
if (Number.isInteger(idx) && !used.has(idx)) {
used.add(idx);
pick.push(idx);
selectedCenters.push(Number(packet.centers[idx]));
}
}
if (!pick.length) return packet;
const pickVector = (src) => {
if (!isVectorLike(src)) return [];
return pick.map((idx) => Number(src[idx]));
};
return {
...packet,
centers: selectedCenters,
bands_avg: pickVector(packet.bands_avg || packet.bands),
bands_peak: pickVector(packet.bands_peak),
bands: pickVector(packet.bands || packet.bands_avg),
};
}
function mapIirLevels(packet, CONFIG, range, expectedLen) {
if (!packet || packet.engine !== 'iir') return null;
const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? 0) || 0;
const ensureLen = (arr) => (isVectorLike(arr) && (!expectedLen || arr.length === expectedLen)) ? arr : null;
const avgRaw = ensureLen(packet.bands_avg || packet.bands);
const peakRaw = ensureLen(packet.bands_peak);
const requestedMode = (CONFIG.RTA_BALLISTICS_MODE === 'peak' || CONFIG.RTA_BALLISTICS_MODE === 'both') ? CONFIG.RTA_BALLISTICS_MODE : 'average';
let modeResolved = requestedMode;
if (modeResolved === 'both' && !(avgRaw && peakRaw)) {
modeResolved = avgRaw ? 'average' : (peakRaw ? 'peak' : 'average');
}
if (modeResolved === 'peak' && !peakRaw) {
modeResolved = avgRaw ? 'average' : null;
}
if (modeResolved === 'average' && !avgRaw) {
modeResolved = peakRaw ? 'peak' : null;
}
if (!modeResolved) return null;
const sourcePrimary = modeResolved === 'peak' ? peakRaw : avgRaw;
if (!sourcePrimary) return null;
const convert = (src) => {
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
const v = Number(src[i]);
const val = Number.isFinite(v) ? v : bottom;
out[i] = clamp(val + gain, bottom, top);
}
return out;
};
const primary = convert(sourcePrimary);
let overlay = null;
let modeForRenderer = modeResolved;
if (requestedMode === 'both' && avgRaw && peakRaw) {
overlay = convert(peakRaw);
modeForRenderer = 'both';
}
if (modeForRenderer === 'both' && !overlay) {
modeForRenderer = modeResolved === 'peak' ? 'peak' : 'average';
}
return {
primary,
overlay: (modeForRenderer === 'both') ? overlay : null,
mode: modeForRenderer,
};
}
function mapNativeFftLevels(packet, CONFIG, range, expectedLen) {
if (!packet || packet.engine !== 'fft') return null;
const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
const src = isVectorLike(packet.bands_avg || packet.bands)
? (packet.bands_avg || packet.bands)
: null;
if (!src || (expectedLen && src.length !== expectedLen)) return null;
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
const v = Number(src[i]);
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top);
}
return out;
}
function isVectorLike(v) {
return !!(v && (Array.isArray(v) || ArrayBuffer.isView(v)));
}
if (typeof window !== 'undefined') {
window.resetRealTimeAnalyzerPeakHold = function resetRealTimeAnalyzerPeakHold() {
const state = window.__RTA_STATE__;
if (!state || !state.peakHold || !state.peakHold.length) return;
const floor = (state.currentRange && state.currentRange.bottom) || FLOOR_DB;
state.peakHold = new Float32Array(state.peakHold.length);
state.peakHold.fill(floor);
state.displayLevels = new Float32Array(state.peakHold.length);
state.displayLevels.fill(floor);
state.lastPeakTime = new Array(state.peakHold.length).fill(performance.now());
};
}
function clamp(val, min, max) {
return Math.min(max, Math.max(min, val));
}
+300
View File
@@ -0,0 +1,300 @@
// views/recorder.js — Recorder-View im XY-Layout-Stil (links Recorder-Panel, rechts 3 Slots)
import { drawCachedStaticLayer } from './static_layer.js';
export const id = 'recorder';
const FRAME = { left: 0, top: 70, right: 0, bottom: 0 };
const METER_SLOTS = 3;
const METER_GAP = 8;
const SLOT_GAP = 12;
const METER_WIDTH = 420;
const METER_SLOT_SHRINK = 24;
const METER_PAD_TOP = 1;
const METER_PAD_BOTTOM = -7;
const METER_EXTRA_BOTTOM_PAD = 6;
const PANEL_BG = 'rgba(5,5,11,0.75)';
const SLOT_BG = 'rgba(10,12,18,0.85)';
const BORDER = 'rgba(120, 170, 220, 0.7)';
function isExternalClient() {
const host = String(globalThis?.location?.hostname || '').trim().toLowerCase();
return !!host && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]';
}
export function init() { return { staticLayers: new Map(), hudLayoutSig: '' }; }
export function resize() {}
export function destroy(state) {
if (state) state.staticLayers = null;
}
export async function render(env, state) {
if (!env || !env.ctx) return;
const { ctx: g, rect, recorder, meters, config: CONFIG } = env;
const slotsRaw = env.slots?.(id) || [];
const slots = slotsRaw.filter((v) => v && v !== 'none');
const layout = computeLayout(rect, slots.length);
drawCachedPlotShell(state, g, layout.plot, CONFIG);
drawCachedRecorderPanel(state, g, layout.scope);
if (CONFIG.RECORD_SHOW_RECORDER_AB === true) {
drawRecorderAbIndicator(g, layout.scope, recorder);
}
if (slots.length) {
await drawMeterPanel(state, g, layout.meter, slots, meters, CONFIG);
}
positionRecorderHud(state, env.recorderDom, layout.scope);
}
function computeLayout(rect, slotCount = METER_SLOTS) {
const plotX = 0;
const plotY = FRAME.top;
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
const minPlotW = 200;
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
const panelSlotsWidth = computePanelSlotsWidth(innerWidth, n);
let meterW = n > 0 ? Math.max(panelSlotsWidth, (n === 1 ? 160 : (n === 2 ? 280 : METER_WIDTH))) : 0;
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
if (plotW < minPlotW) {
plotW = minPlotW;
meterW = n > 0 ? (innerWidth - plotW - METER_GAP) : 0;
}
if (n > 0) meterW = Math.max(meterW, panelSlotsWidth);
if (plotW < 80) plotW = 80;
const plotH = Math.max(180, rect.h - FRAME.top - FRAME.bottom);
const plot = { x: plotX, y: plotY, w: plotW, h: plotH };
const scope = computeScopeBox(plot);
const meter = {
x: plot.x + plot.w + (n > 0 ? METER_GAP : 0),
y: plot.y,
w: meterW,
h: plotH,
};
return { plot, scope, meter };
}
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS) {
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
const avail = Math.max(1, canvasWidth);
const totalGap = (n - 1) * SLOT_GAP;
const usable = avail - totalGap;
return Math.max(1, Math.floor(usable / n));
}
function computePanelSlotsWidth(innerWidth, slotCount = METER_SLOTS) {
const panelSlotWidth = 100;
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
return n > 0 ? (panelSlotWidth * n + (n - 1) * SLOT_GAP) : 0;
}
function computeScopeBox(plot) {
// Maximiere die Recorder-Box im Plot (kein zusätzlicher Innenabstand)
const padding = 0;
const w = Math.max(1, plot.w - padding);
const h = Math.max(1, plot.h - padding);
const x = Math.round(plot.x + padding / 2);
const y = Math.round(plot.y + padding / 2);
const cx = x + w / 2;
const cy = y + h / 2;
return { x, y, w, h, cx, cy };
}
function drawCachedPlotShell(state, g, plot, CONFIG = {}) {
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
: 14;
drawCachedStaticLayer(state, g, 'recorder-plot-shell', `${gutterL}`, plot, (cg) => {
cg.fillStyle = PANEL_BG;
cg.fillRect(0, 0, plot.w, plot.h);
cg.save();
cg.translate(-plot.x, -plot.y);
cg.strokeStyle = '#00e7ff';
cg.lineWidth = 2;
cg.beginPath();
cg.moveTo(plot.x - gutterL, plot.y);
cg.lineTo(plot.x + plot.w, plot.y);
cg.stroke();
cg.beginPath();
cg.moveTo(plot.x - gutterL, plot.y + plot.h);
cg.lineTo(plot.x + plot.w, plot.y + plot.h);
cg.stroke();
cg.beginPath();
cg.moveTo(plot.x, plot.y);
cg.lineTo(plot.x, plot.y + plot.h);
cg.stroke();
cg.beginPath();
cg.moveTo(plot.x + plot.w, plot.y);
cg.lineTo(plot.x + plot.w, plot.y + plot.h);
cg.stroke();
cg.restore();
});
}
function drawCachedRecorderPanel(state, g, box) {
drawCachedStaticLayer(state, g, 'recorder-box-shell', 'frame', box, (cg) => {
cg.fillStyle = 'rgba(7, 8, 15, 0.7)';
cg.fillRect(0, 0, box.w, box.h);
cg.strokeStyle = '#00e7ff';
cg.lineWidth = 2;
cg.strokeRect(0.5, 0.5, box.w - 1, box.h - 1);
});
}
async function drawMeterPanel(state, g, area, slots, meters, CONFIG) {
const slotCount = Array.isArray(slots) ? slots.length : 0;
if (!slotCount || !area || area.w <= 0 || area.h <= 0) return;
drawCachedStaticLayer(state, g, 'recorder-meter-shell', `${slotCount}|${CONFIG?.PANEL_DIVIDERS_ENABLED ? 1 : 0}`, area, (cg) => {
cg.fillStyle = PANEL_BG;
cg.fillRect(0, 0, area.w, area.h);
cg.strokeStyle = '#00e7ff';
cg.lineWidth = 2;
cg.strokeRect(0.5, 0.5, area.w - 1, area.h - 1);
if (CONFIG?.PANEL_DIVIDERS_ENABLED) {
const gap = SLOT_GAP;
const slotW = computePanelSlotWidth(area.w, slotCount);
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = Math.max(0, Math.floor((area.w - totalSlotW) / 2));
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
for (let i = 1; i < slotCount; i++) {
const rectX = startX + i * (slotW + gap);
const dividerX = rectX - gap / 2;
cg.save();
cg.strokeStyle = 'rgba(0,231,255,0.4)';
cg.lineWidth = 1;
cg.setLineDash([4, 3]);
cg.beginPath();
cg.moveTo(dividerX, slotTopPadding - 6);
cg.lineTo(dividerX, area.h - slotBottomPadding + 6);
cg.stroke();
cg.restore();
}
}
});
g.save();
const gap = SLOT_GAP;
const slotW = computePanelSlotWidth(area.w, slotCount);
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
const startX = area.x + Math.max(0, Math.floor((area.w - totalSlotW) / 2));
const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2;
const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2;
const slotH = Math.max(40, area.h - slotTopPadding - slotBottomPadding);
for (let i = 0; i < slotCount; i++) {
const x = startX + i * (slotW + gap);
const rect = { x, y: area.y + slotTopPadding, w: slotW, h: slotH };
const slotId = slots[i];
if (slotId) {
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const innerRect = {
x: rect.x,
y: rect.y + METER_PAD_TOP + innerOffset,
w: rect.w,
h: innerHeight,
};
try { await meters.draw(g, innerRect, slotId, CONFIG); }
catch (e) { /* ignore draw errors to keep view alive */ }
}
}
g.restore();
}
function positionRecorderHud(state, dom = {}, scope) {
if (!dom || !scope) return;
const { hud, label, start, stop, auto, list, timer } = dom;
if (!hud) return;
const centerX = scope.x + scope.w / 2;
const topY = scope.y + 8;
// Buttons mittig im unteren Drittel
const baseY = scope.y + scope.h - 24 - Math.max(start?.offsetHeight || 0, stop?.offsetHeight || 0, auto?.offsetHeight || 0);
const gap = 20;
const startW = (start?.offsetWidth || 120);
const stopW = (stop?.offsetWidth || 120);
const autoW = (auto?.offsetWidth || 120);
const totalW = startW + gap + stopW + gap + autoW;
const startX = centerX - totalW / 2;
const listTop = topY + (label?.offsetHeight || 30) + 8;
const listBottomReserve = 28;
const listMaxHeight = list
? Math.max(72, ((timer ? (baseY - (timer.offsetHeight || 18) - 10) : baseY) - listTop - listBottomReserve))
: 0;
const sig = [
scope.x, scope.y, scope.w, scope.h,
label?.offsetWidth || 0, label?.offsetHeight || 0,
start?.offsetWidth || 0, start?.offsetHeight || 0,
stop?.offsetWidth || 0, stop?.offsetHeight || 0,
auto?.offsetWidth || 0, auto?.offsetHeight || 0,
timer?.offsetWidth || 0, timer?.offsetHeight || 0,
listMaxHeight,
].join('|');
if (state?.hudLayoutSig === sig) return;
if (state) state.hudLayoutSig = sig;
if (label) {
label.style.left = `${centerX - (label.offsetWidth || 0) / 2}px`;
label.style.top = `${topY}px`;
}
if (list) {
const y = topY + (label?.offsetHeight || 30) + 8;
list.classList.toggle('rec-list--external', isExternalClient());
list.style.width = `${Math.max(200, scope.w - 45)}px`;
list.style.left = `${scope.x + 12}px`;
list.style.top = `${y}px`;
list.style.maxHeight = `${listMaxHeight}px`;
}
if (start) {
start.style.left = `${startX}px`;
start.style.top = `${baseY}px`;
}
if (stop) {
stop.style.left = `${startX + startW + gap}px`;
stop.style.top = `${baseY}px`;
}
if (auto) {
auto.style.left = `${startX + startW + gap + stopW + gap}px`;
auto.style.top = `${baseY}px`;
}
if (timer) {
const y = baseY - (timer.offsetHeight || 18) - 10;
timer.style.left = `${centerX - (timer.offsetWidth || 80) / 2}px`;
timer.style.top = `${y}px`;
}
}
function drawRecorderAbIndicator(g, box, recorder = {}) {
const active = recorder.activeRecorderSlot === 'A' || recorder.activeRecorderSlot === 'B'
? recorder.activeRecorderSlot
: null;
const processing = new Set(Array.isArray(recorder.processingRecorderSlots) ? recorder.processingRecorderSlots : []);
const next = recorder.nextRecorderSlot === 'B' ? 'B' : 'A';
const colorFor = (slot) => {
if (active === slot) return '#ff3b3b';
if (!processing.has(slot) && (active || next === slot)) return '#34d399';
if (processing.has(slot)) return '#8fd3d4';
return 'rgba(143, 211, 212, 0.45)';
};
const top = box.y + 12;
const left = box.x + 14;
const gap = 18;
g.save();
g.font = 'bold 18px ui-monospace, monospace';
g.textAlign = 'left';
g.textBaseline = 'top';
g.fillStyle = colorFor('A');
g.fillText('A', left, top);
g.fillStyle = colorFor('B');
g.fillText('B', left + gap, top);
g.restore();
}
+836
View File
@@ -0,0 +1,836 @@
// views/spectrogram.js — FFT Spectrogram View with optional Worker renderer
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
const PLOT = { left: 35, 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_ROW_COUNT = 16;
const RTA_X_TICKS = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const SMOOTHING_MAX = 0.2;
const TIME_GRID_SPACING = 100;
const SPECTROGRAM_ID = 'spectrogram';
const SPECTRO_RENDER_SCALE = 0.85;
const SUPPORTS_WORKER = typeof window !== 'undefined'
&& typeof Worker !== 'undefined'
&& typeof HTMLCanvasElement !== 'undefined'
&& !!HTMLCanvasElement.prototype.transferControlToOffscreen;
// Pre-calculated constants
const LOG10 = Math.log(10);
const DB_TO_LINEAR_FACTOR = LOG10 / 10;
const SPECTRO_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] },
];
export const id = SPECTROGRAM_ID;
export function init() {
return {
useWorker: false, // wird im Render je nach Config gesetzt
worker: null,
workerReady: false,
offscreenWidth: 0,
offscreenHeight: 0,
spectroCanvasEl: null,
spectroCanvasTransferred: false,
lastTopDb: null,
lastBottomDb: null,
lastGamma: null,
width: 0,
height: 0,
freqBounds: null,
freqMap: [],
freqMapKey: '',
sampleRate: 48000,
binCount: 0,
history: null,
writeIndex: 0,
imgData: null,
pixelData: null,
scrollAccumulator: 0,
configSig: '',
gridCache: null,
_initialized: false,
_lastScrollTs: 0,
lastPhoenixSpectroSeq: 0,
};
}
export function resize() {}
export function destroy(state) {
teardownWorker(state);
state.history = null;
state.imgData = null;
state.pixelData = null;
state.freqMap = [];
state.freqMapKey = '';
state.gridCache = null;
removeSpectroCanvasElement(state);
if (state.spectroCanvasEl && !state.spectroCanvasTransferred) {
state.spectroCanvasEl.width = 0;
state.spectroCanvasEl.height = 0;
}
}
export async function render(env, state) {
if (!state._initialized) {
await new Promise(resolve => setTimeout(resolve, 0));
state._initialized = true;
}
// Config-Flag schaltet Worker ein (default true), wenn Offscreen verfügbar
state.useWorker = SUPPORTS_WORKER && !!env.config?.SPECTRO_USE_WORKER;
state._dbgNextLog = state._dbgNextLog || 0;
const { ctx: g, rect, config: CONFIG, audio, meters } = env;
const analyzer = audio.getAnalyser?.();
let freqBuf = audio.getFreqBuffer?.();
const phoenixSpectroSeq = Number(env.audio?.phoenixSpectroSeq || 0);
const hasPhoenixSpectro = Number.isFinite(phoenixSpectroSeq) && phoenixSpectroSeq > 0;
if (!freqBuf && analyzer) {
freqBuf = new Float32Array(analyzer.frequencyBinCount || 2048);
}
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(SPECTROGRAM_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_ROW_COUNT, Math.floor(rect.h - plotY - PLOT.bottom));
const dpr = Math.max(1, window.devicePixelRatio || 1);
const renderScale = Math.max(0.5, Math.min(1, SPECTRO_RENDER_SCALE));
const plotWpx = Math.max(1, Math.floor(plotW * dpr * renderScale));
const plotHpx = Math.max(1, Math.floor(plotH * dpr * renderScale));
const range = getDbRange(CONFIG);
const gamma = Math.max(0.3, Math.min(1.2, Number(CONFIG.SPECTRO_GAMMA ?? 0.9)));
state._dbgEnabled = !!CONFIG.SPECTRO_DEBUG;
layoutSpectroCanvas(state, canvasOffsetX + plotX, canvasOffsetY + plotY, plotW, plotH, plotWpx, plotHpx);
drawSpectrogramBackground(g, plotX, plotY, plotW, plotH);
if (!analyzer || !freqBuf || !freqBuf.length) {
drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH);
drawSpectrogramLegendHud(range, gamma);
if (showMeter) {
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
}
return;
}
const width = plotW;
const height = Math.max(MIN_ROW_COUNT, plotH);
const dimensionsChanged = state.width !== width || state.height !== height;
state.width = width;
state.height = height;
const fs = analyzer?.context?.sampleRate || audio.sampleRate || Math.max(48000, (audio.nyq || 24000) * 2);
const nyq = fs / 2;
const freqBounds = getFreqBounds(CONFIG, nyq);
const boundsChanged = !state.freqBounds ||
state.freqBounds.min !== freqBounds.min ||
state.freqBounds.max !== freqBounds.max;
if (boundsChanged) {
state.freqBounds = { ...freqBounds };
}
state.sampleRate = fs;
if (dimensionsChanged || boundsChanged || state.binCount !== freqBuf.length) {
ensureFreqMap(state, height, freqBounds, freqBuf.length, fs);
}
if (state.useWorker) {
setupWorker(state, plotWpx, plotHpx, range, gamma, freqBounds);
} else {
ensureFallbackBuffers(state, width, height, range.bottom, g);
}
const scrollRate = resolveScrollRate(CONFIG);
let spectroDirty = true;
if (hasPhoenixSpectro) {
spectroDirty = phoenixSpectroSeq !== (state.lastPhoenixSpectroSeq || 0);
}
let columnsToEmit = 0;
if (hasPhoenixSpectro) {
if (spectroDirty) {
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate;
while (state.scrollAccumulator >= 1) {
columnsToEmit += 1;
state.scrollAccumulator -= 1;
}
if (columnsToEmit < 1) {
columnsToEmit = 1;
state.scrollAccumulator = 0;
}
}
} else {
const nowTs = performance.now();
const dtMs = Math.max(1, nowTs - (state._lastScrollTs || nowTs));
state._lastScrollTs = nowTs;
const targetFrameMs = 1000 / 60;
const frameUnits = dtMs / targetFrameMs;
state.scrollAccumulator = (state.scrollAccumulator || 0) + scrollRate * frameUnits;
while (state.scrollAccumulator >= 1) {
columnsToEmit += 1;
state.scrollAccumulator -= 1;
}
}
if (spectroDirty) {
analyzer.smoothingTimeConstant = Math.min(SMOOTHING_MAX, Math.max(0, analyzer.smoothingTimeConstant ?? 0));
analyzer.getFloatFrequencyData(freqBuf);
if (hasPhoenixSpectro) {
state.lastPhoenixSpectroSeq = phoenixSpectroSeq;
}
}
if (columnsToEmit > 0 && spectroDirty) {
const column = buildColumnData(state, freqBuf, range.bottom);
const columns = [];
for (let i = 0; i < columnsToEmit; i++) {
columns.push(i === 0 ? column : new Float32Array(column));
}
if (state.useWorker && state.workerReady) {
columns.forEach(col => sendColumnToWorker(state, col));
} else {
columns.forEach(col => writeColumnToHistory(state, col));
}
}
if (!state.useWorker) {
drawSpectrogramImage(g, state, plotX, plotY, range, gamma);
}
drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH);
drawSpectrogramLegendHud(range, gamma);
if (showMeter) {
await drawMeterPanel(g, plotX, plotY, plotW, plotH, CONFIG, meters, activeMeter);
}
}
function drawSpectrogramBackground(g, plotX, plotY, plotW, plotH) {
g.save();
g.clearRect(plotX, plotY, plotW, plotH);
g.restore();
}
function setupWorker(state, widthPx, heightPx, range, gamma, freqBounds) {
const canvasEl = ensureSpectroCanvasElement(state);
if (!canvasEl) {
state.useWorker = false;
return;
}
if (!state.worker) {
try {
const workerUrl = new URL('../workers/spectrogram.worker.js', import.meta.url);
const worker = new Worker(workerUrl, { type: 'module' });
worker.onmessage = (event) => handleWorkerMessage(state, event);
worker.onerror = () => {
state.useWorker = false;
teardownWorker(state);
};
const offscreen = canvasEl.transferControlToOffscreen();
state.spectroCanvasEl = canvasEl;
state.worker = worker;
state.workerReady = false;
state.offscreenWidth = widthPx;
state.offscreenHeight = heightPx;
state.spectroCanvasTransferred = true;
state.worker.postMessage({
type: 'init',
canvas: offscreen,
width: widthPx,
height: heightPx,
topDb: range.top,
bottomDb: range.bottom,
gamma,
fMin: freqBounds?.min,
fMax: freqBounds?.max,
}, [offscreen]);
state.lastTopDb = range.top;
state.lastBottomDb = range.bottom;
state.lastGamma = gamma;
} catch (e) {
state.useWorker = false;
teardownWorker(state);
return;
}
}
const needsResize = state.offscreenWidth !== widthPx || state.offscreenHeight !== heightPx;
if (needsResize) {
state.offscreenWidth = widthPx;
state.offscreenHeight = heightPx;
state.worker?.postMessage({
type: 'resize',
width: widthPx,
height: heightPx,
fMin: freqBounds?.min,
fMax: freqBounds?.max,
});
}
const needsConfigUpdate = state.lastTopDb !== range.top ||
state.lastBottomDb !== range.bottom ||
state.lastGamma !== gamma;
if (needsConfigUpdate) {
state.lastTopDb = range.top;
state.lastBottomDb = range.bottom;
state.lastGamma = gamma;
state.worker?.postMessage({
type: 'config',
topDb: range.top,
bottomDb: range.bottom,
gamma
});
}
}
function teardownWorker(state) {
if (state.worker) {
try {
state.worker.postMessage({ type: 'dispose' });
setTimeout(() => {
try { state.worker.terminate(); } catch (_) {}
}, 10);
} catch (_) {}
}
state.worker = null;
state.workerReady = false;
state.spectroCanvasEl = null;
state.spectroCanvasTransferred = false;
}
function handleWorkerMessage(state, event) {
const data = event.data || {};
if (data.type === 'ready') {
state.workerReady = true;
} else if (data.type === 'dbg' && state._dbgEnabled) {
console.debug('[spectro worker]', data);
}
}
function sendColumnToWorker(state, column) {
if (!state.worker || !column) return;
// Prüfe ob Worker bereit ist und nicht überlastet
if (!state.workerReady) return;
try {
state.worker.postMessage({ type: 'column', data: column }, [column.buffer]);
} catch (e) {
state.useWorker = false;
}
advanceWriteIndex(state);
// Debug: alle ~1s senden wir ein Status-Log
if (state._dbgEnabled && performance.now() >= state._dbgNextLog) {
state._dbgNextLog = performance.now() + 1000;
console.debug('[spectro main] sent column', { writeIndex: state.writeIndex });
}
}
function ensureFallbackBuffers(state, width, height, fillDb, g) {
const needsInit = !state.history ||
state.width !== width ||
state.height !== height ||
!state.imgData ||
!state.pixelData;
if (!needsInit) return;
state.width = width;
state.height = height;
state.history = Array.from({ length: width }, () => {
const arr = new Float32Array(height);
arr.fill(fillDb);
return arr;
});
state.writeIndex = 0;
state.scrollAccumulator = 0;
state._lastScrollTs = performance.now();
state.imgData = g.createImageData(width, height);
state.pixelData = state.imgData.data;
}
function ensureFreqMap(state, height, freqBounds, binCount, fs) {
const key = [
height,
freqBounds.min.toFixed(3),
freqBounds.max.toFixed(3),
binCount,
fs
].join('|');
if (state.freqMapKey === key && state.freqMap && state.freqMap.length === height) {
return;
}
state.freqMap = buildFreqMap(height, freqBounds, fs, binCount);
state.freqMapKey = key;
state.binCount = binCount;
}
function buildColumnData(state, freqBuf, fallbackDb) {
const height = state.height;
if (!height) return null;
const column = new Float32Array(height);
const freqMap = state.freqMap || [];
const bins = freqBuf.length;
for (let y = 0; y < height; y++) {
const map = freqMap[y];
if (!map) {
column[y] = fallbackDb;
continue;
}
let binLo = map.binLo;
let binHi = map.binHi;
if (!Number.isFinite(binLo)) binLo = 0;
if (!Number.isFinite(binHi)) binHi = binLo;
binLo = Math.max(0, Math.min(bins - 1, binLo));
binHi = Math.max(binLo, Math.min(bins - 1, binHi));
let linearSum = 0;
let count = 0;
let maxDb = -Infinity;
for (let bin = binLo; bin <= binHi; bin++) {
const dB = freqBuf[bin];
if (!Number.isFinite(dB)) continue;
linearSum += Math.exp(dB * DB_TO_LINEAR_FACTOR);
count++;
if (dB > maxDb) maxDb = dB;
}
if (count > 0) {
const avgLinear = linearSum / count;
column[y] = Math.log10(Math.max(avgLinear, 1e-12)) * 10;
} else if (Number.isFinite(maxDb)) {
column[y] = maxDb;
} else {
column[y] = fallbackDb;
}
}
return column;
}
function writeColumnToHistory(state, column) {
if (!state.history || !column) return;
const target = state.history[state.writeIndex];
if (target) target.set(column);
advanceWriteIndex(state);
}
function drawSpectrogramImage(g, state, plotX, plotY, range, gamma) {
const img = state.imgData;
const pixels = state.pixelData;
if (!img || !pixels || !state.history) return;
const width = state.width;
const height = state.height;
const totalRange = Math.max(1e-3, range.top - range.bottom);
const baseIndex = state.writeIndex;
const colorStops = SPECTRO_COLOR_STOPS;
for (let x = 0; x < width; x++) {
const historyIdx = (baseIndex + x) % width;
const column = state.history[historyIdx];
if (!column) continue;
for (let y = 0; y < height; y++) {
const dB = column[y];
const clamped = dB < range.bottom ? range.bottom : dB > range.top ? range.top : dB;
let norm = (clamped - range.bottom) / totalRange;
norm = Math.pow(norm < 0 ? 0 : norm > 1 ? 1 : norm, gamma);
const [r, gVal, bVal] = colorFromNorm(norm, colorStops);
const idx = ((height - 1 - y) * width + x) * 4;
pixels[idx + 0] = r;
pixels[idx + 1] = gVal;
pixels[idx + 2] = bVal;
pixels[idx + 3] = 255;
}
}
g.putImageData(img, plotX, plotY);
}
function drawSpectrogramGrid(g, state, plotX, plotY, plotW, plotH) {
const bounds = state.freqBounds;
if (!bounds) return;
const ticks = RTA_X_TICKS.filter((f) => f >= bounds.min && f <= bounds.max);
drawCachedSpectroGrid(state, g, plotX, plotY, plotW, plotH, bounds, ticks);
g.save();
g.fillStyle = '#bcd';
g.textAlign = 'right';
g.font = '12px monospace';
for (const freq of ticks) {
const y = freqToPixel(freq, state, plotY, plotH, bounds);
if (!Number.isFinite(y)) continue;
if (freq <= Math.max(20, bounds.min * 1.05)) {
g.textBaseline = 'alphabetic';
g.fillText(formatFreqLabel(freq), plotX - 6, y);
} else {
g.textBaseline = 'middle';
g.fillText(formatFreqLabel(freq), plotX - 6, y);
}
}
g.textAlign = 'right';
g.textBaseline = 'bottom';
g.fillText('Zeit →', plotX + plotW - 6, plotY + plotH - 4);
g.restore();
}
function resolveScrollRate(CONFIG) {
const mode = Number(CONFIG?.SPECTRO_SCROLL_MODE ?? 0);
if (mode === 0.5) return 0.5;
if (mode === 2) return 2;
if (mode === 4) return 4;
if (mode === 6) return 6;
return 1;
}
function advanceWriteIndex(state) {
const width = Math.max(1, state.width || 1);
state.writeIndex = (state.writeIndex + 1) % width;
}
function drawSpectrogramLegendHud(range, gamma = 1) {
if (!range) return;
const wrap = document.getElementById('spectroLegendWrap');
const canvas = document.getElementById('spectroLegend');
if (!wrap || !canvas) return;
if (wrap.style.display === 'none' || wrap.hidden) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssWidth = Math.max(200, canvas.clientWidth || canvas.width || 200);
const cssHeight = Math.max(32, canvas.clientHeight || canvas.height || 32);
const width = Math.round(cssWidth * dpr);
const height = Math.round(cssHeight * dpr);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.scale(dpr, dpr);
const w = width / dpr;
const h = height / dpr;
const span = Math.max(1e-3, range.top - range.bottom);
const paddingTop = 0;
const paddingSide = 20;
const gradientWidth = Math.max(80, w - paddingSide * 2);
const gradientHeight = Math.max(10, Math.min(18, h * 0.45));
const gradientX = paddingSide;
const gradientY = paddingTop;
const steps = Math.max(1, Math.round(gradientWidth));
const stepWidth = gradientWidth / steps;
for (let i = 0; i < steps; i++) {
const frac = steps <= 1 ? 0 : i / (steps - 1);
const gammaFrac = Math.pow(Math.min(1, Math.max(0, frac)), gamma);
const [r, gCol, bCol] = colorFromNorm(gammaFrac, SPECTRO_COLOR_STOPS);
ctx.fillStyle = `rgb(${r},${gCol},${bCol})`;
ctx.fillRect(gradientX + i * stepWidth, gradientY, stepWidth + 1, gradientHeight);
}
const ticks = [range.bottom, range.bottom + span / 2, range.top];
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.fillStyle = '#e6f9ff';
ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const gap = 9;
for (const value of ticks) {
const frac = (value - range.bottom) / span;
const x = gradientX + Math.min(1, Math.max(0, frac)) * gradientWidth;
ctx.beginPath();
ctx.moveTo(x, gradientY + gradientHeight);
ctx.lineTo(x, gradientY + gradientHeight + 7);
ctx.stroke();
ctx.fillText(`${formatDbLabel(value)} dB`, x, gradientY + gradientHeight + gap + 1);
}
ctx.restore();
}
function drawCachedSpectroGrid(state, ctx, plotX, plotY, plotW, plotH, bounds, ticks) {
const key = [
Math.round(plotW),
Math.round(plotH),
bounds.min.toFixed(2),
bounds.max.toFixed(2),
ticks.join(','),
TIME_GRID_SPACING
].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) {
cg.save();
cg.scale(cw / plotW, ch / plotH);
cg.strokeStyle = FRAME_COLOR;
cg.lineWidth = 2;
cg.strokeRect(0.5, 0.5, plotW - 1, plotH - 1);
cg.strokeStyle = 'rgba(0,231,255,0.25)';
cg.lineWidth = 1;
for (const freq of ticks) {
const y = freqToPixel(freq, { height: plotH, freqBounds: bounds }, 0, plotH, bounds);
if (!Number.isFinite(y)) continue;
cg.beginPath();
cg.moveTo(0, y + 0.5);
cg.lineTo(plotW, y + 0.5);
cg.stroke();
}
// Removed vertical time grid lines for cleaner spectrogram view
cg.restore();
}
state.gridCache = { key, canvas, width: cw, height: ch };
}
ctx.drawImage(state.gridCache.canvas, plotX, plotY, plotW, plotH);
}
function freqToPixel(freq, state, plotY, plotH, bounds) {
if (!bounds) return plotY + plotH;
const minF = Math.max(10, bounds.min);
const maxF = Math.max(minF + 1, bounds.max);
const logRatio = Math.log(maxF / minF);
if (!Number.isFinite(logRatio) || logRatio <= 0) return plotY + plotH;
const frac = Math.log(freq / minF) / logRatio;
const height = state.height || plotH;
const row = (frac < 0 ? 0 : frac > 1 ? 1 : frac) * Math.max(0, height - 1);
return plotY + (height - 1 - row);
}
function formatFreqLabel(freq) {
if (freq >= 1000) {
const val = freq / 1000;
return val >= 10 ? `${Math.round(val)}k` : `${val.toFixed(1)}k`;
}
return String(Math.round(freq));
}
function formatDbLabel(value) {
if (!Number.isFinite(value)) return '0';
const rounded = Math.abs(value) < 10 ? value.toFixed(1) : Math.round(value);
return String(rounded);
}
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 getDbRange(CONFIG) {
const BASE_TOP_DB = -9;
const top = Number.isFinite(CONFIG.DBFS_TOP) ? CONFIG.DBFS_TOP : BASE_TOP_DB;
const bottom = Number.isFinite(CONFIG.DBFS_BOTTOM) ? CONFIG.DBFS_BOTTOM : -63;
return { top, bottom };
}
function getFreqBounds(CONFIG, nyq) {
const min = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5 : 20;
const max = CONFIG.RTA_FREQ_RANGE === 'lf' ? 5000 : 20000;
return {
min: Math.max(10, min),
max: Math.min(Math.max(min + 1, max), nyq)
};
}
function buildFreqMap(height, freqBounds, fs, binCount) {
const nyq = fs / 2;
const minF = Math.max(10, freqBounds.min);
const maxF = Math.max(minF + 1, Math.min(freqBounds.max, nyq));
const denom = Math.max(1, height - 1);
const ratio = maxF / minF;
const map = new Array(height);
for (let y = 0; y < height; y++) {
const loPos = denom === 0 ? 0 : (y - 0.5) / denom;
const hiPos = denom === 0 ? 0 : (y + 0.5) / denom;
const fLo = minF * Math.pow(ratio, loPos < 0 ? 0 : loPos > 1 ? 1 : loPos);
const fHi = minF * Math.pow(ratio, hiPos < 0 ? 0 : hiPos > 1 ? 1 : hiPos);
const binLo = clampBin(Math.floor(freqToBin(fLo, nyq, binCount)), binCount);
const binHi = clampBin(Math.ceil(freqToBin(fHi, nyq, binCount)), binCount);
map[y] = {
binLo: Math.min(binLo, binHi),
binHi: Math.max(binLo, binHi)
};
}
return map;
}
function freqToBin(freq, nyq, binCount) {
if (!Number.isFinite(freq) || freq <= 0) return 0;
const maxIdx = Math.max(1, binCount - 1);
return (Math.min(freq, nyq) / nyq) * maxIdx;
}
function clampBin(idx, binCount) {
if (!Number.isFinite(idx)) return 0;
const maxIdx = Math.max(0, binCount - 1);
return idx < 0 ? 0 : idx > maxIdx ? maxIdx : idx;
}
function colorFromNorm(value, stops) {
const t = value < 0 ? 0 : value > 1 ? 1 : value;
for (let i = 1; i < stops.length; i++) {
const left = stops[i - 1];
const right = 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 = stops[stops.length - 1].color;
return [last[0], last[1], last[2]];
}
function layoutSpectroCanvas(state, plotX, plotY, plotW, plotH, widthPx, heightPx) {
const canvas = ensureSpectroCanvasElement(state);
if (!canvas) return;
canvas.style.left = `${plotX}px`;
canvas.style.top = `${plotY}px`;
canvas.style.width = `${plotW}px`;
canvas.style.height = `${plotH}px`;
if (!state.spectroCanvasTransferred) {
canvas.width = widthPx;
canvas.height = heightPx;
}
}
function ensureSpectroCanvasElement(state) {
if (state.spectroCanvasEl) return state.spectroCanvasEl;
let canvas = document.getElementById('spectroCanvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'spectroCanvas';
canvas.className = 'spectrogram-layer';
canvas.style.pointerEvents = 'none';
canvas.style.willChange = 'transform';
const overlay = document.getElementById('cv');
if (overlay && overlay.parentNode) {
overlay.parentNode.insertBefore(canvas, overlay);
} else {
document.body.appendChild(canvas);
}
}
state.spectroCanvasEl = canvas;
return canvas;
}
function removeSpectroCanvasElement(state) {
const canvas = state.spectroCanvasEl || document.getElementById('spectroCanvas');
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
}
state.spectroCanvasEl = null;
state.spectroCanvasTransferred = false;
}
+471
View File
@@ -0,0 +1,471 @@
// views/split_view.js — Split-View: zwei nebeneinander gerenderte Views (links/rechts)
import * as viewGoni from './goniometer_rtw.js';
import * as viewPhaseWheel from './phase_wheel.js';
import * as viewPanel from './panel.js';
import * as viewRealtime from './realtime.js';
import * as viewClassicNeedles from './classic_needles.js';
import * as viewPeakHistory from './peak_history.js';
import * as viewClock from './clock.js';
import * as viewWaveform from './waveform.js';
import * as viewSpectrogram from './spectrogram.js';
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
export const id = 'split-view';
const CONTENT_TOP = DEFAULT_TOP_INSET;
const CONTENT_BOTTOM = 0;
const CHILD_VIEWS = {
'realtime': viewRealtime,
'classic-needles': viewClassicNeedles,
'peak-history': viewPeakHistory,
'goniometer-rtw': viewGoni,
'phase-wheel': viewPhaseWheel,
'panel': viewPanel,
'clock': viewClock,
'waveform': viewWaveform,
'spectrogram': viewSpectrogram,
};
const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
const ALLOWED_SPLIT_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']);
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
const OUTER_GAP = 10;
const SIDE_INNER_GAP = 8;
const METER_GAP = 12;
const METER_W_DEFAULT = 140;
const METER_W_MIN = 90;
const MIN_PLOT_W = 240;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
function sanitizeChildId(val, fallback) {
return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback;
}
function sanitizePlotId(val, fallback) {
return ALLOWED_SPLIT_PLOT_IDS.has(val) ? val : fallback;
}
function sanitizeMeterId(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_IDS.has(id) ? id : fallback;
}
function sanitizeMeterPos(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
}
function clampMeterCount(val) {
const n = Number(val);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(3, n | 0));
}
async function withClippedSubRect(g, rect, fn) {
g.save();
g.translate(rect.x, rect.y);
g.beginPath();
g.rect(0, 0, rect.w, rect.h);
g.clip();
try { return await fn(); } finally { g.restore(); }
}
function drawSubframe(g, rect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
g.restore();
}
function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx, rect);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
function drawEmptyPlot(state, g, rect, label) {
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
lg.fillStyle = '#9aa';
lg.textAlign = 'left';
lg.font = 'bold 14px ui-monospace, monospace';
lg.fillText(label || '(leer)', 12, 32);
lg.textAlign = 'start';
});
}
function readSplitMeters(CONFIG) {
const count = clampMeterCount(CONFIG?.SPLIT_VIEW_METER_COUNT);
const slots = [
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_1, 'vu'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_1_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_2, 'ppm-din'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_2_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_3, 'lufs'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_3_POS, 'right'),
},
].slice(0, count);
return slots;
}
function groupMetersByPosition(slots) {
const out = { left: [], center: [], right: [] };
for (const s of slots || []) {
if (!s) continue;
if (s.pos === 'left') out.left.push(s.id);
else if (s.pos === 'center') out.center.push(s.id);
else out.right.push(s.id);
}
return out;
}
function computeSplitLayout(rect, slots) {
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
const hasContent = contentH >= 140;
const BLOCK_GAP = SIDE_INNER_GAP;
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
while (true) {
const grouped = groupMetersByPosition(effectiveSlots);
const leftCount = grouped.left.length;
const centerCount = grouped.center.length;
const rightCount = grouped.right.length;
const totalSlots = leftCount + centerCount + rightCount;
const hasLeftMeters = hasContent && leftCount > 0;
const hasCenterMeters = hasContent && centerCount > 0;
const hasRightMeters = hasContent && rightCount > 0;
const interBlockGaps =
(hasLeftMeters ? BLOCK_GAP : 0) +
(hasRightMeters ? BLOCK_GAP : 0) +
(hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP);
const internalGaps =
Math.max(0, leftCount - 1) * METER_GAP +
Math.max(0, centerCount - 1) * METER_GAP +
Math.max(0, rightCount - 1) * METER_GAP;
const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps;
const remainingForMeters = rect.w - minRequired;
let slotW = 0;
if (totalSlots > 0) {
slotW = Math.floor(remainingForMeters / totalSlots);
slotW = Math.min(METER_W_DEFAULT, slotW);
}
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0;
const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0;
const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0;
const metersTotalW = leftW + centerW + rightW;
const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW);
const leftPlotW = Math.floor(plotAvail / 2);
const rightPlotW = plotAvail - leftPlotW;
const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W;
if (!plotFits && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
let x = 0;
const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null;
if (leftMetersRect) x += leftW + BLOCK_GAP;
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
x += leftPlotW;
let centerMetersRect = null;
if (hasCenterMeters) {
x += BLOCK_GAP;
centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH };
x += centerW + BLOCK_GAP;
} else {
x += OUTER_GAP;
}
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
x += rightPlotW;
let rightMetersRect = null;
if (hasRightMeters) {
x += BLOCK_GAP;
rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH };
}
return {
plots: { left: leftPlotRect, right: rightPlotRect },
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
meterIds: grouped,
slotW,
effectiveSlots,
};
}
}
async function drawMetersPanel(env, state, rect, meterIds, slotW) {
const { ctx: g, meters, config: CONFIG } = env;
if (!rect || rect.w <= 0 || rect.h <= 0) return;
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
const count = ids.length;
if (!count) return;
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
});
const gap = METER_GAP;
const n = Math.max(1, Math.min(3, count));
const usedW = n * slotW + (n - 1) * gap;
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const slotY = rect.y + METER_PAD_TOP + innerOffset;
const slotH = innerHeight;
for (let i = 0; i < n; i++) {
const id = sanitizeMeterId(ids[i], 'none');
const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH };
if (id === 'none') {
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => {
lg.strokeStyle = 'rgba(0,231,255,0.25)';
lg.setLineDash([6, 5]);
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
lg.setLineDash([]);
lg.fillStyle = '#9aa';
lg.textAlign = 'center';
lg.font = '12px ui-monospace, monospace';
lg.fillText('(leer)', r.w / 2, 22);
lg.textAlign = 'start';
});
} else {
try {
g.save();
g.beginPath();
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
g.clip();
await meters.draw(g, r, id, CONFIG);
g.restore();
} catch (e) {
g.restore();
console.warn('Split meter draw error:', e);
}
}
}
}
function destroyChild(child) {
if (!child || child.id === 'none') return;
const mod = CHILD_VIEWS[child.id];
if (mod && typeof mod.destroy === 'function') {
try { mod.destroy(child.state); } catch (e) { console.warn('Split child destroy error:', e); }
}
}
function initChild(env, childId) {
const id = sanitizeChildId(childId, 'none');
if (id === 'none') return { id: 'none', state: {} };
const mod = CHILD_VIEWS[id];
const state = (mod && typeof mod.init === 'function') ? (mod.init(env) || {}) : {};
return { id, state };
}
function resizeChild(env, rect, child) {
if (!child || child.id === 'none') return;
const mod = CHILD_VIEWS[child.id];
if (!mod || typeof mod.resize !== 'function') return;
try { mod.resize({ rect }, child.state); } catch (e) { console.warn('Split child resize error:', e); }
}
async function renderChild(env, rect, child) {
const { ctx: g } = env;
if (!child || child.id === 'none') return;
const mod = CHILD_VIEWS[child.id];
if (!mod || typeof mod.render !== 'function') return;
await withClippedSubRect(g, rect, async () => {
const plotOnlySlots = (viewId) => {
if (viewId === 'peak-history' || viewId === 'classic-needles' || viewId === 'panel') {
const configured = env?.slots?.(viewId);
if (Array.isArray(configured) && configured.length) return configured;
}
if (viewId === 'peak-history') return ['ppm-din'];
if (viewId === 'classic-needles') return ['vu'];
if (viewId === 'panel') return ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms'];
return ['none'];
};
const subEnv = Object.assign({}, env, {
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
embedded: true,
containerView: 'split-view',
slots: plotOnlySlots,
embeddedOffsetX: rect.x,
embeddedOffsetY: rect.y,
});
await mod.render(subEnv, child.state);
});
}
export function init(env) {
const leftId = sanitizePlotId(env?.config?.SPLIT_VIEW_LEFT, 'phase-wheel');
const rightId = sanitizePlotId(env?.config?.SPLIT_VIEW_RIGHT, 'realtime');
const state = {
staticLayers: new Map(),
left: initChild(env, leftId),
right: initChild(env, rightId),
popup: initChild(env, 'none'),
lastLeftId: leftId,
lastRightId: rightId,
lastPopupId: 'none',
lastRectSig: '',
};
return state;
}
export function destroy(state) {
if (state) state.staticLayers = null;
destroyChild(state?.left);
destroyChild(state?.right);
destroyChild(state?.popup);
}
export function resize({ rect }, state) {
if (!state || !rect) return;
const sig = `${rect.w}x${rect.h}`;
if (state.lastRectSig === sig) return;
state.lastRectSig = sig;
const layout = computeLayout(rect);
resizeChild(null, { x: 0, y: 0, w: layout.left.w, h: layout.left.h }, state.left);
resizeChild(null, { x: 0, y: 0, w: layout.right.w, h: layout.right.h }, state.right);
resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup);
}
function computeLayout(rect) {
const gap = OUTER_GAP;
const w = Math.max(0, rect.w);
const h = Math.max(0, rect.h);
const half = Math.floor((w - gap) / 2);
const left = { x: 0, y: 0, w: Math.max(0, half), h };
const right = { x: Math.max(0, half + gap), y: 0, w: Math.max(0, w - (half + gap)), h };
return { left, right, gap };
}
export async function render(env, state) {
const { ctx: g, rect, config: CONFIG } = env;
const popupCfg = env?.splitPopup;
const popupWanted = popupCfg && popupCfg.open ? sanitizeChildId(popupCfg.viewId, 'none') : 'none';
// Popup-Modus: rendere die View in Originalgröße (vollflächig),
// ohne Split-Hintergrund/Layout. So sieht es exakt wie die Einzel-View aus.
if (popupWanted !== 'none' && state) {
if (state.lastPopupId !== popupWanted) {
destroyChild(state.popup);
state.popup = initChild(env, popupWanted);
state.lastPopupId = popupWanted;
resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup);
}
state._splitHit = {
contentY: 0,
plots: null,
meters: [],
popupBox: { x: 0, y: 0, w: rect.w, h: rect.h },
};
const mod = CHILD_VIEWS[state.popup.id];
if (mod && typeof mod.render === 'function') {
const subEnv = Object.assign({}, env, {
rect: { x: 0, y: 0, w: rect.w, h: rect.h },
embedded: false,
});
await mod.render(subEnv, state.popup.state);
}
return;
}
if (state && state.lastPopupId !== 'none') {
destroyChild(state.popup);
state.popup = initChild(env, 'none');
state.lastPopupId = 'none';
}
const desiredLeftId = sanitizePlotId(env?.config?.SPLIT_VIEW_LEFT, 'phase-wheel');
const desiredRightId = sanitizePlotId(env?.config?.SPLIT_VIEW_RIGHT, 'realtime');
if (state.lastLeftId !== desiredLeftId) {
destroyChild(state.left);
state.left = initChild(env, desiredLeftId);
state.lastLeftId = desiredLeftId;
}
if (state.lastRightId !== desiredRightId) {
destroyChild(state.right);
state.right = initChild(env, desiredRightId);
state.lastRightId = desiredRightId;
}
const slots = readSplitMeters(CONFIG);
const layout = computeSplitLayout(rect, slots);
const leftPlotRect = layout?.plots?.left || { x: 0, y: 0, w: Math.floor(rect.w / 2), h: rect.h };
const rightPlotRect = layout?.plots?.right || { x: Math.floor(rect.w / 2), y: 0, w: rect.w - Math.floor(rect.w / 2), h: rect.h };
if (state) {
state._splitHit = {
contentY: 0,
plots: {
left: { ...leftPlotRect, viewId: desiredLeftId },
right: { ...rightPlotRect, viewId: desiredRightId },
},
meters: [layout?.meters?.left, layout?.meters?.center, layout?.meters?.right].filter(Boolean).map((r) => ({ ...r })),
popupBox: null,
};
}
if (state.left?.id === 'none') drawEmptyPlot(state, g, leftPlotRect, 'Links: (leer)');
else await renderChild(env, leftPlotRect, state.left);
if (state.right?.id === 'none') drawEmptyPlot(state, g, rightPlotRect, 'Rechts: (leer)');
else await renderChild(env, rightPlotRect, state.right);
if (layout?.meters?.left) await drawMetersPanel(env, state, layout.meters.left, layout.meterIds.left, layout.slotW);
if (layout?.meters?.center) await drawMetersPanel(env, state, layout.meters.center, layout.meterIds.center, layout.slotW);
if (layout?.meters?.right) await drawMetersPanel(env, state, layout.meters.right, layout.meterIds.right, layout.slotW);
}
+24
View File
@@ -0,0 +1,24 @@
export function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx, rect);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
+487
View File
@@ -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;
}