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
+161
View File
@@ -0,0 +1,161 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
import {
Activity,
Waves,
Radio,
TrendingUp,
FileAudio,
Clock
} from "lucide-react";
import type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps {
result: AnalysisResult | null;
analyzing: boolean;
onAnalyze?: () => void;
showAnalyzeButton?: boolean;
}
export function AudioAnalysis({
result,
analyzing,
onAnalyze,
showAnalyzeButton = true
}: AudioAnalysisProps) {
if (analyzing) {
return (
<Card>
<CardContent className="px-6">
<div className="flex items-center justify-center py-8 gap-3">
<Spinner />
<span className="text-muted-foreground">Analyzing audio quality...</span>
</div>
</CardContent>
</Card>
);
}
if (!result && showAnalyzeButton) {
return (
<Card>
<CardContent className="px-6">
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Activity className="h-12 w-12 text-muted-foreground/50" />
<div className="text-center space-y-2">
<p className="font-medium">Audio Quality Analysis</p>
<p className="text-sm text-muted-foreground">
Verify the true lossless quality of downloaded files
</p>
</div>
{onAnalyze && (
<Button onClick={onAnalyze}>
<Activity className="h-4 w-4" />
Analyze Audio
</Button>
)}
</div>
</CardContent>
</Card>
);
}
if (!result) {
return null;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatNumber = (num: number) => {
return num.toFixed(2);
};
return (
<Card>
<CardHeader>
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Audio Quality Analysis
</CardTitle>
<CardDescription>
Technical analysis of audio file properties
</CardDescription>
</div>
</CardHeader>
<CardContent className="px-6 space-y-6">
{/* Technical Specifications */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Radio className="h-3 w-3" />
Sample Rate
</div>
<p className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileAudio className="h-3 w-3" />
Bit Depth
</div>
<p className="font-semibold">{result.bit_depth}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waves className="h-3 w-3" />
Channels
</div>
<p className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Duration
</div>
<p className="font-semibold">{formatDuration(result.duration)}</p>
</div>
</div>
{/* Dynamic Range Analysis */}
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="flex items-center gap-2 text-sm font-medium">
<TrendingUp className="h-4 w-4" />
Dynamic Range Analysis
</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Dynamic Range</p>
<p className="font-semibold">{formatNumber(result.dynamic_range)} dB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Peak Level</p>
<p className="font-semibold">{formatNumber(result.peak_amplitude)} dB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">RMS Level</p>
<p className="font-semibold">{formatNumber(result.rms_level)} dB</p>
</div>
</div>
</div>
{/* Technical Info Footer */}
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
Total Samples: {result.total_samples.toLocaleString()}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,143 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Activity, Upload, X } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
export function AudioAnalysisDialog() {
const [open, setOpen] = useState(false);
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleClose = () => {
setOpen(false);
setTimeout(() => {
clearResult();
setSelectedFilePath("");
}, 200);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
handleClose();
} else {
setOpen(true);
}
}}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Activity className="h-5 w-5" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="left">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto flex flex-col p-6 [&>button]:hidden custom-scrollbar" aria-describedby={undefined}>
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Audio Quality Analyzer</DialogTitle>
<div className="space-y-4">
{/* File Selection */}
{!result && !analyzing && (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed rounded-lg">
<Activity className="h-16 w-16 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">Analyze FLAC Audio Quality</h3>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
Upload a FLAC file to verify true lossless quality, view detailed technical specifications, and see the frequency spectrum
</p>
<Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" />
Select FLAC File
</Button>
</div>
)}
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* File Info */}
<div className="p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground">Analyzing file:</p>
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
</div>
{/* Spectrum Visualization */}
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
{/* Detailed Analysis */}
<AudioAnalysis
result={result}
analyzing={analyzing}
showAnalyzeButton={false}
/>
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button onClick={handleSelectFile} variant="outline">
<Upload className="h-4 w-4" />
Analyze Another File
</Button>
</div>
</div>
)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+2
View File
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Settings } from "@/components/Settings";
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog";
import {
Tooltip,
TooltipContent,
@@ -72,6 +73,7 @@ export function Header({ version, hasUpdate }: HeaderProps) {
<p>Report bug or request feature</p>
</TooltipContent>
</Tooltip>
<AudioAnalysisDialog />
<Settings />
</div>
</div>
@@ -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>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState, useCallback } from "react";
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [error, setError] = useState<string | null>(null);
const analyzeFile = useCallback(async (filePath: string) => {
if (!filePath) {
setError("No file path provided");
return null;
}
setAnalyzing(true);
setError(null);
setResult(null);
try {
logger.info(`Analyzing audio file: ${filePath}`);
const startTime = Date.now();
const response = await AnalyzeTrack(filePath);
const analysisResult: AnalysisResult = JSON.parse(response);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`);
setResult(analysisResult);
return analysisResult;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`);
setError(errorMessage);
toast.error("Audio Analysis Failed", {
description: errorMessage,
});
return null;
} finally {
setAnalyzing(false);
}
}, []);
const clearResult = useCallback(() => {
setResult(null);
setError(null);
}, []);
return {
analyzing,
result,
error,
analyzeFile,
clearResult,
};
}
+27
View File
@@ -138,3 +138,30 @@ export interface HealthResponse {
status: string;
time: string;
}
export interface TimeSlice {
time: number;
magnitudes: number[];
}
export interface SpectrumData {
time_slices: TimeSlice[];
sample_rate: number;
freq_bins: number;
duration: number;
max_freq: number;
}
export interface AnalysisResult {
file_path: string;
sample_rate: number;
channels: number;
bits_per_sample: number;
total_samples: number;
duration: number;
bit_depth: string;
dynamic_range: number;
peak_amplitude: number;
rms_level: number;
spectrum?: SpectrumData;
}