tcdweb/js/analyzer.js

431 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-07-05 00:44:08 +02:00
const Analyzer = {
detectBPM(pcmData, sampleRate, channels) {
const frameSize = 2048;
const hopSize = 256;
const maxSecs = 60;
const totalSamples = pcmData.length / channels;
const limitSamples = Math.min(totalSamples, Math.floor(sampleRate * maxSecs));
if (limitSamples < sampleRate * 3) return 0;
const numFrames = Math.max(1, Math.floor((limitSamples - frameSize) / hopSize) + 1);
const onset = new Float64Array(numFrames);
for (let f = 0; f < numFrames; f++) {
let energy = 0;
const start = f * hopSize * channels;
const end = Math.min(start + frameSize * channels, limitSamples * channels);
for (let i = start; i < end; i += channels) {
const s = pcmData[i];
energy += s * s;
}
onset[f] = energy;
}
const odf = new Float64Array(numFrames);
for (let f = 1; f < numFrames; f++) {
const d = onset[f] - onset[f - 1];
odf[f] = d > 0 ? d : 0;
}
let maxOdf = 0;
for (let i = 0; i < numFrames; i++) if (odf[i] > maxOdf) maxOdf = odf[i];
if (maxOdf > 0) for (let i = 0; i < numFrames; i++) odf[i] /= maxOdf;
let meanOdf = 0;
for (let i = 0; i < numFrames; i++) meanOdf += odf[i];
meanOdf /= numFrames;
for (let i = 0; i < numFrames; i++) odf[i] -= meanOdf;
const minBPM = 30;
const maxBPM = 300;
const secsPerHop = hopSize / sampleRate;
const minLag = Math.ceil(60 / (maxBPM * secsPerHop));
const maxLag = Math.floor(60 / (minBPM * secsPerHop));
if (minLag >= numFrames || maxLag < minLag) return 0;
const acLen = maxLag - minLag + 1;
const ac = new Float64Array(acLen);
for (let lag = minLag; lag <= maxLag; lag++) {
let s = 0;
const n = numFrames - lag;
for (let i = 0; i < n; i++) s += odf[i] * odf[i + lag];
ac[lag - minLag] = n > 0 ? s / n : 0;
}
const interpAC = (lag) => {
const idx = lag - minLag;
const i = Math.floor(idx);
const f = idx - i;
if (i < 0 || i + 1 >= acLen) return 0;
return ac[i] + f * (ac[i + 1] - ac[i]);
};
const peaks = [];
for (let i = 1; i < acLen - 1; i++) {
if (ac[i] > ac[i - 1] && ac[i] >= ac[i + 1]) {
const a = ac[i - 1];
const b = ac[i];
const c = ac[i + 1];
const denom = a - 2 * b + c;
if (Math.abs(denom) < 1e-12) continue;
const p = 0.5 * (a - c) / denom;
const peakLag = (i + minLag) + p;
if (peakLag <= 0) continue;
const bpm = 60 / (peakLag * secsPerHop);
if (bpm >= minBPM && bpm <= maxBPM) {
const interpVal = b + 0.25 * (a - c) * p;
peaks.push({ bpm, lag: peakLag, score: interpVal });
}
}
}
if (peaks.length === 0) return 0;
peaks.sort((a, b) => b.score - a.score);
const fastBPM = (lag) => 60 / (lag * secsPerHop);
let bestBPM = peaks[0].bpm;
let bestScore = peaks[0].score;
for (const pk of peaks) {
const lag = pk.lag;
const acBase = pk.score;
let hScore = acBase;
for (let div = 2; div <= 8; div *= 2) {
const fl = lag / div;
if (fl >= minLag && fl <= maxLag) {
hScore += interpAC(fl) * (1.0 / div);
}
}
if (hScore > bestScore) {
bestScore = hScore;
bestBPM = pk.bpm;
}
}
for (const pk of peaks) {
const lag = pk.lag;
for (let div = 2; div <= 8; div *= 2) {
const fl = lag / div;
if (fl >= minLag && fl <= maxLag) {
const acDiv = interpAC(fl);
if (acDiv > pk.score * 0.35) {
const candBPM = fastBPM(fl);
if (candBPM >= minBPM && candBPM <= maxBPM) {
bestBPM = candBPM;
return bestBPM;
}
}
}
}
}
return bestBPM;
},
applyHann(buf) {
const n = buf.length;
for (let i = 0; i < n; i++)
buf[i] *= 0.5 * (1.0 - Math.cos(2.0 * Math.PI * i / (n - 1)));
},
fftRadix2(re, im, n, inv) {
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j) {
let tr = re[i]; re[i] = re[j]; re[j] = tr;
let ti = im[i]; im[i] = im[j]; im[j] = ti;
}
}
for (let len = 2; len <= n; len <<= 1) {
const ang = 2.0 * Math.PI / len * (inv ? -1 : 1);
const wr = Math.cos(ang), wi = Math.sin(ang);
for (let i = 0; i < n; i += len) {
let cr = 1.0, ci = 0.0;
for (let j = 0; j < len / 2; j++) {
const a = i + j, b = a + len / 2;
const tr = cr * re[b] - ci * im[b];
const ti = cr * im[b] + ci * re[b];
re[b] = re[a] - tr; im[b] = im[a] - ti;
re[a] += tr; im[a] += ti;
const ncr = cr * wr - ci * wi;
const nci = cr * wi + ci * wr;
cr = ncr; ci = nci;
}
}
}
if (inv)
for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
},
analyzeAudio(pcmData, sampleRate, channels, fftSize) {
const power = new Float64Array(fftSize / 2);
let count = 0;
const frame = pcmData;
const buf = new Float64Array(fftSize);
const re = new Float64Array(fftSize);
const im = new Float64Array(fftSize);
const overlap = 2;
const step = fftSize / overlap;
for (let ch = 0; ch < channels; ch++) {
for (let pos = 0; pos + fftSize <= frame.length / channels; pos += step) {
for (let i = 0; i < fftSize; i++)
buf[i] = frame[(pos + i) * channels + ch];
this.applyHann(buf);
re.set(buf);
im.fill(0);
this.fftRadix2(re, im, fftSize, 0);
for (let i = 0; i < fftSize / 2; i++)
power[i] += re[i] * re[i] + im[i] * im[i];
count++;
}
}
if (count === 0) return null;
return { power, count, sampleRate, channels, fftSize };
},
detectCutoff(a, thresholdDb, sensitivity) {
const n = a.fftSize / 2;
const sr = a.sampleRate;
const mag = new Float64Array(n);
let peak = 0.0;
for (let i = 0; i < n; i++) {
mag[i] = Math.sqrt(a.power[i] / a.count);
if (mag[i] > peak) peak = mag[i];
}
if (peak < 1e-12) return { score: 0, cutoff: 0 };
const threshold = peak * Math.pow(10.0, thresholdDb / 20.0);
let cutoffHz = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= threshold) { cutoffHz = i * sr / a.fftSize; break; }
}
const lowThresh60 = peak * 0.001;
let cutoff60Hz = 0;
let cutoff60Bin = n - 1;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= lowThresh60) { cutoff60Hz = i * sr / a.fftSize; cutoff60Bin = i; break; }
}
const highThresh = peak * 0.1;
let cutoffHighHz = 0;
const startBin = cutoff60Bin > 0 ? cutoff60Bin : n - 1;
for (let i = startBin; i >= 0; i--) {
if (mag[i] >= highThresh) { cutoffHighHz = i * sr / a.fftSize; break; }
}
let transitionBw = (cutoff60Hz > 0) ? cutoff60Hz - cutoffHighHz : 0;
if (transitionBw < 0) transitionBw = 0;
const steepness = transitionBw;
let noiseSum = 0, noiseCount = 0;
for (let i = n * 3 / 4; i < n; i++) {
if (mag[i] > 0) { noiseSum += mag[i]; noiseCount++; }
}
const noiseFloor = (noiseCount > 0) ? (noiseSum / noiseCount) : 1e-12;
const noiseDb = 20.0 * Math.log10(noiseFloor / peak);
let extDb = noiseDb + 6.0;
const minExtDb = thresholdDb - 30.0;
if (extDb < minExtDb) extDb = minExtDb;
const extPeak = peak * Math.pow(10.0, extDb / 20.0);
let extCutoff = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= extPeak) { extCutoff = i * sr / a.fftSize; break; }
}
const extendedCutoff = extCutoff;
let roughCutoff = cutoffHz;
if (noiseDb > thresholdDb) {
const adjDb = noiseDb + 10.0;
const adjThresh = peak * Math.pow(10.0, adjDb / 20.0);
let adjCutoff = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= adjThresh) { adjCutoff = i * sr / a.fftSize; break; }
}
if (adjCutoff > 0) roughCutoff = adjCutoff;
}
let roughness = 0.0;
const cutoffIdx = roughCutoff * a.fftSize / sr;
let lo = Math.floor(cutoffIdx * 0.60);
let hi = Math.floor(cutoffIdx * 0.95);
if (hi >= n) hi = n - 1;
if (lo < 1) lo = 1;
const noiseMag = Math.pow(10.0, noiseDb / 20.0) * peak;
const regionTotal = hi - lo + 1;
let aboveNoise = 0;
for (let i = lo; i <= hi; i++) {
if (mag[i] > noiseMag * 2.0) aboveNoise++;
}
if (aboveNoise / regionTotal < 0.30) {
roughness = 0.01;
} else if (hi > lo) {
let sum = 0;
for (let i = lo; i <= hi; i++) sum += mag[i];
const mean = sum / (hi - lo + 1);
if (mean > 1e-12) {
let varSum = 0;
for (let i = lo; i <= hi; i++) {
const dev = (mag[i] - mean) / mean;
varSum += dev * dev;
}
roughness = Math.sqrt(varSum / (hi - lo));
}
}
if (roughness < 0.01) roughness = 0.01;
let energyLow = 0, energyHigh = 0;
let elCount = 0, ehCount = 0;
for (let i = 0; i < n; i++) {
const f = i * sr / a.fftSize;
if (f >= 12000 && f < 16000) { energyLow += mag[i]; elCount++; }
if (f >= 16000 && f < 20000) { energyHigh += mag[i]; ehCount++; }
}
const bandRatio = (elCount > 0 && ehCount > 0)
? (energyHigh / ehCount) / (energyLow / elCount + 1e-12)
: 0.5;
const nyquist = sr / 2.0;
let decisionCutoff = cutoffHz;
if (noiseDb > thresholdDb) {
const dcDb = noiseDb + 10.0;
const dcThresh = peak * Math.pow(10.0, dcDb / 20.0);
let dc = 0;
for (let i = n - 1; i >= 0; i--) {
if (mag[i] >= dcThresh) { dc = i * sr / a.fftSize; break; }
}
if (dc > 0) decisionCutoff = dc;
}
const cutoffRatio = decisionCutoff / nyquist;
const effCutoffRatio = (extendedCutoff > cutoffHz && noiseDb >= -100.0)
? extendedCutoff / nyquist : cutoffRatio;
const bwFactor = Math.max(0.25, 2.0 * (1.0 - sensitivity));
const r1 = 0.40 * (1.0 + (0.5 - sensitivity) * 1.5);
const r2 = 0.30 * (1.0 + (0.5 - sensitivity) * 1.5);
const r3 = 0.20 * (1.0 + (0.5 - sensitivity) * 1.5);
const b1 = 0.90 - (0.5 - sensitivity) * 0.10;
const b2 = 0.85 - (0.5 - sensitivity) * 0.10;
const bypass = 0.99 - (0.5 - sensitivity) * 0.02;
let score = 0;
if (decisionCutoff <= 0 || cutoffRatio >= bypass) {
if (roughness > r1) score = 1;
else if (roughness > r2 && bandRatio < b1) score = 1;
else if (roughness > r3 && bandRatio < b2) score = 1;
return { score, cutoff: cutoffHz, steepness, noiseDb, roughness, bandRatio, extendedCutoff,
cutoffRatio, bypassed: true, mag, peak };
}
let maxBw;
if (cutoffRatio < 0.50) maxBw = 4000.0 * bwFactor;
else if (cutoffRatio < 0.70) maxBw = 3000.0 * bwFactor;
else if (cutoffRatio < 0.80) maxBw = 2000.0 * bwFactor;
else if (cutoffRatio < 0.90) maxBw = 1200.0 * bwFactor;
else maxBw = 500.0 * bwFactor;
if (transitionBw < maxBw) score = 1;
if (!score && cutoffRatio > 0.80) {
if (roughness > r1) score = 1;
else if (roughness > r2 && bandRatio < b1) score = 1;
else if (roughness > r3 && bandRatio < b2) score = 1;
}
return { score, cutoff: cutoffHz, steepness, noiseDb, roughness, bandRatio, extendedCutoff,
cutoffRatio, maxBw, transitionBw, bypassed: false, mag, peak };
},
computeConfidence(cutoffRatio, steepness, roughness, bandRatio, isNativeLossy) {
let conf = 0;
let count = 0;
if (isNativeLossy) {
if (cutoffRatio < 0.70) {
const margin = Math.min(1, (0.70 - cutoffRatio) / 0.70);
conf += 0.50 + 0.50 * margin;
count++;
} else if (cutoffRatio < 0.80) {
const margin = Math.min(1, (0.80 - cutoffRatio) / 0.80);
conf += 0.40 + 0.60 * margin;
count++;
} else if (cutoffRatio < 0.85) {
const margin = Math.min(1, (0.85 - cutoffRatio) / 0.85);
conf += 0.20 + 0.60 * margin;
count++;
} else {
conf += 0.70;
count++;
}
} else {
if (cutoffRatio < 0.50) {
const margin = Math.min(1, (0.50 - cutoffRatio) / 0.50);
const sMargin = Math.max(0, Math.min(1, (4000.0 - steepness) / 4000.0));
conf += 0.50 + 0.50 * (margin * 0.5 + sMargin * 0.5);
count++;
} else if (cutoffRatio < 0.70) {
const rMargin = Math.min(1, (0.70 - cutoffRatio) / 0.70);
const sMargin = Math.max(0, Math.min(1, (3000.0 - steepness) / 3000.0));
conf += 0.30 + 0.70 * (rMargin * 0.4 + sMargin * 0.6);
count++;
} else if (cutoffRatio < 0.80) {
const rMargin = Math.min(1, (0.80 - cutoffRatio) / 0.80);
const sMargin = Math.max(0, Math.min(1, (2000.0 - steepness) / 2000.0));
conf += 0.20 + 0.80 * (rMargin * 0.4 + sMargin * 0.6);
count++;
} else if (cutoffRatio < 0.90) {
const rMargin = Math.min(1, (0.90 - cutoffRatio) / 0.90);
const sMargin = Math.max(0, Math.min(1, (1200.0 - steepness) / 1200.0));
conf += 0.10 + 0.90 * (rMargin * 0.4 + sMargin * 0.6);
count++;
} else {
const sMargin = Math.max(0, Math.min(1, (500.0 - steepness) / 500.0));
conf += 0.20 + 0.80 * sMargin;
count++;
}
if (cutoffRatio > 0.80) {
if (roughness > 0.40) {
const margin = Math.min(1, (roughness - 0.40) / 0.40);
conf += 0.40 + 0.60 * margin;
count++;
} else if (roughness > 0.30 && bandRatio < 0.90) {
const rMargin = Math.min(1, (roughness - 0.30) / 0.10);
const bMargin = Math.min(1, (0.90 - bandRatio) / 0.90);
conf += 0.20 + 0.80 * (rMargin * 0.5 + bMargin * 0.5);
count++;
} else if (roughness > 0.20 && bandRatio < 0.85) {
const rMargin = Math.min(1, (roughness - 0.20) / 0.10);
const bMargin = Math.min(1, (0.85 - bandRatio) / 0.85);
conf += 0.10 + 0.90 * (rMargin * 0.5 + bMargin * 0.5);
count++;
}
}
}
if (count === 0) return 0;
return Math.max(0, Math.min(100, conf / count * 100.0));
}
};