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