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