From 41eda2d23011a2fbde09e84f7b4c2d70ff95ae9f Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Thu, 2 Apr 2026 11:30:00 +0700 Subject: [PATCH] .batch audio quality analyzer --- backend/file_dialog.go | 8 +- backend/filemanager.go | 2 +- frontend/src/components/AudioAnalysisPage.tsx | 1147 +++++++++++++++-- .../src/components/SpectrumVisualization.tsx | 39 +- frontend/src/hooks/useAudioAnalysis.ts | 352 ++++- 5 files changed, 1376 insertions(+), 172 deletions(-) diff --git a/backend/file_dialog.go b/backend/file_dialog.go index b47e1de..a2f740f 100644 --- a/backend/file_dialog.go +++ b/backend/file_dialog.go @@ -11,8 +11,8 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) { Title: "Select Audio Files", Filters: []runtime.FileFilter{ { - DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)", - Pattern: "*.mp3;*.m4a;*.flac", + DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)", + Pattern: "*.mp3;*.m4a;*.flac;*.aac", }, { DisplayName: "MP3 Files (*.mp3)", @@ -26,6 +26,10 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) { DisplayName: "FLAC Files (*.flac)", Pattern: "*.flac", }, + { + DisplayName: "AAC Files (*.aac)", + Pattern: "*.aac", + }, { DisplayName: "All Files (*.*)", Pattern: "*.*", diff --git a/backend/filemanager.go b/backend/filemanager.go index dda8dc3..9b915fb 100644 --- a/backend/filemanager.go +++ b/backend/filemanager.go @@ -94,7 +94,7 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) { } ext := strings.ToLower(filepath.Ext(path)) - if ext == ".flac" || ext == ".mp3" || ext == ".m4a" { + if ext == ".flac" || ext == ".mp3" || ext == ".m4a" || ext == ".aac" { result = append(result, FileInfo{ Name: info.Name(), Path: path, diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index 962b6b1..74061ba 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -1,16 +1,50 @@ -import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react"; +import { useState, useCallback, useRef, useEffect, type ChangeEvent, type CSSProperties, type DragEvent } from "react"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; -import { Upload, ArrowLeft, Trash2, Download } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { Upload, ArrowLeft, Trash2, Download, FolderOpen, X, AlertCircle, CheckCircle2, FileMusic, ChevronDown, Play, StopCircle } from "lucide-react"; import { AudioAnalysis } from "@/components/AudioAnalysis"; -import { SpectrumVisualization } from "@/components/SpectrumVisualization"; +import { SpectrumVisualization, createSpectrogramDataURL, type SpectrumVisualizationHandle } from "@/components/SpectrumVisualization"; import { useAudioAnalysis } from "@/hooks/useAudioAnalysis"; +import type { AnalysisResult } from "@/types/api"; +import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App"; +import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; + interface AudioAnalysisPageProps { onBack?: () => void; } + +type BatchItemStatus = "pending" | "analyzing" | "success" | "error"; +type BatchItemSource = "path" | "browser"; + +interface BatchAnalysisItem { + id: string; + source: BatchItemSource; + path: string; + name: string; + size: number; + status: BatchItemStatus; + error?: string; + result?: AnalysisResult; + file?: File; +} + +interface QueueProgressState { + completed: number; + total: number; + fileName: string; +} + +const EMPTY_PROGRESS_STATE: QueueProgressState = { + completed: 0, + total: 0, + fileName: "", +}; + const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; const SUPPORTED_AUDIO_ACCEPT = [ ".flac", @@ -51,98 +85,554 @@ function fileNameFromPath(filePath: string): string { const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] || filePath; } + +function browserFileId(file: File): string { + return `browser:${file.name}:${file.size}:${file.lastModified}`; +} + +function downloadDataURL(dataUrl: string, fileName: string): void { + const link = document.createElement("a"); + link.href = dataUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +function formatFileSize(bytes: number): string { + if (bytes <= 0) { + return "0 B"; + } + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const index = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); + return `${parseFloat((bytes / Math.pow(k, index)).toFixed(1))} ${sizes[index]}`; +} + +function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +function itemMetaLine(item: BatchAnalysisItem): string { + if (item.result) { + const parts = [ + item.result.file_type ?? "Audio", + `${(item.result.sample_rate / 1000).toFixed(1)} kHz`, + formatDuration(item.result.duration), + ]; + + if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) { + parts.push(`${item.result.bitrate_kbps} kbps`); + } + + return parts.join(" • "); + } + + switch (item.status) { + case "analyzing": + return "Analyzing audio quality..."; + case "error": + return item.error || "Analysis failed"; + case "pending": + default: + return "Waiting to be analyzed"; + } +} + +function statusIcon(status: BatchItemStatus) { + switch (status) { + case "analyzing": + return ; + case "success": + return ; + case "error": + return ; + case "pending": + default: + return ; + } +} export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis(); + const { + analysisProgress, + spectrumLoading, + spectrumProgress, + analyzeFile, + analyzeFilePath, + cancelAnalysis, + loadStoredAnalysis, + clearStoredAnalysis, + reAnalyzeSpectrum, + clearResult, + } = useAudioAnalysis(); + + const [items, setItems] = useState([]); + const [activeItemId, setActiveItemId] = useState(null); const [isDragging, setIsDragging] = useState(false); - const [isExporting, setIsExporting] = useState(false); + const [isExportingSelected, setIsExportingSelected] = useState(false); + const [isExportingBatch, setIsExportingBatch] = useState(false); + const [isBatchRunning, setIsBatchRunning] = useState(false); + const [batchProgress, setBatchProgress] = useState(EMPTY_PROGRESS_STATE); + const [exportProgress, setExportProgress] = useState(EMPTY_PROGRESS_STATE); + const fileInputRef = useRef(null); - const spectrumRef = useRef<{ - getCanvasDataURL: () => string | null; - }>(null); - const analyzeSelectedPath = useCallback(async (filePath: string) => { - if (!isSupportedAudioPath(filePath)) { - toast.error("Invalid File Type", { - description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`, - }); + const spectrumRef = useRef(null); + const batchRunIdRef = useRef(0); + const itemsRef = useRef(items); + const activeItemIdRef = useRef(activeItemId); + + useEffect(() => { + itemsRef.current = items; + }, [items]); + + useEffect(() => { + activeItemIdRef.current = activeItemId; + }, [activeItemId]); + + const setActiveSelection = useCallback((nextId: string | null) => { + activeItemIdRef.current = nextId; + setActiveItemId(nextId); + }, []); + + const activeItem = items.find((item) => item.id === activeItemId) ?? null; + const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum); + const pendingItems = items.filter((item) => item.status === "pending"); + const isSingleMode = items.length === 1; + const isBatchMode = items.length > 1; + const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0; + + const batchPercent = batchProgress.total > 0 + ? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100))) + : 0; + const exportPercent = exportProgress.total > 0 + ? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100))) + : 0; + + useEffect(() => { + if (!activeItem?.result) { return; } - await analyzeFilePath(filePath); - }, [analyzeFilePath]); - const analyzeSelectedFile = useCallback(async (file: File) => { - if (!isSupportedAudioFile(file)) { - toast.error("Invalid File Type", { - description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`, - }); + + loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path); + }, [activeItem, loadStoredAnalysis]); + + const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => { + if (entries.length === 0) { return; } - await analyzeFile(file); - }, [analyzeFile]); - const handleSelectFile = useCallback(async () => { + + const runId = batchRunIdRef.current + 1; + batchRunIdRef.current = runId; + setIsBatchRunning(true); + setBatchProgress({ + completed: 0, + total: entries.length, + fileName: entries[0]?.name ?? "", + }); + + let successCount = 0; + let failCount = 0; + try { - const filePath = await SelectFile(); - if (!filePath) { - return; + for (let index = 0; index < entries.length; index++) { + if (batchRunIdRef.current !== runId) { + return; + } + + const entry = entries[index]; + setBatchProgress({ + completed: index, + total: entries.length, + fileName: entry.name, + }); + + setItems((prev) => prev.map((item) => item.id === entry.id + ? { ...item, status: "analyzing", error: undefined } + : item)); + + const outcome = entry.source === "browser" && entry.file + ? await analyzeFile(entry.file, { + analysisKey: entry.id, + displayPath: entry.path, + suppressToast: true, + }) + : await analyzeFilePath(entry.path, { + analysisKey: entry.id, + displayPath: entry.path, + suppressToast: true, + }); + + if (batchRunIdRef.current !== runId) { + return; + } + + if (outcome.cancelled) { + return; + } + + if (outcome.result) { + const analysisResult = outcome.result; + successCount++; + setItems((prev) => prev.map((item) => item.id === entry.id + ? { + ...item, + status: "success", + error: undefined, + result: analysisResult, + size: analysisResult.file_size || item.size, + } + : item)); + + const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result); + if (!hasSelectedSuccess) { + setActiveSelection(entry.id); + } + } + else { + failCount++; + setItems((prev) => prev.map((item) => item.id === entry.id + ? { + ...item, + status: "error", + error: outcome.error || "Analysis failed", + } + : item)); + + if (!activeItemIdRef.current) { + setActiveSelection(entry.id); + } + } } - await analyzeSelectedPath(filePath); + + if (batchRunIdRef.current === runId) { + setBatchProgress({ + completed: entries.length, + total: entries.length, + fileName: "", + }); + + if (successCount > 0) { + toast.success("Batch Analysis Complete", { + description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } + else if (failCount > 0) { + toast.error("Batch Analysis Failed", { + description: `All ${failCount} file(s) failed to analyze`, + }); + } + } + } + finally { + if (batchRunIdRef.current === runId) { + setIsBatchRunning(false); + } + } + }, [analyzeFile, analyzeFilePath, setActiveSelection]); + + const ensureIdleQueue = useCallback(() => { + if (!isBatchRunning) { + return true; + } + + toast.info("Analysis in progress", { + description: "Please wait for the current batch to finish or clear it first.", + }); + return false; + }, [isBatchRunning]); + + const addPathItems = useCallback(async (paths: string[]) => { + if (!ensureIdleQueue()) { + return; + } + + const uniquePaths = Array.from(new Set(paths.filter(Boolean))); + const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length; + const validPaths = uniquePaths.filter(isSupportedAudioPath); + + if (invalidCount > 0) { + toast.error("Unsupported format", { + description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, + }); + } + + if (validPaths.length === 0) { + return; + } + + const existingIds = new Set(itemsRef.current.map((item) => item.id)); + const newPaths = validPaths.filter((path) => !existingIds.has(path)); + + if (newPaths.length === 0) { + toast.info("No new files added", { + description: "All selected files were already in the batch queue.", + }); + return; + } + + const fileSizes = await GetFileSizes(newPaths); + const newItems = newPaths.map((path) => ({ + id: path, + source: "path" as const, + path, + name: fileNameFromPath(path), + size: fileSizes[path] || 0, + status: "pending" as const, + })); + + if (validPaths.length !== newPaths.length) { + toast.info("Some files skipped", { + description: `${validPaths.length - newPaths.length} file(s) were already queued.`, + }); + } + + setItems((prev) => [...prev, ...newItems]); + if (!activeItemIdRef.current) { + setActiveSelection(newItems[0]?.id ?? null); + } + + void runBatchAnalysis(newItems); + }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); + + const addBrowserFiles = useCallback(async (files: File[]) => { + if (!ensureIdleQueue()) { + return; + } + + const validFiles = files.filter(isSupportedAudioFile); + const invalidCount = files.length - validFiles.length; + + if (invalidCount > 0) { + toast.error("Unsupported format", { + description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, + }); + } + + if (validFiles.length === 0) { + return; + } + + const existingIds = new Set(itemsRef.current.map((item) => item.id)); + const newItems = validFiles + .map((file) => ({ + id: browserFileId(file), + source: "browser" as const, + path: file.name, + name: file.name, + size: file.size, + status: "pending" as const, + file, + })) + .filter((item) => !existingIds.has(item.id)); + + if (newItems.length === 0) { + toast.info("No new files added", { + description: "All selected files were already in the batch queue.", + }); + return; + } + + if (validFiles.length !== newItems.length) { + toast.info("Some files skipped", { + description: `${validFiles.length - newItems.length} file(s) were already queued.`, + }); + } + + setItems((prev) => [...prev, ...newItems]); + if (!activeItemIdRef.current) { + setActiveSelection(newItems[0]?.id ?? null); + } + + void runBatchAnalysis(newItems); + }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); + + const handleSelectFiles = useCallback(async () => { + if (!ensureIdleQueue()) { + return; + } + + try { + const selectedPaths = await SelectAudioFiles(); + if (selectedPaths && selectedPaths.length > 0) { + await addPathItems(selectedPaths); + } + return; } catch { fileInputRef.current?.click(); + return; } - }, [analyzeSelectedPath]); - const handleInputChange = useCallback(async (e: ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) + }, [addPathItems, ensureIdleQueue]); + + const handleSelectFolder = useCallback(async () => { + if (!ensureIdleQueue()) { return; - await analyzeSelectedFile(file); - e.target.value = ""; - }, [analyzeSelectedFile]); - const handleHtmlDrop = useCallback(async (e: DragEvent) => { - e.preventDefault(); + } + + try { + const selectedFolder = await SelectFolder(""); + if (!selectedFolder) { + return; + } + + const folderFiles = await ListAudioFilesInDir(selectedFolder); + if (!folderFiles || folderFiles.length === 0) { + toast.info("No audio files found", { + description: `No ${SUPPORTED_AUDIO_LABEL} files were found in the selected folder.`, + }); + return; + } + + await addPathItems(folderFiles.map((file) => file.path)); + } + catch (err) { + toast.error("Folder Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select folder", + }); + } + }, [addPathItems, ensureIdleQueue]); + + const handleInputChange = useCallback(async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + + if (files.length === 0) { + return; + } + + await addBrowserFiles(files); + }, [addBrowserFiles]); + + const handleHtmlDrop = useCallback(async (event: DragEvent) => { + event.preventDefault(); setIsDragging(false); - const file = e.dataTransfer.files?.[0]; - if (!file) + + const files = Array.from(event.dataTransfer.files ?? []); + if (files.length === 0) { return; - await analyzeSelectedFile(file); - }, [analyzeSelectedFile]); + } + + await addBrowserFiles(files); + }, [addBrowserFiles]); useEffect(() => { OnFileDrop((_x, _y, paths) => { setIsDragging(false); - const droppedPath = paths?.[0]; - if (!droppedPath) + + if (!paths || paths.length === 0) { return; - void analyzeSelectedPath(droppedPath); + } + + void addPathItems(paths); }, true); + return () => { OnFileDropOff(); }; - }, [analyzeSelectedPath]); - const handleExport = useCallback(async () => { - if (!spectrumRef.current) - return; - const dataUrl = spectrumRef.current.getCanvasDataURL(); - if (!dataUrl) { - toast.error("Export Failed", { description: "Cannot get canvas data" }); + }, [addPathItems]); + + const handleSelectItem = useCallback((itemId: string) => { + setActiveSelection(itemId); + }, [setActiveSelection]); + + const handleRemoveItem = useCallback((itemId: string) => { + if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { return; } - setIsExporting(true); + + clearStoredAnalysis(itemId); + const nextItems = itemsRef.current.filter((item) => item.id !== itemId); + itemsRef.current = nextItems; + setItems(nextItems); + + if (activeItemIdRef.current === itemId) { + const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null; + setActiveSelection(nextActive?.id ?? null); + if (!nextActive) { + clearResult(); + } + } + }, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]); + + const handleClearAll = useCallback(() => { + if (isExportingBatch || isExportingSelected) { + return; + } + + batchRunIdRef.current += 1; + itemsRef.current = []; + setItems([]); + setActiveSelection(null); + clearStoredAnalysis(); + clearResult(); + setIsBatchRunning(false); + setBatchProgress(EMPTY_PROGRESS_STATE); + setExportProgress(EMPTY_PROGRESS_STATE); + setIsDragging(false); + }, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]); + + const handleStopBatch = useCallback(() => { + if (!isBatchRunning) { + return; + } + + batchRunIdRef.current += 1; + cancelAnalysis(); + setIsBatchRunning(false); + setBatchProgress(EMPTY_PROGRESS_STATE); + setItems((prev) => prev.map((item) => item.status === "analyzing" + ? { + ...item, + status: "pending", + } + : item)); + toast.info("Batch analysis stopped", { + description: "Click Analyze to continue the remaining files.", + }); + }, [cancelAnalysis, isBatchRunning]); + + const handleAnalyzePending = useCallback(() => { + if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { + return; + } + + const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending"); + if (nextPendingItems.length === 0) { + return; + } + + void runBatchAnalysis(nextPendingItems); + }, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]); + + const handleExportSelected = useCallback(async () => { + if (!activeItem?.result?.spectrum || !spectrumRef.current) { + return; + } + + const dataUrl = spectrumRef.current.getCanvasDataURL(); + if (!dataUrl) { + toast.error("Export Failed", { + description: "Cannot get canvas data", + }); + return; + } + + setIsExportingSelected(true); + try { - if (selectedFilePath && isAbsolutePath(selectedFilePath)) { - const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl); - toast.success("Exported Successfully", { + if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) { + const outPath = await SaveSpectrumImage(activeItem.path, dataUrl); + toast.success("PNG Exported", { 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", { + + const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram"; + downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); + toast.success("PNG Exported", { description: "Spectrogram image downloaded", }); } @@ -152,75 +642,502 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); } finally { - setIsExporting(false); + setIsExportingSelected(false); } - }, [selectedFilePath]); - const handleAnalyzeAnother = () => { - clearResult(); - }; - const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined; - return (
- + }, [activeItem]); -
+ const handleBatchExport = useCallback(async () => { + const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum); + + if (exportableItems.length === 0) { + toast.error("Nothing to export", { + description: "Analyze at least one file successfully before exporting PNGs.", + }); + return; + } + + const preferences = loadAudioAnalysisPreferences(); + setIsExportingBatch(true); + setExportProgress({ + completed: 0, + total: exportableItems.length, + fileName: exportableItems[0]?.name ?? "", + }); + + let successCount = 0; + let failCount = 0; + + try { + for (let index = 0; index < exportableItems.length; index++) { + const item = exportableItems[index]; + const result = item.result; + + if (!result?.spectrum) { + failCount++; + continue; + } + + setExportProgress({ + completed: index, + total: exportableItems.length, + fileName: item.name, + }); + + try { + const dataUrl = await createSpectrogramDataURL({ + spectrumData: result.spectrum, + sampleRate: result.sample_rate, + duration: result.duration, + freqScale: preferences.freqScale, + colorScheme: preferences.colorScheme, + fileName: item.name, + }); + + if (item.source === "path" && isAbsolutePath(item.path)) { + await SaveSpectrumImage(item.path, dataUrl); + } + else { + const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram"; + downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); + } + + successCount++; + } + catch { + failCount++; + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + setExportProgress({ + completed: exportableItems.length, + total: exportableItems.length, + fileName: "", + }); + + if (successCount > 0) { + toast.success("Batch PNG Export Complete", { + description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } + else { + toast.error("Batch PNG Export Failed", { + description: "No spectrogram PNG files were exported.", + }); + } + } + finally { + setIsExportingBatch(false); + } + }, []); + + const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { + if (!activeItem?.result) { + return; + } + + const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction); + if (!nextResult) { + return; + } + + setItems((prev) => prev.map((item) => item.id === activeItem.id + ? { + ...item, + result: nextResult, + status: "success", + error: undefined, + } + : item)); + }, [activeItem, reAnalyzeSpectrum]); + + const batchDetailContent = !activeItem ? ( + + +

+ Select a file from the batch queue to inspect its analysis result. +

+
+
+ ) : activeItem.status !== "success" || !activeItem.result ? ( + + + {activeItem.name} +

{activeItem.path}

+
+ + {activeItem.status === "analyzing" && ( +
+
+ + Analyzing audio quality... +
+ +

{analysisProgress.message}

+
+ )} + {activeItem.status === "pending" && ( +

+ This file is queued and waiting for batch analysis to start. +

+ )} + {activeItem.status === "error" && ( +
+ {activeItem.error || "Analysis failed"} +
+ )} +
+
+ ) : ( +
+ + + +
+ ); + + const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? ( +
+ + + +
+ ) : activeItem.status === "analyzing" || activeItem.status === "pending" ? ( +
+
+
+ {activeItem.status === "pending" ? "Preparing..." : "Processing..."} + {analysisProgress.percent}% +
+ +

{analysisProgress.message}

+
+
+ ) : ( +
+
+ {activeItem.error || "Analysis failed"} +
+
+ ); + + const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result; + + return ( +
+ + +
- {onBack && ()} + {onBack && ( + + )}

Audio Quality Analyzer

- {result && (
- - + )} + {isBatchMode && ( + + + + + + + + Add Files + + + + Add Folder + + + + )} + {showSingleModeActions && ( + + )} + {isBatchMode && ( + + + + + + + + Export Selected PNG + + + + Export All PNG + + + + )} + {showSingleModeActions && ( + -
)} + )} + {isBatchMode && ( + + )} +
- {!result && !analyzing && (
{ - e.preventDefault(); - setIsDragging(true); - }} onDragLeave={(e) => { - e.preventDefault(); - setIsDragging(false); - }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}> + {items.length === 0 && ( +
{ + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(event) => { + event.preventDefault(); + setIsDragging(false); + }} + onDrop={handleHtmlDrop} + style={{ "--wails-drop-target": "drop" } as CSSProperties} + >
- +

{isDragging - ? "Drop your audio file here" - : "Drag and drop an audio file here, or click the button below to select"} + ? "Drop your audio files here" + : "Drag and drop audio files here, or click the button below to select"}

- +
+ + +

Supported formats: FLAC, MP3, M4A, AAC

-
)} +
+ )} - {analyzing && !result && (
-
-
- Processing... - {analysisProgress.percent}% -
- + {isSingleMode && ( +
+ {singleModeContent} +
+ )} + + {isBatchMode && ( +
+
+ {(isBatchRunning || isExportingBatch) && ( + + + + {isExportingBatch ? "Batch PNG Export" : "Batch Analysis"} + + + +
+ + {isExportingBatch + ? exportProgress.fileName || "Preparing export..." + : batchProgress.fileName || analysisProgress.message} + + + {isExportingBatch + ? `${exportProgress.completed}/${exportProgress.total}` + : `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`} + +
+ + {!isExportingBatch && ( +
+ {analysisProgress.message} + {analysisProgress.percent}% +
+ )} +
+
+ )} + + + +
+ Batch Queue +

+ {items.length} queued • {successItems.length} ready +

+
+
+ +
+ {items.map((item) => { + const isActive = item.id === activeItemId; + const isSelectable = item.status !== "pending"; + return ( +
{ + if (!isSelectable) { + return; + } + handleSelectItem(item.id); + }} + onKeyDown={(event) => { + if (!isSelectable) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSelectItem(item.id); + } + }} + > +
{statusIcon(item.status)}
+
+

{item.name}

+

+ {itemMetaLine(item)} +

+
+ {formatFileSize(item.size)} + {fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"} +
+
+ +
+ ); + })} +
+
+
-
)} - {result && (
- - - -
)} -
); +
+ {batchDetailContent} +
+
+ )} +
+ ); } diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index af4e15a..6b9d288 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -7,6 +7,18 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from " export interface SpectrumVisualizationHandle { getCanvasDataURL: () => string | null; } +type ColorScheme = AnalyzerColorScheme; +type FreqScale = AnalyzerFreqScale; +type WindowFunction = AnalyzerWindowFunction; +export interface SpectrogramRenderOptions { + spectrumData: SpectrumData; + sampleRate: number; + duration: number; + freqScale: FreqScale; + colorScheme: ColorScheme; + fileName?: string; + shouldCancel?: () => boolean; +} interface SpectrumVisualizationProps { sampleRate: number; duration: number; @@ -19,9 +31,6 @@ interface SpectrumVisualizationProps { message: string; }; } -type ColorScheme = AnalyzerColorScheme; -type FreqScale = AnalyzerFreqScale; -type WindowFunction = AnalyzerWindowFunction; const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 }; const CANVAS_W = 1100; const CANVAS_H = 600; @@ -420,6 +429,20 @@ async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: Spectr addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName); drawColorBar(ctx, plotHeight, colorScheme); } +export async function renderSpectrogramToCanvas(canvas: HTMLCanvasElement, options: SpectrogramRenderOptions): Promise { + canvas.width = CANVAS_W; + canvas.height = CANVAS_H; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Cannot get 2D canvas context"); + } + await renderSpectrogram(ctx, options.spectrumData, options.sampleRate, options.duration, options.freqScale, options.colorScheme, options.fileName, options.shouldCancel ?? (() => false)); +} +export async function createSpectrogramDataURL(options: SpectrogramRenderOptions): Promise { + const canvas = document.createElement("canvas"); + await renderSpectrogramToCanvas(canvas, options); + return canvas.toDataURL("image/png"); +} const COLOR_SCHEMES: { value: ColorScheme; label: string; @@ -468,7 +491,15 @@ export const SpectrumVisualization = forwardRef canceled; if (spectrumData) { - void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel); + void renderSpectrogramToCanvas(canvas, { + spectrumData, + sampleRate, + duration, + freqScale, + colorScheme, + fileName, + shouldCancel, + }); } else { ctx.fillStyle = "#000000"; diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index 95b5ea5..672f006 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -2,9 +2,21 @@ import { useState, useCallback, useRef, useEffect, type MutableRefObject } from import type { AnalysisResult } from "@/types/api"; import { logger } from "@/lib/logger"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeDecodedSamples, analyzeSpectrumFromSamples, parseAudioMetadataFromInput, pcm16MonoArrayBufferToFloat32Samples, type AnalysisProgress, type FrontendAnalysisPayload, type ParsedAudioMetadata, } from "@/lib/flac-analysis"; +import { + analyzeAudioArrayBuffer, + analyzeAudioFile, + analyzeDecodedSamples, + analyzeSpectrumFromSamples, + parseAudioMetadataFromInput, + pcm16MonoArrayBufferToFloat32Samples, + type AnalysisProgress, + type FrontendAnalysisPayload, + type ParsedAudioMetadata, +} from "@/lib/flac-analysis"; import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; + type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; + function toWindowFunction(value: string): WindowFunction { switch (value) { case "hamming": @@ -16,13 +28,16 @@ function toWindowFunction(value: string): WindowFunction { return "hann"; } } + function fileNameFromPath(filePath: string): string { const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] || filePath; } + function nextUiTick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } + async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise { const clean = base64.includes(",") ? base64.split(",")[1] : base64; const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0; @@ -30,36 +45,60 @@ async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean) const bytes = new Uint8Array(outputLength); const chunkSize = 4 * 16384; let writeOffset = 0; + for (let offset = 0; offset < clean.length; offset += chunkSize) { if (shouldCancel?.()) { throw new Error("Analysis cancelled"); } + const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize)); const binary = atob(chunk); + for (let i = 0; i < binary.length; i++) { bytes[writeOffset++] = binary.charCodeAt(i); } + if ((offset / chunkSize) % 4 === 0) { await nextUiTick(); } } + return bytes.buffer; } + let sessionResult: AnalysisResult | null = null; let sessionSelectedFilePath = ""; let sessionError: string | null = null; let sessionSamples: Float32Array | null = null; +let sessionCurrentAnalysisKey = ""; +const sessionSamplesByKey = new Map(); + interface ProgressState { percent: number; message: string; } + const DEFAULT_PROGRESS_STATE: ProgressState = { percent: 0, message: "Preparing analysis...", }; + interface CancelToken { cancelled: boolean; } + +interface AnalyzeExecutionOptions { + analysisKey?: string; + displayPath?: string; + suppressToast?: boolean; +} + +export interface AnalyzeExecutionOutcome { + result: AnalysisResult | null; + error: string | null; + cancelled: boolean; +} + interface WailsWindow extends Window { go?: { main?: { @@ -70,6 +109,7 @@ interface WailsWindow extends Window { }; }; } + interface BackendAnalysisDecodeResponse { pcm_base64: string; sample_rate: number; @@ -79,34 +119,41 @@ interface BackendAnalysisDecodeResponse { bitrate_kbps?: number; bit_depth?: string; } + function cancelToken(tokenRef: MutableRefObject): void { if (tokenRef.current) { tokenRef.current.cancelled = true; tokenRef.current = null; } } + function createToken(tokenRef: MutableRefObject): CancelToken { cancelToken(tokenRef); const token: CancelToken = { cancelled: false }; tokenRef.current = token; return token; } + function isCancelledError(error: unknown): boolean { return error instanceof Error && error.message === "Analysis cancelled"; } + function toProgressState(progress: AnalysisProgress): ProgressState { return { percent: Math.round(Math.max(0, Math.min(100, progress.percent))), message: progress.message, }; } + function isDecodeFailure(error: unknown): boolean { return error instanceof Error && /decode/i.test(error.message); } + function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata { const sampleRate = decoded.sample_rate > 0 ? decoded.sample_rate : parsed.sampleRate; const bitsPerSample = decoded.bits_per_sample > 0 ? decoded.bits_per_sample : parsed.bitsPerSample; const duration = decoded.duration > 0 ? decoded.duration : parsed.duration; + return { ...parsed, sampleRate, @@ -117,6 +164,7 @@ function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: Backe bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps, }; } + export function useAudioAnalysis() { const [analyzing, setAnalyzing] = useState(false); const [analysisProgress, setAnalysisProgress] = useState(DEFAULT_PROGRESS_STATE); @@ -125,33 +173,64 @@ export function useAudioAnalysis() { const [error, setError] = useState(() => sessionError); const [spectrumLoading, setSpectrumLoading] = useState(false); const [spectrumProgress, setSpectrumProgress] = useState(DEFAULT_PROGRESS_STATE); + const samplesRef = useRef(sessionSamples); + const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey); const analysisTokenRef = useRef(null); const spectrumTokenRef = useRef(null); + useEffect(() => { return () => { cancelToken(analysisTokenRef); cancelToken(spectrumTokenRef); }; }, []); + const setResultWithSession = useCallback((next: AnalysisResult | null) => { sessionResult = next; setResult(next); }, []); + const setSelectedFilePathWithSession = useCallback((next: string) => { sessionSelectedFilePath = next; setSelectedFilePath(next); }, []); + const setErrorWithSession = useCallback((next: string | null) => { sessionError = next; setError(next); }, []); - const analyzeFile = useCallback(async (file: File) => { + + const setCurrentAnalysisKey = useCallback((analysisKey: string) => { + currentAnalysisKeyRef.current = analysisKey; + sessionCurrentAnalysisKey = analysisKey; + }, []); + + const storeSuccessfulAnalysis = useCallback((analysisKey: string, displayPath: string, payload: FrontendAnalysisPayload) => { + sessionSamplesByKey.set(analysisKey, payload.samples); + samplesRef.current = payload.samples; + sessionSamples = payload.samples; + setCurrentAnalysisKey(analysisKey); + setResultWithSession(payload.result); + setSelectedFilePathWithSession(displayPath); + setErrorWithSession(null); + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); + + const analyzeFile = useCallback(async (file: File, options?: AnalyzeExecutionOptions): Promise => { if (!file) { - setErrorWithSession("No file provided"); - return null; + const errorMessage = "No file provided"; + setErrorWithSession(errorMessage); + return { + result: null, + error: errorMessage, + cancelled: false, + }; } + const token = createToken(analysisTokenRef); + const analysisKey = options?.analysisKey || file.name; + const displayPath = options?.displayPath || file.name; + cancelToken(spectrumTokenRef); setAnalyzing(true); setAnalysisProgress({ @@ -160,33 +239,53 @@ export function useAudioAnalysis() { }); setErrorWithSession(null); setResultWithSession(null); - setSelectedFilePathWithSession(file.name); + setSelectedFilePathWithSession(displayPath); + setCurrentAnalysisKey(analysisKey); + try { - logger.info(`Analyzing audio file (frontend): ${file.name}`); + logger.info(`Analyzing audio file (frontend): ${displayPath}`); const start = Date.now(); const prefs = loadAudioAnalysisPreferences(); + const payload = await analyzeAudioFile(file, { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, }, (progress) => { - if (token.cancelled) + if (token.cancelled) { return; + } + setAnalysisProgress(toProgressState(progress)); }, () => token.cancelled); + if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } - samplesRef.current = payload.samples; - sessionSamples = payload.samples; - setResultWithSession(payload.result); + + storeSuccessfulAnalysis(analysisKey, displayPath, payload); + const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); - return payload.result; + + return { + result: payload.result, + error: null, + cancelled: false, + }; } catch (err) { if (isCancelledError(err)) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } + const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; logger.error(`Analysis error: ${errorMessage}`); setErrorWithSession(errorMessage); @@ -194,10 +293,18 @@ export function useAudioAnalysis() { percent: 0, message: "Analysis failed", }); - toast.error("Audio Analysis Failed", { - description: errorMessage, - }); - return null; + + if (!options?.suppressToast) { + toast.error("Audio Analysis Failed", { + description: errorMessage, + }); + } + + return { + result: null, + error: errorMessage, + cancelled: false, + }; } finally { if (analysisTokenRef.current === token) { @@ -205,13 +312,23 @@ export function useAudioAnalysis() { setAnalyzing(false); } } - }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - const analyzeFilePath = useCallback(async (filePath: string) => { + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); + + const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise => { if (!filePath) { - setErrorWithSession("No file path provided"); - return null; + const errorMessage = "No file path provided"; + setErrorWithSession(errorMessage); + return { + result: null, + error: errorMessage, + cancelled: false, + }; } + const token = createToken(analysisTokenRef); + const analysisKey = options?.analysisKey || filePath; + const displayPath = options?.displayPath || filePath; + cancelToken(spectrumTokenRef); setAnalyzing(true); setAnalysisProgress({ @@ -220,32 +337,50 @@ export function useAudioAnalysis() { }); setErrorWithSession(null); setResultWithSession(null); - setSelectedFilePathWithSession(filePath); + setSelectedFilePathWithSession(displayPath); + setCurrentAnalysisKey(analysisKey); + try { logger.info(`Analyzing audio file (frontend from path): ${filePath}`); const start = Date.now(); const prefs = loadAudioAnalysisPreferences(); const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64; + if (!readFileAsBase64) { throw new Error("ReadFileAsBase64 backend method is unavailable"); } + let base64Data = await readFileAsBase64(filePath); + if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } + setAnalysisProgress({ percent: 10, message: "File loaded", }); + const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled); base64Data = ""; + if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } + setAnalysisProgress({ percent: 15, message: "Preparing audio buffer...", }); + const fileName = fileNameFromPath(filePath); const input = { fileName, @@ -256,16 +391,21 @@ export function useAudioAnalysis() { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, } as const; + const updateProgress = (progress: AnalysisProgress) => { - if (token.cancelled) + if (token.cancelled) { return; + } + const mappedPercent = 10 + (progress.percent * 0.9); setAnalysisProgress({ percent: Math.round(Math.max(0, Math.min(100, mappedPercent))), message: progress.message, }); }; + let payload: FrontendAnalysisPayload; + try { payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled); } @@ -273,50 +413,93 @@ export function useAudioAnalysis() { if (!isDecodeFailure(err)) { throw err; } + const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis; + if (!decodeAudioForAnalysis) { throw err; } + logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`); setAnalysisProgress({ percent: 18, message: "Browser decoder failed, trying FFmpeg fallback...", }); + const decoded = await decodeAudioForAnalysis(filePath); + if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } + setAnalysisProgress({ percent: 24, message: "Decoding audio with FFmpeg...", }); + const pcmBase64 = decoded.pcm_base64 || ""; + if (!pcmBase64) { throw new Error("FFmpeg analysis decode returned no PCM data"); } + const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled); + if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } + const parsedMetadata = parseAudioMetadataFromInput(input); const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded); const samples = pcm16MonoArrayBufferToFloat32Samples(pcmBuffer); - payload = await analyzeDecodedSamples(input, mergedMetadata, samples, analysisParams, updateProgress, () => token.cancelled, mergedMetadata.duration); + + payload = await analyzeDecodedSamples( + input, + mergedMetadata, + samples, + analysisParams, + updateProgress, + () => token.cancelled, + mergedMetadata.duration, + ); } + if (token.cancelled) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } - samplesRef.current = payload.samples; - sessionSamples = payload.samples; - setResultWithSession(payload.result); + + storeSuccessfulAnalysis(analysisKey, displayPath, payload); + const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); - return payload.result; + + return { + result: payload.result, + error: null, + cancelled: false, + }; } catch (err) { if (isCancelledError(err)) { - return null; + return { + result: null, + error: null, + cancelled: true, + }; } + const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; logger.error(`Analysis error: ${errorMessage}`); setErrorWithSession(errorMessage); @@ -324,10 +507,18 @@ export function useAudioAnalysis() { percent: 0, message: "Analysis failed", }); - toast.error("Audio Analysis Failed", { - description: errorMessage, - }); - return null; + + if (!options?.suppressToast) { + toast.error("Audio Analysis Failed", { + description: errorMessage, + }); + } + + return { + result: null, + error: errorMessage, + cancelled: false, + }; } finally { if (analysisTokenRef.current === token) { @@ -335,39 +526,92 @@ export function useAudioAnalysis() { setAnalyzing(false); } } - }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { - if (!result || !samplesRef.current) + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); + + const loadStoredAnalysis = useCallback((analysisKey: string, nextResult: AnalysisResult, displayPath: string) => { + setCurrentAnalysisKey(analysisKey); + samplesRef.current = sessionSamplesByKey.get(analysisKey) ?? null; + sessionSamples = samplesRef.current; + setResultWithSession(nextResult); + setSelectedFilePathWithSession(displayPath); + setErrorWithSession(null); + }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); + + const clearStoredAnalysis = useCallback((analysisKey?: string) => { + if (analysisKey) { + sessionSamplesByKey.delete(analysisKey); + + if (currentAnalysisKeyRef.current === analysisKey) { + currentAnalysisKeyRef.current = ""; + sessionCurrentAnalysisKey = ""; + samplesRef.current = null; + sessionSamples = null; + } + return; + } + + sessionSamplesByKey.clear(); + currentAnalysisKeyRef.current = ""; + sessionCurrentAnalysisKey = ""; + samplesRef.current = null; + sessionSamples = null; + }, []); + + const cancelAnalysis = useCallback(() => { + cancelToken(analysisTokenRef); + setAnalyzing(false); + setAnalysisProgress((prev) => prev.percent > 0 + ? { + percent: prev.percent, + message: "Analysis stopped", + } + : DEFAULT_PROGRESS_STATE); + }, []); + + const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { + if (!result || !samplesRef.current) { + return null; + } + const token = createToken(spectrumTokenRef); setSpectrumLoading(true); setSpectrumProgress({ percent: 0, message: "Preparing FFT...", }); + try { await new Promise((resolve) => setTimeout(resolve, 0)); + const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, { fftSize, windowFunction: toWindowFunction(windowFunction), }, (progress) => { - if (token.cancelled) + if (token.cancelled) { return; + } + setSpectrumProgress(toProgressState(progress)); }, () => token.cancelled); + if (token.cancelled) { - return; + return null; } - setResult((prev) => { - const next = prev ? { ...prev, spectrum } : prev; - sessionResult = next; - return next; - }); + + const nextResult = { + ...result, + spectrum, + }; + + setResultWithSession(nextResult); + return nextResult; } catch (err) { if (isCancelledError(err)) { - return; + return null; } + const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; logger.error(`Spectrum re-analysis error: ${errorMessage}`); setSpectrumProgress({ @@ -377,6 +621,7 @@ export function useAudioAnalysis() { toast.error("Spectrum Analysis Failed", { description: errorMessage, }); + return null; } finally { if (spectrumTokenRef.current === token) { @@ -384,7 +629,8 @@ export function useAudioAnalysis() { setSpectrumLoading(false); } } - }, [result]); + }, [result, setResultWithSession]); + const clearResult = useCallback(() => { cancelToken(analysisTokenRef); cancelToken(spectrumTokenRef); @@ -395,9 +641,12 @@ export function useAudioAnalysis() { setSpectrumLoading(false); setAnalysisProgress(DEFAULT_PROGRESS_STATE); setSpectrumProgress(DEFAULT_PROGRESS_STATE); + currentAnalysisKeyRef.current = ""; + sessionCurrentAnalysisKey = ""; samplesRef.current = null; sessionSamples = null; }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); + return { analyzing, analysisProgress, @@ -408,6 +657,9 @@ export function useAudioAnalysis() { spectrumProgress, analyzeFile, analyzeFilePath, + cancelAnalysis, + loadStoredAnalysis, + clearStoredAnalysis, reAnalyzeSpectrum, clearResult, };