diff --git a/app.go b/app.go index 6fe6e97..9fe1929 100644 --- a/app.go +++ b/app.go @@ -285,6 +285,11 @@ func (a *App) SelectFolder(defaultPath string) (string, error) { return backend.SelectFolderDialog(a.ctx, defaultPath) } +// SelectFile opens a file selection dialog and returns the selected file path +func (a *App) SelectFile() (string, error) { + return backend.SelectFileDialog(a.ctx) +} + // GetDefaults returns the default configuration func (a *App) GetDefaults() map[string]string { return map[string]string{ @@ -302,3 +307,47 @@ func (a *App) Quit() { // You can add cleanup logic here if needed panic("quit") // This will trigger Wails to close the app } + +// AnalyzeTrack analyzes audio quality of a FLAC file +func (a *App) AnalyzeTrack(filePath string) (string, error) { + if filePath == "" { + return "", fmt.Errorf("file path is required") + } + + result, err := backend.AnalyzeTrack(filePath) + if err != nil { + return "", fmt.Errorf("failed to analyze track: %v", err) + } + + jsonData, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to encode response: %v", err) + } + + return string(jsonData), nil +} + +// AnalyzeMultipleTracks analyzes multiple FLAC files +func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) { + if len(filePaths) == 0 { + return "", fmt.Errorf("at least one file path is required") + } + + results := make([]*backend.AnalysisResult, 0, len(filePaths)) + + for _, filePath := range filePaths { + result, err := backend.AnalyzeTrack(filePath) + if err != nil { + // Skip failed analyses + continue + } + results = append(results, result) + } + + jsonData, err := json.Marshal(results) + if err != nil { + return "", fmt.Errorf("failed to encode response: %v", err) + } + + return string(jsonData), nil +} diff --git a/backend/analysis.go b/backend/analysis.go new file mode 100644 index 0000000..f551ae5 --- /dev/null +++ b/backend/analysis.go @@ -0,0 +1,181 @@ +package backend + +import ( + "fmt" + "math" + "os" + + "github.com/go-flac/go-flac" + mewflac "github.com/mewkiz/flac" +) + +// AnalysisResult contains the audio analysis data +type AnalysisResult struct { + FilePath string `json:"file_path"` + SampleRate uint32 `json:"sample_rate"` + Channels uint8 `json:"channels"` + BitsPerSample uint8 `json:"bits_per_sample"` + TotalSamples uint64 `json:"total_samples"` + Duration float64 `json:"duration"` + BitDepth string `json:"bit_depth"` + DynamicRange float64 `json:"dynamic_range"` + PeakAmplitude float64 `json:"peak_amplitude"` + RMSLevel float64 `json:"rms_level"` + Spectrum *SpectrumData `json:"spectrum,omitempty"` +} + +// AnalyzeTrack performs audio analysis on a FLAC file +func AnalyzeTrack(filepath string) (*AnalysisResult, error) { + if !fileExists(filepath) { + return nil, fmt.Errorf("file does not exist: %s", filepath) + } + + // Parse FLAC file + f, err := flac.ParseFile(filepath) + if err != nil { + return nil, fmt.Errorf("failed to parse FLAC file: %w", err) + } + + result := &AnalysisResult{ + FilePath: filepath, + } + + // Extract basic audio properties from STREAMINFO block + if len(f.Meta) > 0 { + streamInfo := f.Meta[0] + if streamInfo.Type == flac.StreamInfo { + // Read STREAMINFO data + data := streamInfo.Data + if len(data) >= 18 { + // Sample rate (bits 10-29 of bytes 10-13) + result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 + + // Channels (bits 30-32 of byte 12) + result.Channels = ((data[12] >> 1) & 0x07) + 1 + + // Bits per sample (bits 33-37 of bytes 12-13) + result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1 + + // Total samples (bits 38-73 of bytes 13-17) + result.TotalSamples = uint64(data[13]&0x0F)<<32 | + uint64(data[14])<<24 | + uint64(data[15])<<16 | + uint64(data[16])<<8 | + uint64(data[17]) + + // Calculate duration + if result.SampleRate > 0 { + result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) + } + + // Read min/max frame size and block size for additional analysis + // Min block size (bytes 0-1) + // Max block size (bytes 2-3) + // These can give us hints about encoding quality + } + } + } + + // Analyze spectrum and calculate real audio metrics + spectrum, err := AnalyzeSpectrum(filepath) + if err != nil { + // Log error but continue + fmt.Printf("Warning: failed to analyze spectrum: %v\n", err) + } else { + result.Spectrum = spectrum + // Calculate dynamic range, peak, and RMS from decoded samples + calculateRealAudioMetrics(result, filepath) + } + + // Set bit depth + result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) + + return result, nil +} + +// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio +func calculateRealAudioMetrics(result *AnalysisResult, filepath string) { + // Decode FLAC to get actual samples + samples, err := decodeFLACForMetrics(filepath) + if err != nil { + return + } + + // Calculate peak amplitude + var peak float64 + var sumSquares float64 + + for _, sample := range samples { + absVal := sample + if absVal < 0 { + absVal = -absVal + } + if absVal > peak { + peak = absVal + } + sumSquares += sample * sample + } + + // Convert peak to dB (reference: 1.0 = 0 dBFS) + peakDB := 20.0 * math.Log10(peak) + result.PeakAmplitude = peakDB + + // Calculate RMS (Root Mean Square) + rms := math.Sqrt(sumSquares / float64(len(samples))) + rmsDB := 20.0 * math.Log10(rms) + result.RMSLevel = rmsDB + + // Dynamic range is the difference between peak and RMS + result.DynamicRange = peakDB - rmsDB +} + +// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation +func decodeFLACForMetrics(filepath string) ([]float64, error) { + stream, err := mewflac.ParseFile(filepath) + if err != nil { + return nil, err + } + defer stream.Close() + + // Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz) + maxSamples := 10000000 + samples := make([]float64, 0, maxSamples) + + // Read all audio frames + for { + frame, err := stream.ParseNext() + if err != nil { + break + } + + // Get samples from first channel (mono or left channel) + var channelSamples []int32 + if len(frame.Subframes) > 0 { + channelSamples = frame.Subframes[0].Samples + } + + // Normalize samples to -1.0 to 1.0 range + maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1)) + for _, sample := range channelSamples { + if len(samples) >= maxSamples { + return samples, nil + } + normalized := float64(sample) / maxVal + samples = append(samples, normalized) + } + + if len(samples) >= maxSamples { + break + } + } + + return samples, nil +} + +func GetFileSize(filepath string) (int64, error) { + info, err := os.Stat(filepath) + if err != nil { + return 0, err + } + return info.Size(), nil +} diff --git a/backend/folder.go b/backend/folder.go index e6b7a68..0527f76 100644 --- a/backend/folder.go +++ b/backend/folder.go @@ -48,3 +48,31 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) return selectedPath, nil } + +func SelectFileDialog(ctx context.Context) (string, error) { + options := wailsRuntime.OpenDialogOptions{ + Title: "Select FLAC File for Analysis", + Filters: []wailsRuntime.FileFilter{ + { + DisplayName: "FLAC Audio Files (*.flac)", + Pattern: "*.flac", + }, + { + DisplayName: "All Files (*.*)", + Pattern: "*.*", + }, + }, + } + + selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options) + if err != nil { + return "", err + } + + // If user cancelled, selectedFile will be empty + if selectedFile == "" { + return "", nil + } + + return selectedFile, nil +} diff --git a/backend/spectrum.go b/backend/spectrum.go new file mode 100644 index 0000000..b32ec49 --- /dev/null +++ b/backend/spectrum.go @@ -0,0 +1,205 @@ +package backend + +import ( + "fmt" + "math" + "math/cmplx" + "os" + + "github.com/mewkiz/flac" +) + +// SpectrumData contains frequency spectrum information +type SpectrumData struct { + TimeSlices []TimeSlice `json:"time_slices"` + SampleRate int `json:"sample_rate"` + FreqBins int `json:"freq_bins"` + Duration float64 `json:"duration"` + MaxFreq float64 `json:"max_freq"` +} + +// TimeSlice represents spectrum data at a point in time +type TimeSlice struct { + Time float64 `json:"time"` + Magnitudes []float64 `json:"magnitudes"` +} + +// AnalyzeSpectrum decodes FLAC file and performs FFT analysis +func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { + // Open FLAC file + stream, err := flac.ParseFile(filepath) + if err != nil { + return nil, fmt.Errorf("failed to parse FLAC: %w", err) + } + defer stream.Close() + + info := stream.Info + sampleRate := int(info.SampleRate) + channels := int(info.NChannels) + + // Read audio samples + samples, err := readSamples(stream, channels) + if err != nil { + return nil, fmt.Errorf("failed to read samples: %w", err) + } + + if len(samples) == 0 { + return nil, fmt.Errorf("no audio samples found") + } + + // Calculate spectrum + return calculateSpectrum(samples, sampleRate), nil +} + +// readSamples reads and decodes audio samples from FLAC stream +func readSamples(stream *flac.Stream, channels int) ([]float64, error) { + var allSamples []float64 + maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues + + // Decode frames + for { + frame, err := stream.ParseNext() + if err != nil { + // End of stream + break + } + + // Convert samples to float64 and mix channels to mono + for i := 0; i < frame.Subframes[0].NSamples; i++ { + var sample float64 + + // Mix all channels to mono by averaging + for ch := 0; ch < channels; ch++ { + sample += float64(frame.Subframes[ch].Samples[i]) + } + sample /= float64(channels) + + allSamples = append(allSamples, sample) + + // Limit sample count + if len(allSamples) >= maxSamples { + return allSamples, nil + } + } + } + + return allSamples, nil +} + +// calculateSpectrum performs FFT analysis on audio samples +func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { + fftSize := 8192 + numTimeSlices := 300 + + duration := float64(len(samples)) / float64(sampleRate) + + samplesPerSlice := len(samples) / numTimeSlices + if samplesPerSlice < fftSize { + samplesPerSlice = fftSize + numTimeSlices = len(samples) / fftSize + } + + timeSlices := make([]TimeSlice, 0, numTimeSlices) + freqBins := fftSize / 2 + maxFreq := float64(sampleRate) / 2.0 + + for i := 0; i < numTimeSlices; i++ { + startIdx := i * samplesPerSlice + if startIdx+fftSize > len(samples) { + break + } + + window := samples[startIdx : startIdx+fftSize] + + windowedSamples := applyHannWindow(window) + + spectrum := fft(windowedSamples) + + magnitudes := make([]float64, freqBins) + for j := 0; j < freqBins; j++ { + magnitude := cmplx.Abs(spectrum[j]) + + if magnitude < 1e-10 { + magnitude = 1e-10 + } + magnitudes[j] = 20 * math.Log10(magnitude) + } + + timeSlice := TimeSlice{ + Time: float64(startIdx) / float64(sampleRate), + Magnitudes: magnitudes, + } + timeSlices = append(timeSlices, timeSlice) + } + + return &SpectrumData{ + TimeSlices: timeSlices, + SampleRate: sampleRate, + FreqBins: freqBins, + Duration: duration, + MaxFreq: maxFreq, + } +} + +// applyHannWindow applies Hann window to reduce spectral leakage +func applyHannWindow(samples []float64) []float64 { + n := len(samples) + windowed := make([]float64, n) + + for i := 0; i < n; i++ { + window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1))) + windowed[i] = samples[i] * window + } + + return windowed +} + +// fft performs Fast Fourier Transform using Cooley-Tukey algorithm +func fft(samples []float64) []complex128 { + n := len(samples) + + x := make([]complex128, n) + for i := 0; i < n; i++ { + x[i] = complex(samples[i], 0) + } + + return fftRecursive(x) +} + +// fftRecursive performs recursive FFT +func fftRecursive(x []complex128) []complex128 { + n := len(x) + + if n <= 1 { + return x + } + + even := make([]complex128, n/2) + odd := make([]complex128, n/2) + + for i := 0; i < n/2; i++ { + even[i] = x[2*i] + odd[i] = x[2*i+1] + } + + evenFFT := fftRecursive(even) + oddFFT := fftRecursive(odd) + + result := make([]complex128, n) + for k := 0; k < n/2; k++ { + t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k] + result[k] = evenFFT[k] + t + result[k+n/2] = evenFFT[k] - t + } + + return result +} + +// GetFileSize helper +func getSpectrumFileSize(filepath string) (int64, error) { + info, err := os.Stat(filepath) + if err != nil { + return 0, err + } + return info.Size(), nil +} diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx new file mode 100644 index 0000000..72be2b3 --- /dev/null +++ b/frontend/src/components/AudioAnalysis.tsx @@ -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 ( + + +
+ + Analyzing audio quality... +
+
+
+ ); + } + + if (!result && showAnalyzeButton) { + return ( + + +
+ +
+

Audio Quality Analysis

+

+ Verify the true lossless quality of downloaded files +

+
+ {onAnalyze && ( + + )} +
+
+
+ ); + } + + 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 ( + + +
+ + + Audio Quality Analysis + + + Technical analysis of audio file properties + +
+
+ + + + {/* Technical Specifications */} +
+
+
+ + Sample Rate +
+

{(result.sample_rate / 1000).toFixed(1)} kHz

+
+ +
+
+ + Bit Depth +
+

{result.bit_depth}

+
+ +
+
+ + Channels +
+

{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}

+
+ +
+
+ + Duration +
+

{formatDuration(result.duration)}

+
+
+ + {/* Dynamic Range Analysis */} +
+
+ + Dynamic Range Analysis +
+ +
+
+

Dynamic Range

+

{formatNumber(result.dynamic_range)} dB

+
+
+

Peak Level

+

{formatNumber(result.peak_amplitude)} dB

+
+
+

RMS Level

+

{formatNumber(result.rms_level)} dB

+
+
+
+ + {/* Technical Info Footer */} +
+

+ Total Samples: {result.total_samples.toLocaleString()} +

+
+
+
+ ); +} diff --git a/frontend/src/components/AudioAnalysisDialog.tsx b/frontend/src/components/AudioAnalysisDialog.tsx new file mode 100644 index 0000000..56eda97 --- /dev/null +++ b/frontend/src/components/AudioAnalysisDialog.tsx @@ -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(""); + + 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 ( + { + if (!isOpen) { + handleClose(); + } else { + setOpen(true); + } + }}> + + + + + + + +

