diff --git a/app.go b/app.go index cab47c1..ee82710 100644 --- a/app.go +++ b/app.go @@ -791,47 +791,6 @@ func (a *App) ClearFetchHistoryByType(itemType string) error { return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC") } -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 -} - -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") @@ -856,30 +815,6 @@ func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string 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") - } - - results := make([]*backend.AnalysisResult, 0, len(filePaths)) - - for _, filePath := range filePaths { - result, err := backend.AnalyzeTrack(filePath) - if err != nil { - - 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 -} - type LyricsDownloadRequest struct { SpotifyID string `json:"spotify_id"` TrackName string `json:"track_name"` @@ -1272,6 +1207,15 @@ func (a *App) ReadTextFile(filePath string) (string, error) { return string(content), nil } +func (a *App) ReadFileAsBase64(filePath string) (string, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(content), nil +} + func (a *App) RenameFileTo(oldPath, newName string) error { dir := filepath.Dir(oldPath) ext := filepath.Ext(oldPath) diff --git a/backend/analysis.go b/backend/analysis.go index a15ca78..daf0f0e 100644 --- a/backend/analysis.go +++ b/backend/analysis.go @@ -2,170 +2,26 @@ package backend import ( "fmt" - "math" "os" "os/exec" "strconv" "strings" "time" - - "github.com/go-flac/go-flac" - mewflac "github.com/mewkiz/flac" ) type AnalysisResult struct { - FilePath string `json:"file_path"` - FileSize int64 `json:"file_size"` - SampleRate uint32 `json:"sample_rate"` - Channels uint8 `json:"channels"` - BitsPerSample uint8 `json:"bits_per_sample"` - TotalSamples uint64 `json:"total_samples"` - Duration float64 `json:"duration"` - Bitrate int `json:"bit_rate"` - 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"` -} - -func AnalyzeTrack(filepath string) (*AnalysisResult, error) { - if !fileExists(filepath) { - return nil, fmt.Errorf("file does not exist: %s", filepath) - } - - fileInfo, err := os.Stat(filepath) - if err != nil { - return nil, fmt.Errorf("failed to get file info: %w", err) - } - - f, err := flac.ParseFile(filepath) - if err != nil { - return nil, fmt.Errorf("failed to parse FLAC file: %w", err) - } - - result := &AnalysisResult{ - FilePath: filepath, - FileSize: fileInfo.Size(), - } - - if len(f.Meta) > 0 { - streamInfo := f.Meta[0] - if streamInfo.Type == flac.StreamInfo { - - data := streamInfo.Data - if len(data) >= 18 { - - result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 - - result.Channels = ((data[12] >> 1) & 0x07) + 1 - - result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1 - - result.TotalSamples = uint64(data[13]&0x0F)<<32 | - uint64(data[14])<<24 | - uint64(data[15])<<16 | - uint64(data[16])<<8 | - uint64(data[17]) - - if result.SampleRate > 0 { - result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) - } - - } - } - } - - spectrum, err := AnalyzeSpectrum(filepath) - if err != nil { - - fmt.Printf("Warning: failed to analyze spectrum: %v\n", err) - } else { - result.Spectrum = spectrum - - calculateRealAudioMetrics(result, filepath) - } - - result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) - - return result, nil -} - -func calculateRealAudioMetrics(result *AnalysisResult, filepath string) { - - samples, err := decodeFLACForMetrics(filepath) - if err != nil { - return - } - - 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 - } - - peakDB := 20.0 * math.Log10(peak) - result.PeakAmplitude = peakDB - - rms := math.Sqrt(sumSquares / float64(len(samples))) - rmsDB := 20.0 * math.Log10(rms) - result.RMSLevel = rmsDB - - result.DynamicRange = peakDB - rmsDB -} - -func decodeFLACForMetrics(filepath string) ([]float64, error) { - stream, err := mewflac.ParseFile(filepath) - if err != nil { - return nil, err - } - defer stream.Close() - - maxSamples := 10000000 - samples := make([]float64, 0, maxSamples) - - for { - frame, err := stream.ParseNext() - if err != nil { - break - } - - var channelSamples []int32 - if len(frame.Subframes) > 0 { - channelSamples = frame.Subframes[0].Samples - } - - 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 + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + SampleRate uint32 `json:"sample_rate"` + Channels uint8 `json:"channels"` + BitsPerSample uint8 `json:"bits_per_sample"` + TotalSamples uint64 `json:"total_samples"` + Duration float64 `json:"duration"` + Bitrate int `json:"bit_rate"` + BitDepth string `json:"bit_depth"` + DynamicRange float64 `json:"dynamic_range"` + PeakAmplitude float64 `json:"peak_amplitude"` + RMSLevel float64 `json:"rms_level"` } func GetTrackMetadata(filepath string) (*AnalysisResult, error) { @@ -194,20 +50,23 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) { "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate", - "-of", "default=noprint_wrappers=1:nokey=1", + "-of", "default=noprint_wrappers=0", filePath, } - cmd := exec.Command(ffprobePath, args...) setHideWindow(cmd) output, err := cmd.CombinedOutput() if err != nil { - return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output)) + return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output)) } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) < 4 { - return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output)) + infoMap := make(map[string]string) + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } } res := &AnalysisResult{ @@ -218,28 +77,6 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) { res.FileSize = info.Size() } - infoMap := make(map[string]string) - - args = []string{ - "-v", "error", - "-select_streams", "a:0", - "-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate", - "-of", "default=noprint_wrappers=0", - filePath, - } - cmd = exec.Command(ffprobePath, args...) - setHideWindow(cmd) - output, err = cmd.CombinedOutput() - if err == nil { - lines = strings.Split(string(output), "\n") - for _, line := range lines { - if strings.Contains(line, "=") { - parts := strings.SplitN(line, "=", 2) - infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) - } - } - } - if val, ok := infoMap["sample_rate"]; ok { s, _ := strconv.Atoi(val) res.SampleRate = uint32(s) diff --git a/backend/spectrum.go b/backend/spectrum.go deleted file mode 100644 index ba26b09..0000000 --- a/backend/spectrum.go +++ /dev/null @@ -1,222 +0,0 @@ -package backend - -import ( - "fmt" - "math" - "math/cmplx" - - "github.com/mewkiz/flac" -) - -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"` -} - -type TimeSlice struct { - Time float64 `json:"time"` - Magnitudes []float64 `json:"magnitudes"` -} - -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) - } - defer stream.Close() - - info := stream.Info - sampleRate := int(info.SampleRate) - channels := int(info.NChannels) - - 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") - } - - 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) { - var allSamples []float64 - maxSamples := 10 * 1024 * 1024 - - for { - frame, err := stream.ParseNext() - if err != nil { - - break - } - - for i := 0; i < frame.Subframes[0].NSamples; i++ { - var sample float64 - - for ch := 0; ch < channels; ch++ { - sample += float64(frame.Subframes[ch].Samples[i]) - } - sample /= float64(channels) - - allSamples = append(allSamples, sample) - - if len(allSamples) >= maxSamples { - return allSamples, nil - } - } - } - - return allSamples, nil -} - -func calculateSpectrumWithParams(samples []float64, sampleRate, fftSize int, windowFunc string) *SpectrumData { - 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 := applyWindow(window, windowFunc) - - 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, - } -} - -func applyWindow(samples []float64, windowType string) []float64 { - n := len(samples) - windowed := make([]float64, n) - - for i := 0; i < n; i++ { - 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) - - x := make([]complex128, n) - for i := 0; i < n; i++ { - x[i] = complex(samples[i], 0) - } - - return fftRecursive(x) -} - -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 -} diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx index 722b6a9..cb830fa 100644 --- a/frontend/src/components/AudioAnalysis.tsx +++ b/frontend/src/components/AudioAnalysis.tsx @@ -140,7 +140,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton {result.spectrum && (() => { const frames = result.spectrum.time_slices.length; - const fftSize = result.spectrum.freq_bins * 2; + const fftSize = (result.spectrum.freq_bins - 1) * 2; const freqRes = result.sample_rate / fftSize; return ( diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index c71d6c1..f907a3e 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -1,27 +1,118 @@ -import { useState, useCallback, useEffect, useRef } from "react"; +import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react"; import { Button } from "@/components/ui/button"; 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, SaveSpectrumImage } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; interface AudioAnalysisPageProps { onBack?: () => void; } -export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - 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); +function isFlacPath(filePath: string): boolean { + return filePath.toLowerCase().endsWith(".flac"); +} - const handleExport = async () => { - if (!selectedFilePath || !spectrumRef.current) +function isFlacFile(file: File): boolean { + const name = file.name.toLowerCase(); + return ( + name.endsWith(".flac") || + file.type === "audio/flac" || + file.type === "audio/x-flac" + ); +} + +function isAbsolutePath(filePath: string): boolean { + return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath); +} + +function fileNameFromPath(filePath: string): string { + const parts = filePath.split(/[/\\]/); + return parts[parts.length - 1] || filePath; +} + +export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { + const { + analyzing, + result, + analyzeFile, + analyzeFilePath, + clearResult, + selectedFilePath, + spectrumLoading, + reAnalyzeSpectrum, + } = useAudioAnalysis(); + + const [isDragging, setIsDragging] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const fileInputRef = useRef(null); + const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null); + + const analyzeSelectedPath = useCallback(async (filePath: string) => { + if (!isFlacPath(filePath)) { + toast.error("Invalid File Type", { + description: "Please select a FLAC file for analysis", + }); return; + } + await analyzeFilePath(filePath); + }, [analyzeFilePath]); + + const analyzeSelectedFile = useCallback(async (file: File) => { + if (!isFlacFile(file)) { + toast.error("Invalid File Type", { + description: "Please select a FLAC file for analysis", + }); + return; + } + await analyzeFile(file); + }, [analyzeFile]); + + const handleSelectFile = useCallback(async () => { + try { + const filePath = await SelectFile(); + if (!filePath) { + return; + } + await analyzeSelectedPath(filePath); + } catch { + fileInputRef.current?.click(); + } + }, [analyzeSelectedPath]); + + const handleInputChange = useCallback(async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + await analyzeSelectedFile(file); + e.target.value = ""; + }, [analyzeSelectedFile]); + + const handleHtmlDrop = useCallback(async (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (!file) return; + await analyzeSelectedFile(file); + }, [analyzeSelectedFile]); + + useEffect(() => { + OnFileDrop((_x, _y, paths) => { + setIsDragging(false); + const droppedPath = paths?.[0]; + if (!droppedPath) return; + void analyzeSelectedPath(droppedPath); + }, true); + + return () => { + OnFileDropOff(); + }; + }, [analyzeSelectedPath]); + + const handleExport = useCallback(async () => { + if (!spectrumRef.current) return; const dataUrl = spectrumRef.current.getCanvasDataURL(); if (!dataUrl) { @@ -31,68 +122,51 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { setIsExporting(true); try { - const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl); + if (selectedFilePath && isAbsolutePath(selectedFilePath)) { + const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl); + toast.success("Exported Successfully", { + description: `Saved to: ${outPath}`, + }); + return; + } + + const base = selectedFilePath + ? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "") + : "spectrogram"; + const a = document.createElement("a"); + a.href = dataUrl; + a.download = `${base}_spectrogram.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); toast.success("Exported Successfully", { - description: `Saved to: ${outPath}`, + description: "Spectrogram image downloaded", }); - } - catch (err) { + } catch (err) { toast.error("Export Failed", { - description: err instanceof Error ? err.message : "Failed to save image", + description: err instanceof Error ? err.message : "Failed to export image", }); - } - finally { + } finally { setIsExporting(false); } - }; - - const handleSelectFile = async () => { - try { - const filePath = await SelectFile(); - if (filePath) { - await analyzeFile(filePath); - } - } - catch (err) { - toast.error("File Selection Failed", { - description: err instanceof Error ? err.message : "Failed to select file", - }); - } - }; - - const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => { - setIsDragging(false); - if (paths.length === 0) - return; - const filePath = paths[0]; - if (!filePath.toLowerCase().endsWith(".flac")) { - toast.error("Invalid File Type", { - description: "Please drop a FLAC file for analysis", - }); - return; - } - await analyzeFile(filePath); - }, [analyzeFile]); - - useEffect(() => { - OnFileDrop((x, y, paths) => { - handleFileDrop(x, y, paths); - }, true); - return () => { - OnFileDropOff(); - }; - }, [handleFileDrop]); + }, [selectedFilePath]); const handleAnalyzeAnother = () => { clearResult(); }; - const fileName = selectedFilePath - ? selectedFilePath.split(/[/\\]/).pop() - : undefined; + const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined; return (
+ +
{onBack && ( @@ -123,13 +197,21 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { {!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} + className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${ + isDragging + ? "border-primary bg-primary/10" + : "border-muted-foreground/30" + }`} + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={handleHtmlDrop} + style={{ "--wails-drop-target": "drop" } as CSSProperties} >
diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index bfa0a71..7af73c7 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -1,7 +1,13 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react"; import type { SpectrumData } from "@/types/api"; import { Label } from "@/components/ui/label"; -import { forwardRef, useImperativeHandle } from "react"; +import { + loadAudioAnalysisPreferences, + saveAudioAnalysisPreferences, + type AnalyzerColorScheme, + type AnalyzerFreqScale, + type AnalyzerWindowFunction, +} from "@/lib/audio-analysis-preferences"; import { Select, SelectContent, @@ -23,326 +29,457 @@ interface SpectrumVisualizationProps { isAnalyzingSpectrum?: boolean; } -type ColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale"; +type ColorScheme = AnalyzerColorScheme; +type FreqScale = AnalyzerFreqScale; +type WindowFunction = AnalyzerWindowFunction; -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); - } +const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 }; +const CANVAS_W = 1100; +const CANVAS_H = 600; +const MAX_RENDER_HEIGHT = 1080; + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); } -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 spekColorMap(t: number): [number, number, number] { + const colors: Array<[number, number, number]> = [ + [0, 0, 0], + [0, 0, 25], + [0, 0, 50], + [0, 0, 80], + [20, 0, 120], + [50, 0, 150], + [80, 0, 180], + [120, 0, 120], + [150, 0, 80], + [180, 0, 40], + [210, 0, 0], + [240, 30, 0], + [255, 60, 0], + [255, 100, 0], + [255, 140, 0], + [255, 180, 0], + [255, 210, 0], + [255, 235, 0], + [255, 250, 50], + [255, 255, 100], + [255, 255, 150], + [255, 255, 200], + [255, 255, 255], + ]; + + const scaled = t * (colors.length - 1); + const idx = Math.floor(scaled); + const fraction = scaled - idx; + + if (idx >= colors.length - 1) { + return colors[colors.length - 1]; + } + + const c1 = colors[idx]; + const c2 = colors[idx + 1]; + return [ + Math.round(c1[0] + (c2[0] - c1[0]) * fraction), + Math.round(c1[1] + (c2[1] - c1[1]) * fraction), + Math.round(c1[2] + (c2[2] - c1[2]) * fraction), + ]; } -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][] = [ +function viridisColorMap(t: number): [number, number, number] { + const colors: Array<[number, number, number]> = [ [68, 1, 84], - [72, 36, 117], + [70, 20, 100], + [72, 40, 120], + [67, 62, 133], [62, 74, 137], + [55, 89, 140], [49, 104, 142], + [43, 117, 142], [38, 130, 142], + [35, 144, 140], [31, 158, 137], + [42, 171, 129], [53, 183, 121], - [110, 206, 88], - [181, 222, 43], + [81, 194, 105], + [109, 205, 89], + [144, 214, 67], + [180, 222, 44], + [216, 227, 41], [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]; + + const scaled = t * (colors.length - 1); + const idx = Math.floor(scaled); + const fraction = scaled - idx; + + if (idx >= colors.length - 1) { + return colors[colors.length - 1]; + } + + const c1 = colors[idx]; + const c2 = colors[idx + 1]; + return [ + Math.floor(c1[0] + (c2[0] - c1[0]) * fraction), + Math.floor(c1[1] + (c2[1] - c1[1]) * fraction), + Math.floor(c1[2] + (c2[2] - c1[2]) * fraction), + ]; +} + +function hotColorMap(t: number): [number, number, number] { + if (t < 0.33) { + return [Math.floor(t * 3 * 255), 0, 0]; + } + if (t < 0.66) { + return [255, Math.floor((t - 0.33) * 3 * 255), 0]; + } + return [255, 255, Math.floor((t - 0.66) * 3 * 255)]; +} + +function coolColorMap(t: number): [number, number, number] { + return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255]; +} + +function getColorValues(norm: number, scheme: ColorScheme): [number, number, number] { + const value = clamp01(norm); + switch (scheme) { + case "spek": + return spekColorMap(value); + case "viridis": + return viridisColorMap(value); + case "hot": + return hotColorMap(value); + case "cool": + return coolColorMap(value); + case "grayscale": + default: { + const gray = Math.floor(value * 255); + return [gray, gray, gray]; + } + } +} + +function getColorString(norm: number, scheme: ColorScheme): string { + const [r, g, b] = getColorValues(norm, scheme); return `rgb(${r},${g},${b})`; } -function hotColor(t: number): string { - if (t < 0.33) { - return `rgb(${Math.round(t / 0.33 * 255)},0,0)`; +function addAxisLabels( + ctx: CanvasRenderingContext2D, + plotWidth: number, + plotHeight: number, + sampleRate: number, + duration: number, + freqScale: FreqScale, + fileName?: string, +) { + ctx.fillStyle = "#ffffff"; + ctx.font = "12px Segoe UI"; + + ctx.textAlign = "center"; + const widthFactor = plotWidth / 1000; + let timeStep: number; + + if (duration <= 10) { + timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5); + } else if (duration <= 30) { + timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1); + } else if (duration <= 120) { + timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5); + } else if (duration <= 600) { + timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20); + } else { + timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40); } - if (t < 0.67) { - return `rgb(255,${Math.round((t - 0.33) / 0.34 * 255)},0)`; + + if (duration > 0) { + for (let time = 0; time <= duration + 1e-9; time += timeStep) { + const timeProgress = time / duration; + const x = MARGIN.left + timeProgress * (plotWidth - 1); + const y = CANVAS_H - MARGIN.bottom + 20; + + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, MARGIN.top + plotHeight); + ctx.lineTo(x, MARGIN.top + plotHeight + 5); + ctx.stroke(); + + let label: string; + if (timeStep >= 60) { + const minutes = Math.floor(time / 60); + const seconds = time % 60; + label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; + } else { + label = `${time}s`; + } + ctx.fillText(label, x, y); + } } - return `rgb(255,255,${Math.round((t - 0.67) / 0.33 * 255)})`; + + ctx.textAlign = "right"; + const maxFreq = sampleRate / 2; + + if (freqScale === "log2") { + const heightFactor = plotHeight / 500; + const minFreq = 20; + const frequencies: number[] = []; + const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2); + + let octaveCount = 0; + for (let freq = minFreq; freq <= maxFreq; freq *= 2) { + if (octaveCount % octaveStep === 0) { + frequencies.push(freq); + } + octaveCount++; + } + + for (const freq of frequencies) { + const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq); + const y = MARGIN.top + plotHeight * (1 - freqNormalized); + + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(MARGIN.left - 5, y); + ctx.lineTo(MARGIN.left, y); + ctx.stroke(); + + const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`; + ctx.fillText(label, MARGIN.left - 10, y + 4); + } + } else { + const heightFactor = plotHeight / 500; + let freqStep: number; + + if (maxFreq <= 8000) { + freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500); + } else if (maxFreq <= 16000) { + freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000); + } else if (maxFreq <= 24000) { + freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000); + } else { + freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000); + } + + for (let freq = 0; freq <= maxFreq; freq += freqStep) { + const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4; + const x = MARGIN.left - 15; + + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(MARGIN.left - 5, y - 4); + ctx.lineTo(MARGIN.left, y - 4); + ctx.stroke(); + + let label: string; + if (freq === 0) { + label = "0"; + } else if (freq >= 1000) { + label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`; + } else { + label = `${freq}`; + } + ctx.fillText(label, x, y); + } + } + + ctx.textAlign = "center"; + ctx.font = "14px Segoe UI"; + ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15); + + ctx.save(); + ctx.translate(25, CANVAS_H / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText("Frequency (Hz)", 0, 0); + ctx.restore(); + + ctx.font = "12px Segoe UI"; + if (fileName) { + ctx.textAlign = "left"; + ctx.fillText(fileName, MARGIN.left + 15, 25); + } + + ctx.textAlign = "right"; + ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25); } -function coolColor(t: number): string { - if (t < 0.33) { - return `rgb(0,0,${Math.round(128 + t / 0.33 * 127)})`; +function drawColorBar( + ctx: CanvasRenderingContext2D, + plotHeight: number, + colorScheme: ColorScheme, +) { + const colorBarWidth = 20; + const colorBarX = CANVAS_W - MARGIN.right + 30; + const colorBarY = MARGIN.top; + const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY); + + for (let i = 0; i <= 100; i++) { + const value = i / 100; + gradient.addColorStop(value, getColorString(value, colorScheme)); } - 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)`; + + ctx.fillStyle = gradient; + ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight); + + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight); + + ctx.fillStyle = "#ffffff"; + ctx.font = "10px Segoe UI"; + ctx.textAlign = "left"; + ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12); + ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5); } -type FreqScale = "linear" | "log2"; - -const MARGIN = { top: 50, right: 100, bottom: 50, left: 80 }; -const CANVAS_W = 1200; -const CANVAS_H = 600; - -function renderSpectrogram( +async function renderSpectrogram( ctx: CanvasRenderingContext2D, spectrum: SpectrumData, sampleRate: number, duration: number, freqScale: FreqScale, colorScheme: ColorScheme, - fileName?: string, + fileName: string | undefined, + shouldCancel: () => boolean, ) { - const { top, right, bottom, left } = MARGIN; - const pw = CANVAS_W - left - right; - const ph = CANVAS_H - top - bottom; + const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right; + const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom; - ctx.fillStyle = "#000"; + ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); - const slices = spectrum.time_slices; - if (!slices || slices.length === 0) + const spectrogramData = spectrum.time_slices; + const numTimeFrames = spectrogramData.length; + const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0; + if (numTimeFrames === 0 || numFreqBins === 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; + let minMag = Number.POSITIVE_INFINITY; + let maxMag = Number.NEGATIVE_INFINITY; + const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1; - 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; + for (let i = 0; i < numTimeFrames; i += sampleStep) { + const frame = spectrogramData[i].magnitudes; + for (const mag of frame) { + if (Number.isFinite(mag)) { + if (mag < minMag) + minMag = mag; + if (mag > maxMag) + maxMag = mag; } - - 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); + if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) { + minMag = -120; + maxMag = 0; + } - ctx.fillStyle = "#ccc"; - ctx.font = "12px 'Segoe UI', Arial"; + const magRange = maxMag - minMag; + const safeMagRange = magRange > 0 ? magRange : 1; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; + const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT); + const highResData = highResImageData.data; + const CHUNK_SIZE = 50; - 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; + for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) { + if (shouldCancel()) { + return; + } + + const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth); + for (let x = xStart; x < xEnd; x++) { + const timeProgress = x / (plotWidth - 1); + const exactTimePos = timeProgress * (numTimeFrames - 1); + const timeIdx = Math.floor(exactTimePos); + const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1); + const timeFrac = exactTimePos - timeIdx; + + const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes; + const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1; + + for (let y = 0; y < MAX_RENDER_HEIGHT; y++) { + let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1); + + if (freqScale === "log2") { + const minFreq = 20; + const maxFreq = sampleRate / 2; + const octaves = Math.log2(maxFreq / minFreq); + const octave = freqProgress * octaves; + const freq = minFreq * Math.pow(2, octave); + freqProgress = freq / maxFreq; + } + + const exactFreqPos = freqProgress * (numFreqBins - 1); + const freqIdx = Math.floor(exactFreqPos); + const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1); + const freqFrac = exactFreqPos - freqIdx; + + let magnitude: number; + if (timeFrac === 0 && freqFrac === 0) { + magnitude = frame1[freqIdx] ?? 0; + } else { + const mag11 = frame1[freqIdx] ?? 0; + const mag12 = frame1[freqIdx2] ?? 0; + const mag21 = frame2[freqIdx] ?? 0; + const mag22 = frame2[freqIdx2] ?? 0; + + const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac; + const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac; + magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac; + } + + const normalizedMag = clamp01((magnitude - minMag) / safeMagRange); + const [r, g, b] = getColorValues(normalizedMag, colorScheme); + const pixelIdx = (y * plotWidth + x) * 4; + highResData[pixelIdx] = r; + highResData[pixelIdx + 1] = g; + highResData[pixelIdx + 2] = b; + highResData[pixelIdx + 3] = 255; + } + } + + if (xStart + CHUNK_SIZE < plotWidth) { + await new Promise((resolve) => setTimeout(resolve, 1)); } - 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(); + if (shouldCancel()) { + return; } - 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); + const finalImageData = ctx.createImageData(plotWidth, plotHeight); + const finalData = finalImageData.data; - ctx.save(); - ctx.translate(24, top + ph / 2); - ctx.rotate(-Math.PI / 2); - ctx.textBaseline = "middle"; - ctx.fillText("Frequency (Hz)", 0, 0); - ctx.restore(); + for (let y = 0; y < plotHeight; y++) { + for (let x = 0; x < plotWidth; x++) { + const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT); + const highResIdx = (highResY * plotWidth + x) * 4; + const finalIdx = (y * plotWidth + x) * 4; - 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); + if (highResIdx < highResData.length) { + finalData[finalIdx] = highResData[highResIdx]; + finalData[finalIdx + 1] = highResData[highResIdx + 1]; + finalData[finalIdx + 2] = highResData[highResIdx + 2]; + finalData[finalIdx + 3] = highResData[highResIdx + 3]; + } + } } - 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; + ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top); + addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName); + drawColorBar(ctx, plotHeight, colorScheme); } 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: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #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: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" }, { value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" }, - { value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000, #fff)" }, + { value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" }, ]; export const SpectrumVisualization = forwardRef(({ @@ -354,41 +491,37 @@ export const SpectrumVisualization = forwardRef { const canvasRef = useRef(null); + const preferencesRef = useRef(loadAudioAnalysisPreferences()); 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"); + const [freqScale, setFreqScale] = useState(preferencesRef.current.freqScale); + const [colorScheme, setColorScheme] = useState(preferencesRef.current.colorScheme); + const [fftSize, setFftSize] = useState(() => String(preferencesRef.current.fftSize)); + const [windowFunction, setWindowFunction] = useState(preferencesRef.current.windowFunction); useEffect(() => { - if (spectrumData && spectrumData.freq_bins) { - setFftSize(String(spectrumData.freq_bins * 2)); + if (spectrumData?.freq_bins) { + setFftSize(String((spectrumData.freq_bins - 1) * 2)); } }, [spectrumData]); - const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => { - setFftSize(newFftSize); - setWindowFunction(newWindowFunc); - if (onReAnalyze) { - onReAnalyze(parseInt(newFftSize), newWindowFunc); - } - }; + useEffect(() => { + saveAudioAnalysisPreferences({ + colorScheme, + freqScale, + fftSize: Number(fftSize), + windowFunction, + }); + }, [colorScheme, freqScale, fftSize, windowFunction]); - const draw = useCallback(() => { + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -396,21 +529,41 @@ export const SpectrumVisualization = forwardRef canceled; + if (spectrumData) { - renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName); + void renderSpectrogram( + ctx, + spectrumData, + sampleRate, + duration, + freqScale, + colorScheme, + fileName, + shouldCancel, + ); } else { - ctx.fillStyle = "#000"; + ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); - ctx.fillStyle = "#444"; + ctx.fillStyle = "#444444"; ctx.font = "16px Arial"; ctx.textAlign = "center"; ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2); } + + return () => { + canceled = true; + }; }, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]); - useEffect(() => { draw(); }, [draw]); - - useEffect(() => { draw(); }, [draw]); + const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => { + setFftSize(newFftSize); + setWindowFunction(newWindowFunc as WindowFunction); + if (onReAnalyze) { + onReAnalyze(parseInt(newFftSize, 10), newWindowFunc); + } + }; return (
@@ -422,14 +575,14 @@ export const SpectrumVisualization = forwardRef - {COLOR_SCHEMES.map((s) => ( - + {COLOR_SCHEMES.map((scheme) => ( +
- {s.label} + {scheme.label}
))} @@ -441,8 +594,8 @@ export const SpectrumVisualization = forwardRef - setFreqScale(v as FreqScale)} disabled={isAnalyzingSpectrum}> + @@ -470,7 +623,7 @@ export const SpectrumVisualization = forwardRef