- 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
129 lines
4.2 KiB
TypeScript
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,
|
|
};
|
|
}
|