315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
// core/utils.js — generische Helfer (ohne Seiteneffekte)
|
|
// Hinweis: Viele Funktionen nehmen CONFIG/Nyquist als Parameter, damit utils keine
|
|
// harte Abhängigkeit auf config.js haben. So bleiben die Module austauschbar.
|
|
|
|
// --- Math & Guards -----------------------------------------------------------
|
|
export const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
|
|
export function clampPow2(v, min = 2048, max = 16384) {
|
|
const allowed = [2048, 4096, 8192, 16384].filter(n => n >= min && n <= max);
|
|
return allowed.includes(v) ? v : Math.min(max, Math.max(min, v));
|
|
}
|
|
|
|
const nowTime = () => {
|
|
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
return performance.now();
|
|
}
|
|
return Date.now();
|
|
};
|
|
|
|
export function createPeakHoldState(initial = -160, now = nowTime(), holdMs = 0) {
|
|
const startValue = Number.isFinite(initial) ? initial : -160;
|
|
const tNow = Number.isFinite(now) ? now : nowTime();
|
|
const span = Math.max(0, Number.isFinite(holdMs) ? holdMs : 0);
|
|
return {
|
|
value: startValue,
|
|
holdUntil: tNow + span,
|
|
lastTs: tNow,
|
|
};
|
|
}
|
|
|
|
export function stepPeakHold(current, state, now = nowTime(), opts = {}) {
|
|
const holdMs = Math.max(0, Number.isFinite(opts.holdMs) ? opts.holdMs : 0);
|
|
const decayDbPerS = Math.max(0, Number.isFinite(opts.decayDbPerS) ? opts.decayDbPerS : 0);
|
|
const floor = Number.isFinite(opts.floor) ? opts.floor : -160;
|
|
const riseThreshold = Number.isFinite(opts.riseThreshold) ? opts.riseThreshold : 0.2;
|
|
const tNow = Number.isFinite(now) ? now : nowTime();
|
|
const sample = Number.isFinite(current) ? current : floor;
|
|
|
|
if (!state || typeof state !== 'object') {
|
|
state = createPeakHoldState(floor, tNow, holdMs);
|
|
}
|
|
|
|
if (!Number.isFinite(state.value)) state.value = floor;
|
|
if (!Number.isFinite(state.lastTs)) state.lastTs = tNow;
|
|
if (!Number.isFinite(state.holdUntil)) state.holdUntil = tNow + holdMs;
|
|
|
|
const holdEnd = tNow + holdMs;
|
|
if (state.holdUntil > holdEnd) state.holdUntil = holdEnd;
|
|
|
|
if (sample >= state.value + riseThreshold) {
|
|
state.value = sample;
|
|
state.holdUntil = tNow + holdMs;
|
|
state.lastTs = tNow;
|
|
} else if (tNow <= state.holdUntil) {
|
|
state.lastTs = tNow;
|
|
} else {
|
|
const dt = Math.max(0, (tNow - state.lastTs) / 1000);
|
|
if (dt > 0 && decayDbPerS > 0) {
|
|
state.value = Math.max(floor, state.value - decayDbPerS * dt);
|
|
}
|
|
state.lastTs = tNow;
|
|
}
|
|
|
|
return state.value;
|
|
}
|
|
|
|
// --- dBFS Mapping ------------------------------------------------------------
|
|
export function yFromDbfs(db, yTop, yBot, CONFIG) {
|
|
const top = CONFIG.DBFS_TOP, bot = CONFIG.DBFS_BOTTOM;
|
|
const d = Math.max(bot, Math.min(top, db));
|
|
const t = (d - bot) / (top - bot);
|
|
return yBot - t * (yBot - yTop);
|
|
}
|
|
|
|
export function mapFRect(f, x0, w, nyq) {
|
|
const f0 = 20, f1 = nyq, fx = Math.max(f, f0);
|
|
const lx = (Math.log10(fx) - Math.log10(f0)) / (Math.log10(f1) - Math.log10(f0));
|
|
return x0 + lx * w;
|
|
}
|
|
|
|
// --- Bänder & Aggregation ----------------------------------------------------
|
|
export function makeIECThirdOctCenters(nyq) {
|
|
const base = [20,25,31.5,40,50,63,80,100,125,160,200,250,315,400,500,630,800,1000,1250,1600,2000,2500,3150,4000,5000,6300,8000,10000,12500,16000,20000,21000];
|
|
return base.filter(f => f < nyq * 0.999);
|
|
}
|
|
|
|
export function avgBandDB(fLo, fHi, data, nyq) {
|
|
const n = data.length;
|
|
const binLo = Math.max(0, Math.min(n - 1, Math.round((fLo / nyq) * n)));
|
|
const binHi = Math.max(0, Math.min(n - 1, Math.round((fHi / nyq) * n)));
|
|
let sum = 0, count = 0;
|
|
for (let i = binLo; i <= binHi; i++) {
|
|
sum += Math.pow(10, data[i] / 10);
|
|
count++;
|
|
}
|
|
return count > 0 ? 10 * Math.log10(sum / count) : -160;
|
|
}
|
|
|
|
// ----- Fractional octave helpers (IEC 61260-1) ------------------------------
|
|
const BPO_VALUE_MAP = { '1_3': 3, '1_6': 6, '1_12': 12 };
|
|
|
|
export function resolveBpoValue(mode) {
|
|
if (typeof mode === 'number' && Number.isFinite(mode)) return mode;
|
|
return BPO_VALUE_MAP[String(mode)] || 6;
|
|
}
|
|
|
|
export function makeFractionalOctaveBands(nyq = 24000, mode = '1_6', opts = {}) {
|
|
const bpo = resolveBpoValue(mode);
|
|
const fMin = Math.max(5, opts.fMin ?? 20);
|
|
const fMax = Math.max(fMin * 1.001, Math.min(opts.fMax ?? nyq, nyq));
|
|
const centerRef = opts.reference ?? 1000;
|
|
const kMin = Math.ceil(bpo * Math.log2(fMin / centerRef));
|
|
const kMax = Math.floor(bpo * Math.log2(fMax / centerRef));
|
|
const bands = [];
|
|
const factor = Math.pow(2, 1 / (2 * bpo));
|
|
for (let k = kMin; k <= kMax; k++) {
|
|
const center = centerRef * Math.pow(2, k / bpo);
|
|
const lo = center / factor;
|
|
const hi = center * factor;
|
|
if (hi < fMin) continue;
|
|
if (lo > fMax) break;
|
|
bands.push({
|
|
center,
|
|
fLo: Math.max(lo, fMin),
|
|
fHi: Math.min(hi, fMax),
|
|
});
|
|
}
|
|
return bands;
|
|
}
|
|
|
|
export function buildBandBinMapping(bands, nyq, binCount) {
|
|
const result = [];
|
|
if (!Array.isArray(bands) || !Number.isFinite(nyq) || !Number.isFinite(binCount) || binCount <= 0) {
|
|
return result;
|
|
}
|
|
const binWidth = nyq / binCount;
|
|
for (const band of bands) {
|
|
const bins = [];
|
|
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
|
|
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
|
|
for (let i = start; i <= end; i++) {
|
|
const binStart = i * binWidth;
|
|
const binEnd = binStart + binWidth;
|
|
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
|
|
if (overlap > 0) {
|
|
bins.push({ index: i, weight: Math.min(1, overlap / binWidth) });
|
|
}
|
|
}
|
|
if (!bins.length) {
|
|
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
|
|
bins.push({ index: idx, weight: 1 });
|
|
}
|
|
result.push({
|
|
center: band.center,
|
|
fLo: band.fLo,
|
|
fHi: band.fHi,
|
|
bins,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function computeBandLevels(mappedBands, freqData, opts = {}) {
|
|
if (!Array.isArray(mappedBands) || !freqData) return [];
|
|
const floor = Number.isFinite(opts.floor) ? opts.floor : -160;
|
|
const weightingFn = typeof opts.weightingFn === 'function' ? opts.weightingFn : null;
|
|
const out = new Array(mappedBands.length);
|
|
for (let i = 0; i < mappedBands.length; i++) {
|
|
const band = mappedBands[i];
|
|
let energy = 0;
|
|
for (const seg of band.bins) {
|
|
const val = freqData[seg.index];
|
|
if (!Number.isFinite(val)) continue;
|
|
energy += Math.pow(10, val / 10) * seg.weight;
|
|
}
|
|
const weightedDb = weightingFn ? weightingFn(band.center) : 0;
|
|
const lin = Math.max(1e-12, energy * Math.pow(10, weightedDb / 10));
|
|
out[i] = 10 * Math.log10(lin);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function weightingDb(freq, mode = 'z') {
|
|
if (!Number.isFinite(freq) || freq <= 0) return 0;
|
|
const f2 = freq * freq;
|
|
if (mode === 'a') {
|
|
const raNum = (12194 ** 2) * f2 * f2;
|
|
const raDen = (f2 + 20.6 ** 2) * Math.sqrt((f2 + 107.7 ** 2) * (f2 + 737.9 ** 2)) * (f2 + 12194 ** 2);
|
|
const ra = raNum / raDen;
|
|
return 20 * Math.log10(ra) + 2.0;
|
|
}
|
|
if (mode === 'c') {
|
|
const rcNum = (12194 ** 2) * f2;
|
|
const rcDen = (f2 + 20.6 ** 2) * (f2 + 12194 ** 2);
|
|
const rc = rcNum / rcDen;
|
|
return 20 * Math.log10(rc) + 0.06;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// --- Korrelation -------------------------------------------------------------
|
|
export function correlation(xyL, xyR, smoothPrev = 0, smooth = 0.85) {
|
|
const n = Math.min(xyL?.length || 0, xyR?.length || 0);
|
|
if (!n) return 0;
|
|
let sLL = 0, sRR = 0, sLR = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
const a = xyL[i], b = xyR[i];
|
|
sLL += a * a; sRR += b * b; sLR += a * b;
|
|
}
|
|
const denom = Math.sqrt((sLL + 1e-12) * (sRR + 1e-12));
|
|
const r = denom > 0 ? sLR / denom : 0;
|
|
return smooth * smoothPrev + (1 - smooth) * Math.max(-1, Math.min(1, r));
|
|
}
|
|
|
|
// --- Labels & Ticks ----------------------------------------------------------
|
|
export function freqTickLabels(nyq, fMin = 20) {
|
|
const ticks = [
|
|
5, 10, 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
|
|
200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
|
|
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
|
|
];
|
|
const min = Math.max(5, fMin);
|
|
return ticks.filter((f) => f >= min && f < nyq);
|
|
}
|
|
|
|
export function fmtFreq(f) {
|
|
if (f >= 1000) return (f / 1000) + 'k';
|
|
return (f % 1 ? f : Math.round(f)).toString();
|
|
}
|
|
|
|
// --- Rechteck-Helper ---------------------------------------------------------
|
|
// --- Zeichnen: Rahmen (ruhig hier, damit Views sich nicht wiederholen) -------
|
|
export function strokeRect(ctx, rect, color = '#00e7ff', lw = 2) {
|
|
ctx.save();
|
|
ctx.strokeStyle = color; ctx.lineWidth = lw;
|
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
|
|
ctx.restore();
|
|
}
|
|
|
|
export function fillRect(ctx, rect, color) {
|
|
ctx.save();
|
|
ctx.fillStyle = color; ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
ctx.restore();
|
|
}
|
|
|
|
// dBFS Raster als Utility, damit Bars/Line denselben Look teilen
|
|
export function drawDbfsGridRect(ctx, x0, y0, w, h, CONFIG, nyq, opts = {}) {
|
|
const {
|
|
colorMajor = '#1e2a35',
|
|
colorMinor = 'rgba(30,42,53,.55)',
|
|
labelColor = '#8fd3d4',
|
|
freqTicks = null,
|
|
freqMin = 20,
|
|
refDb = null,
|
|
refStyle = 'rgba(0,231,255,0.35)',
|
|
refDash = [4, 3],
|
|
} = opts;
|
|
const yTicks = (CONFIG.Y_TICKS && CONFIG.Y_TICKS.length)
|
|
? CONFIG.Y_TICKS
|
|
: [9, 0, -9, -18, -27, -36, -45, -54, -63];
|
|
|
|
const _g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT);
|
|
const gutterL = Number.isFinite(_g) ? Math.max(8, _g) : 14;
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2; ctx.strokeRect(x0 - gutterL, y0, w + gutterL, h);
|
|
ctx.font = 'bold 14px ui-monospace, monospace'; ctx.fillStyle = labelColor;
|
|
|
|
// Grid + Labels at fixed ticks
|
|
for (const v of yTicks) {
|
|
if (v > CONFIG.DBFS_TOP || v < CONFIG.DBFS_BOTTOM) continue;
|
|
const y = yFromDbfs(v, y0, y0 + h, CONFIG);
|
|
const labelY = Math.min(y0 + h - 4, Math.max(y0 + 12, y + 4));
|
|
ctx.strokeStyle = colorMajor; ctx.lineWidth = 1.2;
|
|
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y); ctx.stroke();
|
|
ctx.textAlign = 'right'; ctx.fillText(String(v), (x0 - gutterL) - 6, labelY);
|
|
}
|
|
|
|
// Reference line (e.g. alignment level)
|
|
if (Number.isFinite(refDb) && refDb <= CONFIG.DBFS_TOP && refDb >= CONFIG.DBFS_BOTTOM) {
|
|
const yRef = yFromDbfs(refDb, y0, y0 + h, CONFIG);
|
|
ctx.strokeStyle = refStyle;
|
|
ctx.setLineDash(Array.isArray(refDash) ? refDash : [4, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x0, yRef);
|
|
ctx.lineTo(x0 + w, yRef);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
|
|
|
|
// Frequenz-Ticks (x-Achse)
|
|
ctx.textAlign = 'center'; ctx.fillStyle = labelColor;
|
|
const ticks = Array.isArray(freqTicks) && freqTicks.length
|
|
? freqTicks.filter((f) => Number.isFinite(f) && f > 0 && f < nyq)
|
|
: freqTickLabels(nyq, freqMin);
|
|
const minFreq = Math.max(5, freqMin);
|
|
const logMin = Math.log10(minFreq);
|
|
const logSpan = Math.max(1e-6, Math.log10(nyq) - logMin);
|
|
for (const f of ticks) {
|
|
const x = x0 + ((Math.log10(Math.max(f, minFreq)) - logMin) / logSpan) * w;
|
|
// Linken Rand nicht doppeln: keine Vertikal-Linie auf dem linken Plot-Rand
|
|
if (x > x0 + 0.75) {
|
|
ctx.strokeStyle = '#131b24';
|
|
ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h); ctx.stroke();
|
|
}
|
|
ctx.fillText(fmtFreq(f), x, y0 + h + 18);
|
|
}
|
|
ctx.textAlign = 'start';
|
|
|
|
|
|
ctx.restore();
|
|
}
|