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,
};
}