Add FLAC audio quality analysis and spectrum visualization (#110)

Introduces backend support for analyzing FLAC audio files, including technical metrics and frequency spectrum extraction. Adds frontend components and hooks for file selection, analysis, and visualization, integrating a new Audio Quality Analyzer dialog into the UI. Updates types and dependencies to support audio analysis features.
This commit is contained in:
Lukas
2025-11-25 22:05:12 +01:00
committed by GitHub
parent ddf1844237
commit 2172981110
12 changed files with 1145 additions and 0 deletions
@@ -0,0 +1,275 @@
import { useEffect, useRef } from "react";
import type { SpectrumData } from "@/types/api";
interface SpectrumVisualizationProps {
sampleRate: number;
bitsPerSample: number;
duration: number;
spectrumData?: SpectrumData;
}
export function SpectrumVisualization({
sampleRate,
bitsPerSample,
duration,
spectrumData,
}: SpectrumVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Calculate margins for labels
const marginLeft = 80;
const marginRight = 80;
const marginTop = 20;
const marginBottom = 50;
const plotWidth = width - marginLeft - marginRight;
const plotHeight = height - marginTop - marginBottom;
// Black background like Spek
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
// Calculate Nyquist frequency
const nyquistFreq = sampleRate / 2;
if (spectrumData) {
drawRealSpectrum(
ctx,
marginLeft,
marginTop,
plotWidth,
plotHeight,
spectrumData
);
drawGrid(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq);
}
}, [sampleRate, bitsPerSample, duration, spectrumData]);
const drawRealSpectrum = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
spectrum: SpectrumData
) => {
const timeSlices = spectrum.time_slices;
if (timeSlices.length === 0) return;
const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
let minDB = 0;
let maxDB = -120;
timeSlices.forEach((slice) => {
slice.magnitudes.forEach((db) => {
if (db > maxDB) maxDB = db;
if (db < minDB) minDB = db;
});
});
const dbRange = maxDB - minDB;
for (let t = 0; t < timeSlices.length; t++) {
const slice = timeSlices[t];
const xPos = x + (t / timeSlices.length) * width;
const sliceWidth = Math.max(1, width / timeSlices.length);
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
const db = slice.magnitudes[f];
// Linear frequency scale like Spek
const freq = (f / freqBins) * nyquistFreq;
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
// Calculate next frequency bin position
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
const nextFreqRatio = nextFreq / nyquistFreq;
const nextYPos = y + height - (nextFreqRatio * height);
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
const intensity = (db - minDB) / dbRange;
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
}
};
const getSpekColor = (intensity: number): string => {
// Enhanced color scheme - better than Spek
if (intensity < 0.10) {
// Deep black to dark blue
const t = intensity / 0.10;
return `rgb(0, 0, ${Math.floor(t * 100)})`;
} else if (intensity < 0.25) {
// Dark blue to bright blue
const t = (intensity - 0.10) / 0.15;
return `rgb(0, ${Math.floor(t * 50)}, ${Math.floor(100 + t * 155)})`;
} else if (intensity < 0.40) {
// Blue to cyan
const t = (intensity - 0.25) / 0.15;
return `rgb(0, ${Math.floor(50 + t * 205)}, 255)`;
} else if (intensity < 0.55) {
// Cyan to green
const t = (intensity - 0.40) / 0.15;
return `rgb(0, 255, ${Math.floor(255 - t * 200)})`;
} else if (intensity < 0.70) {
// Green to yellow
const t = (intensity - 0.55) / 0.15;
return `rgb(${Math.floor(t * 255)}, 255, ${Math.floor(55 - t * 55)})`;
} else if (intensity < 0.85) {
// Yellow to orange
const t = (intensity - 0.70) / 0.15;
return `rgb(255, ${Math.floor(255 - t * 100)}, 0)`;
} else {
// Orange to red
const t = (intensity - 0.85) / 0.15;
return `rgb(255, ${Math.floor(155 - t * 155)}, ${Math.floor(t * 30)})`;
}
};
const drawGrid = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
nyquistFreq: number
) => {
// Enhanced grid lines
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
ctx.lineWidth = 1;
// Dynamic frequency grid lines based on Nyquist frequency
const generateFreqLines = (maxFreq: number): number[] => {
if (maxFreq <= 24000) {
// Standard 44.1/48 kHz (Nyquist ~22/24 kHz)
return [1000, 2000, 5000, 10000, 15000, 20000];
} else if (maxFreq <= 48000) {
// 88.2/96 kHz (Nyquist ~44/48 kHz)
return [5000, 10000, 20000, 30000, 40000];
} else if (maxFreq <= 96000) {
// 176.4/192 kHz (Nyquist ~88/96 kHz)
return [10000, 20000, 40000, 60000, 80000];
} else {
// 352.8/384 kHz and higher (Nyquist ~176/192+ kHz)
return [20000, 40000, 80000, 120000, 160000];
}
};
const freqLines = generateFreqLines(nyquistFreq);
freqLines.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
ctx.beginPath();
ctx.moveTo(x, yPos);
ctx.lineTo(x + width, yPos);
ctx.stroke();
}
});
// Vertical time grid lines
for (let i = 1; i < 10; i++) {
const xPos = x + (i / 10) * width;
ctx.beginPath();
ctx.moveTo(xPos, y);
ctx.lineTo(xPos, y + height);
ctx.stroke();
}
ctx.fillStyle = "rgba(220, 220, 220, 0.9)";
ctx.font = "11px Arial";
// Frequency labels - dynamic formatting
freqLines.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillText(label, x - 6, yPos);
}
});
// Time labels
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let i = 0; i <= 10; i++) {
const timePos = x + (i / 10) * width;
const timeValue = (i / 10) * duration;
if (i % 2 === 0) {
ctx.fillText(timeValue.toFixed(1), timePos, y + height + 5);
}
}
ctx.fillStyle = "#FFFFFF";
ctx.font = "bold 13px Arial";
ctx.shadowColor = "rgba(0, 0, 0, 0.8)";
ctx.shadowBlur = 4;
ctx.save();
ctx.translate(8, y + height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Frequency (kHz)", 0, 0);
ctx.restore();
ctx.textAlign = "center";
ctx.fillText("Time (s)", x + width / 2, y + height + 26);
ctx.shadowBlur = 0;
const boxGradient = ctx.createLinearGradient(x + width - 200, y + 5, x + width - 200, y + 68);
boxGradient.addColorStop(0, "rgba(0, 0, 0, 0.85)");
boxGradient.addColorStop(1, "rgba(0, 0, 0, 0.7)");
ctx.fillStyle = boxGradient;
ctx.fillRect(x + width - 200, y + 5, 190, 63);
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1.5;
ctx.strokeRect(x + width - 200, y + 5, 190, 63);
ctx.fillStyle = "#FFFFFF";
ctx.font = "600 11px Arial";
ctx.textAlign = "left";
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
ctx.shadowBlur = 2;
ctx.fillText(`Sample Rate: ${(sampleRate / 1000).toFixed(1)} kHz`, x + width - 190, y + 20);
ctx.fillText(`Bit Depth: ${bitsPerSample}-bit`, x + width - 190, y + 36);
ctx.fillText(`Nyquist: ${(nyquistFreq / 1000).toFixed(1)} kHz`, x + width - 190, y + 52);
ctx.shadowBlur = 0;
};
return (
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
<canvas
ref={canvasRef}
width={1600}
height={800}
className="w-full h-auto"
style={{ imageRendering: "auto" }}
/>
</div>
);
}