Initial Phoenix analyzer
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
// 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));
|
||||
let weightSum = 0;
|
||||
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: overlap });
|
||||
weightSum += overlap;
|
||||
}
|
||||
}
|
||||
if (!bins.length) {
|
||||
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
|
||||
bins.push({ index: idx, weight: 1 });
|
||||
weightSum = 1;
|
||||
}
|
||||
bins.forEach((b) => { b.weight /= weightSum || 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();
|
||||
}
|
||||
Reference in New Issue
Block a user