diff --git a/app.go b/app.go index eeda379..cab47c1 100644 --- a/app.go +++ b/app.go @@ -809,6 +809,53 @@ func (a *App) AnalyzeTrack(filePath string) (string, error) { return string(jsonData), nil } +func (a *App) AnalyzeSpectrumWithParams(filePath string, fftSize int, windowFunction string) (string, error) { + if filePath == "" { + return "", fmt.Errorf("file path is required") + } + + params := backend.SpectrumParams{ + FFTSize: fftSize, + WindowFunction: windowFunction, + } + + result, err := backend.AnalyzeSpectrumWithParams(filePath, params) + if err != nil { + return "", fmt.Errorf("failed to analyze spectrum: %v", err) + } + + jsonData, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to encode response: %v", err) + } + + return string(jsonData), nil +} + +func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) { + if audioFilePath == "" || base64Data == "" { + return "", fmt.Errorf("file path and image data are required") + } + + base64Data = strings.TrimPrefix(base64Data, "data:image/png;base64,") + + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return "", fmt.Errorf("failed to decode base64 image: %v", err) + } + + ext := filepath.Ext(audioFilePath) + baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) + outPath := filepath.Join(filepath.Dir(audioFilePath), baseName+".png") + + err = os.WriteFile(outPath, data, 0644) + if err != nil { + return "", fmt.Errorf("failed to save image to disk: %v", err) + } + + return outPath, nil +} + func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) { if len(filePaths) == 0 { return "", fmt.Errorf("at least one file path is required") diff --git a/backend/spectrum.go b/backend/spectrum.go index fe7b053..ba26b09 100644 --- a/backend/spectrum.go +++ b/backend/spectrum.go @@ -21,8 +21,23 @@ type TimeSlice struct { Magnitudes []float64 `json:"magnitudes"` } -func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { +type SpectrumParams struct { + FFTSize int `json:"fft_size"` + WindowFunction string `json:"window_function"` +} +func DefaultSpectrumParams() SpectrumParams { + return SpectrumParams{ + FFTSize: 4096, + WindowFunction: "hann", + } +} + +func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { + return AnalyzeSpectrumWithParams(filepath, DefaultSpectrumParams()) +} + +func AnalyzeSpectrumWithParams(filepath string, params SpectrumParams) (*SpectrumData, error) { stream, err := flac.ParseFile(filepath) if err != nil { return nil, fmt.Errorf("failed to parse FLAC: %w", err) @@ -42,7 +57,20 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { return nil, fmt.Errorf("no audio samples found") } - return calculateSpectrum(samples, sampleRate), nil + fftSize := params.FFTSize + validSizes := []int{512, 1024, 2048, 4096, 8192} + valid := false + for _, s := range validSizes { + if fftSize == s { + valid = true + break + } + } + if !valid { + fftSize = 4096 + } + + return calculateSpectrumWithParams(samples, sampleRate, fftSize, params.WindowFunction), nil } func readSamples(stream *flac.Stream, channels int) ([]float64, error) { @@ -75,8 +103,7 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) { return allSamples, nil } -func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { - fftSize := 8192 +func calculateSpectrumWithParams(samples []float64, sampleRate, fftSize int, windowFunc string) *SpectrumData { numTimeSlices := 300 duration := float64(len(samples)) / float64(sampleRate) @@ -98,8 +125,7 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { } window := samples[startIdx : startIdx+fftSize] - - windowedSamples := applyHannWindow(window) + windowedSamples := applyWindow(window, windowFunc) spectrum := fft(windowedSamples) @@ -129,18 +155,33 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { } } -func applyHannWindow(samples []float64) []float64 { +func applyWindow(samples []float64, windowType string) []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 + var w float64 + switch windowType { + case "hamming": + w = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1)) + case "blackman": + w = 0.42 - 0.5*math.Cos(2*math.Pi*float64(i)/float64(n-1)) + + 0.08*math.Cos(4*math.Pi*float64(i)/float64(n-1)) + case "rectangular": + w = 1.0 + default: + w = 0.5 * (1.0 - math.Cos(2*math.Pi*float64(i)/float64(n-1))) + } + windowed[i] = samples[i] * w } return windowed } +func applyHannWindow(samples []float64) []float64 { + return applyWindow(samples, "hann") +} + func fft(samples []float64) []complex128 { n := len(samples) diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx index 7976465..722b6a9 100644 --- a/frontend/src/components/AudioAnalysis.tsx +++ b/frontend/src/components/AudioAnalysis.tsx @@ -1,8 +1,9 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { Button } from "@/components/ui/button"; -import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react"; +import { Activity } from "lucide-react"; import type { AnalysisResult } from "@/types/api"; + interface AudioAnalysisProps { result: AnalysisResult | null; analyzing: boolean; @@ -10,116 +11,160 @@ interface AudioAnalysisProps { showAnalyzeButton?: boolean; filePath?: string; } + export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) { if (analyzing) { - return ( - -
- - Analyzing audio quality... -
-
-
); + return ( + + +
+ + Analyzing audio quality... +
+
+
+ ); } + if (!result && showAnalyzeButton) { - return ( - -
- -
-

Audio Quality Analysis

-

- Verify the true lossless quality of downloaded files -

-
- {onAnalyze && ()} -
-
-
); + 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')}`; + return `${mins}:${secs.toString().padStart(2, "0")}`; }; + const formatNumber = (num: number) => { return num.toFixed(2); }; + const formatFileSize = (bytes: number): string => { - if (bytes === 0) - return "0 B"; + if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; }; + const nyquistFreq = result.sample_rate / 2; - return ( - - {filePath && (

{filePath}

)} -
- - -
-
- - 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}`} -
-
- - Duration: - {formatDuration(result.duration)} -
-
- - Nyquist: - {(nyquistFreq / 1000).toFixed(1)} kHz -
- {result.file_size > 0 && (
- - Size: - {formatFileSize(result.file_size)} -
)} -
+ return ( + + + {filePath && ( +

{filePath}

+ )} +
- -
-
- - Dynamic Range: - {formatNumber(result.dynamic_range)} dB -
-
- Peak: - {formatNumber(result.peak_amplitude)} dB -
-
- RMS: - {formatNumber(result.rms_level)} dB -
-
- Samples: - {result.total_samples.toLocaleString()} -
-
-
-
); + +
+
+

Format

+
    +
  • + 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}`} +
  • +
  • + Duration: + {formatDuration(result.duration)} +
  • + {result.file_size > 0 && ( +
  • + Size: + {formatFileSize(result.file_size)} +
  • + )} +
