import FFT from "fft.js"; import { decodeAudio } from "./audioProcessing"; export type AudioAnalysis = { durationSeconds: number; sampleRate: number; channels: number; peakDbfs: number; rmsDbfs: number; crestFactorDb: number; estimatedCutoffHz: number; qualityLabel: "Full Spectrum" | "Suspect Quality" | "Likely Transcode" | "Fake Lossless"; spectrogram: number[][]; }; function db(value: number) { return 20 * Math.log10(Math.max(value, 1e-12)); } function hann(index: number, size: number) { return 0.5 * (1 - Math.cos((2 * Math.PI * index) / Math.max(1, size - 1))); } function monoAt(buffer: AudioBuffer, index: number) { let value = 0; for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) { value += buffer.getChannelData(channel)[index] || 0; } return value / Math.max(1, buffer.numberOfChannels); } function qualityFromCutoff(cutoff: number, nyquist: number): AudioAnalysis["qualityLabel"] { const effective = Math.min(cutoff, nyquist); if (effective < 14_000) return "Fake Lossless"; if (effective < 17_000) return "Likely Transcode"; if (effective < 19_500 && nyquist >= 20_000) return "Suspect Quality"; return "Full Spectrum"; } export async function analyzeAudioFile(file: File): Promise { const buffer = await decodeAudio(file); let peak = 0; let sumSquares = 0; let samples = 0; const stride = Math.max(1, Math.floor(buffer.length / 2_000_000)); for (let index = 0; index < buffer.length; index += stride) { const sample = monoAt(buffer, index); const abs = Math.abs(sample); if (abs > peak) peak = abs; sumSquares += sample * sample; samples += 1; } const rms = Math.sqrt(sumSquares / Math.max(1, samples)); const peakDbfs = db(peak); const rmsDbfs = db(rms); const fftSize = 4096; const fft = new FFT(fftSize); const input = new Array(fftSize).fill(0); const output = fft.createComplexArray(); const slices = Math.min(96, Math.max(24, Math.floor(buffer.duration * 2))); const bins = 128; const spectrogram: number[][] = []; const aggregate = new Float64Array(fftSize / 2); for (let slice = 0; slice < slices; slice += 1) { const center = Math.floor(((slice + 0.5) / slices) * buffer.length); const start = Math.max(0, Math.min(buffer.length - fftSize, center - fftSize / 2)); for (let index = 0; index < fftSize; index += 1) { input[index] = monoAt(buffer, start + index) * hann(index, fftSize); } fft.realTransform(output, input); fft.completeSpectrum(output); const magnitudes = new Float64Array(fftSize / 2); let maxMagnitude = 1e-12; for (let bin = 0; bin < fftSize / 2; bin += 1) { const real = output[2 * bin]; const imag = output[2 * bin + 1]; const magnitude = Math.sqrt(real * real + imag * imag); magnitudes[bin] = magnitude; aggregate[bin] += magnitude; if (magnitude > maxMagnitude) maxMagnitude = magnitude; } const row: number[] = []; for (let displayBin = 0; displayBin < bins; displayBin += 1) { const sourceStart = Math.floor((displayBin / bins) * magnitudes.length); const sourceEnd = Math.max( sourceStart + 1, Math.floor(((displayBin + 1) / bins) * magnitudes.length), ); let localMax = 0; for (let source = sourceStart; source < sourceEnd; source += 1) { localMax = Math.max(localMax, magnitudes[source]); } const relativeDb = db(localMax / maxMagnitude); row.push(Math.max(0, Math.min(1, (relativeDb + 72) / 72))); } spectrogram.push(row); } let aggregatePeak = 1e-12; for (const value of aggregate) aggregatePeak = Math.max(aggregatePeak, value); let cutoffBin = 0; for (let bin = 1; bin < aggregate.length; bin += 1) { const relativeDb = db(aggregate[bin] / aggregatePeak); if (relativeDb > -48) cutoffBin = bin; } const estimatedCutoffHz = Math.min( buffer.sampleRate / 2, (cutoffBin * buffer.sampleRate) / fftSize, ); return { durationSeconds: buffer.duration, sampleRate: buffer.sampleRate, channels: buffer.numberOfChannels, peakDbfs, rmsDbfs, crestFactorDb: peakDbfs - rmsDbfs, estimatedCutoffHz, qualityLabel: qualityFromCutoff(estimatedCutoffHz, buffer.sampleRate / 2), spectrogram, }; }