+ 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 && (
-
-
- {isExporting ? "Exporting..." : "Export PNG"}
+
+
+ {isBatchMode && isBatchRunning && (
+
+
+ Stop
-
-
+ )}
+ {canResumeBatch && (
+
+
+ Analyze
+
+ )}
+ {isBatchMode && (
+
+
+
+
+ Add
+
+
+
+
+
+
+ Add Files
+
+
+
+ Add Folder
+
+
+
+ )}
+ {showSingleModeActions && (
+
+
+ {isExportingSelected ? "Exporting..." : "Export PNG"}
+
+ )}
+ {isBatchMode && (
+
+
+
+
+ {isExportingBatch ? "Exporting..." : isExportingSelected ? "Exporting..." : "Export"}
+
+
+
+
+
+
+ Export Selected PNG
+
+
+
+ Export All PNG
+
+
+
+ )}
+ {showSingleModeActions && (
+
+
Clear
- )}
+ )}
+ {isBatchMode && (
+
+
+ Clear
+
+ )}
+
- {!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"}
-
-
- Select Audio File
-
+
+
+
+ Select Files
+
+
+
+ Select Folder
+
+
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"}
+
+
+
{
+ event.stopPropagation();
+ handleRemoveItem(item.id);
+ }}
+ disabled={isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading}
+ >
+
+
+
+ );
+ })}
+
+
+
-
)}
- {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,
};