Audio Quality Analyzer

+
+
+ +
+ +
+ + Audio Quality Analyzer + +
+ {/* File Selection */} + {!result && !analyzing && ( +
+ +

Analyze FLAC Audio Quality

+

+ Upload a FLAC file to verify true lossless quality, view detailed technical specifications, and see the frequency spectrum +

+ +
+ )} + + {/* Analysis Results */} + {result && ( +
+ {/* File Info */} +
+

Analyzing file:

+

{selectedFilePath}

+
+ + {/* Spectrum Visualization */} + + + {/* Detailed Analysis */} + + + {/* Actions */} +
+ +
+
+ )} + + {/* Loading State */} + {analyzing && !result && ( +
+
+

Analyzing audio file...

+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index a8164cf..8207962 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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) {

Report bug or request feature

+ diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx new file mode 100644 index 0000000..9454187 --- /dev/null +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -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(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 ( +
+ +
+ ); +} diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts new file mode 100644 index 0000000..80ec3b8 --- /dev/null +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -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(null); + const [error, setError] = useState(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, + }; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8af421c..600bd91 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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; +} diff --git a/go.mod b/go.mod index db84c80..80b70ce 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 + github.com/mewkiz/flac v1.0.13 github.com/wailsapp/wails/v2 v2.11.0 ) @@ -15,6 +16,7 @@ require ( github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/icza/bitio v1.1.0 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/labstack/echo/v4 v4.13.4 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -24,6 +26,8 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect + github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 880d18f..bb640ea 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= +github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= @@ -39,6 +43,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= +github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=