Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

129 lines
4.2 KiB
TypeScript

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<AudioAnalysis> {
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,
};
}