mirror of
https://codeberg.org/armin/tcdweb.git
synced 2026-09-01 05:30:45 +02:00
431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
|
|
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));
|
||
|
|
}
|
||
|
|
};
|