+
+ +
+

Signal Analytics

+
    +
  • + Nyquist: + {(nyquistFreq / 1000).toFixed(1)} kHz +
  • +
  • + Dynamic Range: + {formatNumber(result.dynamic_range)} dB +
  • +
  • + Peak Amplitude: + {formatNumber(result.peak_amplitude)} dB +
  • +
  • + RMS Level: + {formatNumber(result.rms_level)} dB +
  • +
  • + Total Samples: + {result.total_samples.toLocaleString()} +
  • +
+
+ + {result.spectrum && (() => { + const frames = result.spectrum.time_slices.length; + const fftSize = result.spectrum.freq_bins * 2; + const freqRes = result.sample_rate / fftSize; + + return ( +
+

Spectrum Meta

+
    +
  • + Analysis Frames: + {frames.toLocaleString()} +
  • +
  • + FFT Size: + {fftSize.toLocaleString()} +
  • +
  • + Freq Resolution: + {freqRes.toFixed(2)} Hz/bin +
  • +
+
+ ); + })()} +
+
+ + ); } diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index 503afb2..c71d6c1 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -1,18 +1,51 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; -import { Upload, ArrowLeft, Trash2 } from "lucide-react"; +import { Upload, ArrowLeft, Trash2, Download } 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 { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; + interface AudioAnalysisPageProps { onBack?: () => void; } + export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis(); + const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading, reAnalyzeSpectrum } = + useAudioAnalysis(); const [isDragging, setIsDragging] = useState(false); + const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null); + const [isExporting, setIsExporting] = useState(false); + + const handleExport = async () => { + if (!selectedFilePath || !spectrumRef.current) + return; + + const dataUrl = spectrumRef.current.getCanvasDataURL(); + if (!dataUrl) { + toast.error("Export Failed", { description: "Cannot get canvas data" }); + return; + } + + setIsExporting(true); + try { + const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl); + toast.success("Exported Successfully", { + description: `Saved to: ${outPath}`, + }); + } + catch (err) { + toast.error("Export Failed", { + description: err instanceof Error ? err.message : "Failed to save image", + }); + } + finally { + setIsExporting(false); + } + }; + const handleSelectFile = async () => { try { const filePath = await SelectFile(); @@ -26,6 +59,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); } }; + const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => { setIsDragging(false); if (paths.length === 0) @@ -39,6 +73,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { } await analyzeFile(filePath); }, [analyzeFile]); + useEffect(() => { OnFileDrop((x, y, paths) => { handleFileDrop(x, y, paths); @@ -47,67 +82,97 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { OnFileDropOff(); }; }, [handleFileDrop]); + const handleAnalyzeAnother = () => { clearResult(); }; - return (
- -
-
- {onBack && ()} -

Audio Quality Analyzer

+ + const fileName = selectedFilePath + ? selectedFilePath.split(/[/\\]/).pop() + : undefined; + + return ( +
+
+
+ {onBack && ( + + )} +

Audio Quality Analyzer

+
+ {result && ( +
+ + +
+ )} +
+ + {!result && !analyzing && ( +
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} + onDrop={(e) => { e.preventDefault(); setIsDragging(false); }} + style={{ "--wails-drop-target": "drop" } as React.CSSProperties} + > +
+ +
+

+ {isDragging + ? "Drop your FLAC file here" + : "Drag and drop a FLAC file here, or click the button below to select"} +

+ +
+ )} + + {analyzing && !result && ( +
+
+

Analyzing audio file...

+
+ )} + + {result && ( +
+ + + +
+ )}
- {result && ()} -
- - - {!result && !analyzing && (
{ - e.preventDefault(); - setIsDragging(true); - }} onDragLeave={(e) => { - e.preventDefault(); - setIsDragging(false); - }} onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}> -
- -
-

- {isDragging - ? "Drop your FLAC file here" - : "Drag and drop a FLAC file here, or click the button below to select"} -

- -
)} - - - {analyzing && !result && (
-
-

Analyzing audio file...

-
)} - - - {result && (
- - - - - {spectrumLoading ? (
-
-

Loading spectrum data...

-
) : ()} -
)} -
); + ); } diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index 5900ebb..bfa0a71 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -1,193 +1,503 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import type { SpectrumData } from "@/types/api"; +import { Label } from "@/components/ui/label"; +import { forwardRef, useImperativeHandle } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export interface SpectrumVisualizationHandle { + getCanvasDataURL: () => string | null; +} + interface SpectrumVisualizationProps { sampleRate: number; - bitsPerSample: number; duration: number; spectrumData?: SpectrumData; + fileName?: string; + onReAnalyze?: (fftSize: number, windowFunction: string) => void; + isAnalyzingSpectrum?: boolean; } -export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) { + +type ColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale"; + +function getColor(intensity: number, scheme: ColorScheme): string { + const t = Math.max(0, Math.min(1, intensity)); + switch (scheme) { + case "spek": + return spekColor(t); + case "viridis": + return viridisColor(t); + case "hot": + return hotColor(t); + case "cool": + return coolColor(t); + case "grayscale": { + const v = Math.round(t * 255); + return `rgb(${v},${v},${v})`; + } + default: + return spekColor(t); + } +} + +function getColorRGB(intensity: number, scheme: ColorScheme): [number, number, number] { + const t = Math.max(0, Math.min(1, intensity)); + const css = getColor(t, scheme); + const m = css.match(/\d+/g)!; + return [parseInt(m[0]), parseInt(m[1]), parseInt(m[2])]; +} + +function spekColor(t: number): string { + if (t < 0.08) { + const v = t / 0.08; + return `rgb(0,0,${Math.round(v * 80)})`; + } + if (t < 0.18) { + const v = (t - 0.08) / 0.10; + return `rgb(${Math.round(v * 50)},${Math.round(v * 30)},${Math.round(80 + v * 175)})`; + } + if (t < 0.28) { + const v = (t - 0.18) / 0.10; + return `rgb(${Math.round(50 + v * 150)},${Math.round(30 - v * 30)},${Math.round(255 - v * 55)})`; + } + if (t < 0.40) { + const v = (t - 0.28) / 0.12; + return `rgb(${Math.round(200 + v * 55)},0,${Math.round(200 - v * 200)})`; + } + if (t < 0.52) { + const v = (t - 0.40) / 0.12; + return `rgb(255,${Math.round(v * 100)},0)`; + } + if (t < 0.65) { + const v = (t - 0.52) / 0.13; + return `rgb(255,${Math.round(100 + v * 80)},0)`; + } + if (t < 0.78) { + const v = (t - 0.65) / 0.13; + return `rgb(255,${Math.round(180 + v * 55)},${Math.round(v * 30)})`; + } + if (t < 0.90) { + const v = (t - 0.78) / 0.12; + return `rgb(255,${Math.round(235 + v * 20)},${Math.round(30 + v * 100)})`; + } + const v = (t - 0.90) / 0.10; + return `rgb(255,255,${Math.round(130 + v * 125)})`; +} + +function viridisColor(t: number): string { + const stops: [number, number, number][] = [ + [68, 1, 84], + [72, 36, 117], + [62, 74, 137], + [49, 104, 142], + [38, 130, 142], + [31, 158, 137], + [53, 183, 121], + [110, 206, 88], + [181, 222, 43], + [253, 231, 37], + ]; + const i = t * (stops.length - 1); + const lo = Math.floor(i); + const hi = Math.min(lo + 1, stops.length - 1); + const f = i - lo; + const [r, g, b] = stops[lo].map((v, k) => Math.round(v + (stops[hi][k] - v) * f)) as [number, number, number]; + return `rgb(${r},${g},${b})`; +} + +function hotColor(t: number): string { + if (t < 0.33) { + return `rgb(${Math.round(t / 0.33 * 255)},0,0)`; + } + if (t < 0.67) { + return `rgb(255,${Math.round((t - 0.33) / 0.34 * 255)},0)`; + } + return `rgb(255,255,${Math.round((t - 0.67) / 0.33 * 255)})`; +} + +function coolColor(t: number): string { + if (t < 0.33) { + return `rgb(0,0,${Math.round(128 + t / 0.33 * 127)})`; + } + if (t < 0.67) { + return `rgb(0,${Math.round((t - 0.33) / 0.34 * 255)},255)`; + } + return `rgb(${Math.round((t - 0.67) / 0.33 * 255)},255,255)`; +} + +type FreqScale = "linear" | "log2"; + +const MARGIN = { top: 50, right: 100, bottom: 50, left: 80 }; +const CANVAS_W = 1200; +const CANVAS_H = 600; + +function renderSpectrogram( + ctx: CanvasRenderingContext2D, + spectrum: SpectrumData, + sampleRate: number, + duration: number, + freqScale: FreqScale, + colorScheme: ColorScheme, + fileName?: string, +) { + const { top, right, bottom, left } = MARGIN; + const pw = CANVAS_W - left - right; + const ph = CANVAS_H - top - bottom; + + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); + + const slices = spectrum.time_slices; + if (!slices || slices.length === 0) + return; + + const numT = slices.length; + const numF = slices[0].magnitudes.length; + const maxFreq = spectrum.max_freq; + + let minDB = Infinity; + let maxDB = -Infinity; + for (const s of slices) { + for (const v of s.magnitudes) { + if (v > maxDB) + maxDB = v; + if (v < minDB && v > -200) + minDB = v; + } + } + minDB = Math.max(minDB, maxDB - 90); + const dbRange = maxDB - minDB; + + const img = ctx.createImageData(pw, ph); + const data = img.data; + + for (let x = 0; x < pw; x++) { + const tProgress = x / (pw - 1); + const tExact = tProgress * (numT - 1); + const t0 = Math.floor(tExact); + const t1 = Math.min(t0 + 1, numT - 1); + const tf = tExact - t0; + const frame0 = slices[t0].magnitudes; + const frame1 = slices[t1].magnitudes; + + for (let y = 0; y < ph; y++) { + let fProgress = (ph - 1 - y) / (ph - 1); + + if (freqScale === "log2") { + const minF = 20; + const octaves = Math.log2(maxFreq / minF); + const freq = minF * Math.pow(2, fProgress * octaves); + fProgress = freq / maxFreq; + } + + const fExact = fProgress * (numF - 1); + const f0 = Math.floor(fExact); + const f1 = Math.min(f0 + 1, numF - 1); + const ff = fExact - f0; + + const m00 = frame0[f0] ?? minDB; + const m01 = frame0[f1] ?? minDB; + const m10 = frame1[f0] ?? minDB; + const m11 = frame1[f1] ?? minDB; + const mag = (m00 * (1 - ff) + m01 * ff) * (1 - tf) + (m10 * (1 - ff) + m11 * ff) * tf; + + const norm = Math.max(0, Math.min(1, (mag - minDB) / dbRange)); + const [r, g, b] = getColorRGB(norm, colorScheme); + const idx = (y * pw + x) * 4; + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = 255; + } + } + + ctx.putImageData(img, left, top); + + ctx.fillStyle = "#ccc"; + ctx.font = "12px 'Segoe UI', Arial"; + + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + + const freqLabels = buildFreqLabels(maxFreq, freqScale); + for (const freq of freqLabels) { + if (freq > maxFreq) + continue; + let yPos: number; + if (freqScale === "log2") { + const minF = 20; + const norm = Math.log2(freq / minF) / Math.log2(maxFreq / minF); + yPos = top + ph - norm * ph; + } else { + yPos = top + ph - (freq / maxFreq) * ph; + } + const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; + ctx.fillText(label, left - 8, yPos); + ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(left - 4, yPos); + ctx.lineTo(left + pw, yPos); + ctx.stroke(); + } + + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const timeStep = smartTimeStep(duration); + for (let t = 0; t <= duration; t += timeStep) { + const xPos = left + (t / duration) * pw; + const label = timeStep >= 60 + ? `${Math.floor(t / 60)}m${t % 60 ? (t % 60) + "s" : ""}` + : `${t}s`; + ctx.fillText(label, xPos, top + ph + 8); + ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(xPos, top); + ctx.lineTo(xPos, top + ph + 4); + ctx.stroke(); + } + + ctx.fillStyle = "#fff"; + ctx.font = "13px 'Segoe UI', Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText("Time (seconds)", left + pw / 2, CANVAS_H - 12); + + ctx.save(); + ctx.translate(24, top + ph / 2); + ctx.rotate(-Math.PI / 2); + ctx.textBaseline = "middle"; + ctx.fillText("Frequency (Hz)", 0, 0); + ctx.restore(); + + ctx.font = "12px 'Segoe UI', Arial"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "#fff"; + if (fileName) + ctx.fillText(fileName, left, 26); + + ctx.textAlign = "right"; + ctx.fillText(`Sample Rate: ${sampleRate} Hz`, left + pw, 26); + + const cbX = left + pw + 25; + const cbW = 14; + for (let i = 0; i < ph; i++) { + const norm = 1 - i / ph; + ctx.fillStyle = getColor(norm, colorScheme); + ctx.fillRect(cbX, top + i, cbW, 1); + } + ctx.strokeStyle = "rgba(255, 255, 255, 0.5)"; + ctx.lineWidth = 1; + ctx.strokeRect(cbX, top, cbW, ph); + + ctx.fillStyle = "#fff"; + ctx.font = "10px 'Segoe UI', Arial"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText("High", cbX + cbW + 6, top + 6); + ctx.fillText("Low", cbX + cbW + 6, top + ph - 6); +} + +function buildFreqLabels(maxFreq: number, scale: FreqScale): number[] { + if (scale === "log2") { + const labels: number[] = []; + for (let f = 20; f <= maxFreq; f *= 2) + labels.push(f); + for (let f = 100; f <= maxFreq; f *= 10) + labels.push(f); + return [...new Set(labels)].sort((a, b) => a - b); + } + if (maxFreq <= 24000) + return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000]; + if (maxFreq <= 48000) + return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000]; + if (maxFreq <= 96000) + return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000]; + return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000]; +} + +function smartTimeStep(duration: number): number { + if (duration <= 30) + return 5; + if (duration <= 60) + return 10; + if (duration <= 120) + return 15; + if (duration <= 300) + return 30; + if (duration <= 600) + return 60; + return 120; +} + +const COLOR_SCHEMES: { value: ColorScheme; label: string; gradient: string; }[] = [ + { value: "spek", label: "Spek", gradient: "linear-gradient(to right, #000050, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" }, + { value: "viridis", label: "Viridis", gradient: "linear-gradient(to right, #440154, #31688e, #35b779, #fde725)" }, + { value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000, #f00, #ff0, #fff)" }, + { value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" }, + { value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000, #fff)" }, +]; + +export const SpectrumVisualization = forwardRef(({ + sampleRate, + duration, + spectrumData, + fileName, + onReAnalyze, + isAnalyzingSpectrum, +}, ref) => { const canvasRef = useRef(null); + + useImperativeHandle(ref, () => ({ + getCanvasDataURL: () => { + if (!canvasRef.current) + return null; + return canvasRef.current.toDataURL("image/png"); + } + })); + + const [freqScale, setFreqScale] = useState("linear"); + const [colorScheme, setColorScheme] = useState("spek"); + + const [fftSize, setFftSize] = useState(() => { + if (spectrumData && spectrumData.freq_bins) { + return String(spectrumData.freq_bins * 2); + } + return "4096"; + }); + const [windowFunction, setWindowFunction] = useState("hann"); + useEffect(() => { + if (spectrumData && spectrumData.freq_bins) { + setFftSize(String(spectrumData.freq_bins * 2)); + } + }, [spectrumData]); + + const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => { + setFftSize(newFftSize); + setWindowFunction(newWindowFunc); + if (onReAnalyze) { + onReAnalyze(parseInt(newFftSize), newWindowFunc); + } + }; + + const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - const width = canvas.width; - const height = canvas.height; - const marginLeft = 70; - const marginRight = 70; - const marginTop = 30; - const marginBottom = 65; - const plotWidth = width - marginLeft - marginRight; - const plotHeight = height - marginTop - marginBottom; - ctx.fillStyle = "#000000"; - ctx.fillRect(0, 0, width, height); - const nyquistFreq = sampleRate / 2; + if (spectrumData) { - drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData); + renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName); + } else { + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); + ctx.fillStyle = "#444"; + ctx.font = "16px Arial"; + ctx.textAlign = "center"; + ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2); } - drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate); - drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight); - }, [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 = -200; - timeSlices.forEach((slice) => { - slice.magnitudes.forEach((db) => { - if (db > maxDB) - maxDB = db; - if (db < minDB && db > -200) - minDB = db; - }); - }); - minDB = Math.max(minDB, maxDB - 90); - const dbRange = maxDB - minDB; - const sliceWidth = Math.ceil(width / timeSlices.length); - for (let t = 0; t < timeSlices.length; t++) { - const slice = timeSlices[t]; - const xPos = x + (t / timeSlices.length) * width; - for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) { - const db = slice.magnitudes[f]; - const freq = (f / freqBins) * nyquistFreq; - const freqRatio = freq / nyquistFreq; - const yPos = y + height - (freqRatio * height); - 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 = Math.max(0, Math.min(1, (db - minDB) / dbRange)); - const color = getSpekColor(intensity); - ctx.fillStyle = color; - ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight); - } - } - }; - const getSpekColor = (intensity: number): string => { - if (intensity < 0.08) { - const t = intensity / 0.08; - return `rgb(0, 0, ${Math.floor(t * 80)})`; - } - else if (intensity < 0.18) { - const t = (intensity - 0.08) / 0.10; - return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`; - } - else if (intensity < 0.28) { - const t = (intensity - 0.18) / 0.10; - return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`; - } - else if (intensity < 0.40) { - const t = (intensity - 0.28) / 0.12; - return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`; - } - else if (intensity < 0.52) { - const t = (intensity - 0.40) / 0.12; - return `rgb(255, ${Math.floor(t * 100)}, 0)`; - } - else if (intensity < 0.65) { - const t = (intensity - 0.52) / 0.13; - return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`; - } - else if (intensity < 0.78) { - const t = (intensity - 0.65) / 0.13; - return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`; - } - else if (intensity < 0.90) { - const t = (intensity - 0.78) / 0.12; - return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`; - } - else { - const t = (intensity - 0.90) / 0.10; - return `rgb(255, 255, ${Math.floor(130 + t * 125)})`; - } - }; - const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => { - ctx.fillStyle = "#CCCCCC"; - ctx.font = "12px Arial"; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - const freqLabels = generateFreqLabels(nyquistFreq); - freqLabels.forEach(freq => { - if (freq <= nyquistFreq) { - const freqRatio = freq / nyquistFreq; - const yPos = y + height - (freqRatio * height); - const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; - ctx.fillText(label, x - 8, yPos); - } - }); - ctx.fillText("0", x - 8, y + height); - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - const timeStep = getTimeStep(duration); - for (let t = 0; t <= duration; t += timeStep) { - const xPos = x + (t / duration) * width; - ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8); - } - ctx.fillStyle = "#FFFFFF"; - ctx.font = "13px Arial"; - ctx.save(); - ctx.translate(12, y + height / 2); - ctx.rotate(-Math.PI / 2); - ctx.textAlign = "center"; - ctx.fillText("Frequency (Hz)", 0, 0); - ctx.restore(); - ctx.textAlign = "center"; - ctx.fillText("Time (seconds)", x + width / 2, y + height + 35); - ctx.textAlign = "right"; - ctx.fillStyle = "#CCCCCC"; - ctx.font = "12px Arial"; - ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3); - }; - const generateFreqLabels = (nyquistFreq: number): number[] => { - if (nyquistFreq <= 24000) { - return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000]; - } - else if (nyquistFreq <= 48000) { - return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000]; - } - else if (nyquistFreq <= 96000) { - return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000]; - } - else { - return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000]; - } - }; - const getTimeStep = (duration: number): number => { - if (duration <= 60) - return 15; - if (duration <= 120) - return 30; - if (duration <= 300) - return 30; - if (duration <= 600) - return 60; - return 60; - }; - const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => { - for (let i = 0; i < height; i++) { - const intensity = 1 - (i / height); - const color = getSpekColor(intensity); - ctx.fillStyle = color; - ctx.fillRect(x, y + i, width, 1); - } - ctx.strokeStyle = "#666666"; - ctx.lineWidth = 1; - ctx.strokeRect(x, y, width, height); - ctx.fillStyle = "#FFFFFF"; - ctx.font = "11px Arial"; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - ctx.fillText("High", x + width + 5, y + 10); - ctx.fillText("Low", x + width + 5, y + height - 10); - }; - return (
- -
); -} + }, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]); + + useEffect(() => { draw(); }, [draw]); + + useEffect(() => { draw(); }, [draw]); + + return ( +
+
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ {isAnalyzingSpectrum && ( +
+
+

Re-analyzing spectrum...

+
+ )} + +
+
+ ); +}); diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index 351f134..7c91e7e 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from "react"; -import { AnalyzeTrack } from "../../wailsjs/go/main/App"; +import { AnalyzeTrack, AnalyzeSpectrumWithParams } 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"; @@ -135,6 +135,29 @@ export function useAudioAnalysis() { } }; }, [result, selectedFilePath, spectrumLoading]); + + const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { + if (!selectedFilePath || !result) + return; + setSpectrumLoading(true); + try { + const response = await AnalyzeSpectrumWithParams(selectedFilePath, fftSize, windowFunction); + const spectrumData = JSON.parse(response); + setResult(prev => prev ? { ...prev, spectrum: spectrumData } : null); + setSpectrumCache(selectedFilePath, spectrumData); + } + catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; + logger.error(`Spectrum re-analysis error: ${errorMessage}`); + toast.error("Spectrum Analysis Failed", { + description: errorMessage, + }); + } + finally { + setSpectrumLoading(false); + } + }, [selectedFilePath, result]); + return { analyzing, result, @@ -142,6 +165,7 @@ export function useAudioAnalysis() { selectedFilePath, spectrumLoading, analyzeFile, + reAnalyzeSpectrum, clearResult, }; }