.batch audio quality analyzer

This commit is contained in:
afkarxyz
2026-04-02 11:30:00 +07:00
parent 78caf6cc61
commit 41eda2d230
5 changed files with 1376 additions and 172 deletions
+6 -2
View File
@@ -11,8 +11,8 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
Title: "Select Audio Files", Title: "Select Audio Files",
Filters: []runtime.FileFilter{ Filters: []runtime.FileFilter{
{ {
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)", DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)",
Pattern: "*.mp3;*.m4a;*.flac", Pattern: "*.mp3;*.m4a;*.flac;*.aac",
}, },
{ {
DisplayName: "MP3 Files (*.mp3)", DisplayName: "MP3 Files (*.mp3)",
@@ -26,6 +26,10 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
DisplayName: "FLAC Files (*.flac)", DisplayName: "FLAC Files (*.flac)",
Pattern: "*.flac", Pattern: "*.flac",
}, },
{
DisplayName: "AAC Files (*.aac)",
Pattern: "*.aac",
},
{ {
DisplayName: "All Files (*.*)", DisplayName: "All Files (*.*)",
Pattern: "*.*", Pattern: "*.*",
+1 -1
View File
@@ -94,7 +94,7 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
} }
ext := strings.ToLower(filepath.Ext(path)) 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{ result = append(result, FileInfo{
Name: info.Name(), Name: info.Name(),
Path: path, Path: path,
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,18 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "
export interface SpectrumVisualizationHandle { export interface SpectrumVisualizationHandle {
getCanvasDataURL: () => string | null; 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 { interface SpectrumVisualizationProps {
sampleRate: number; sampleRate: number;
duration: number; duration: number;
@@ -19,9 +31,6 @@ interface SpectrumVisualizationProps {
message: string; message: string;
}; };
} }
type ColorScheme = AnalyzerColorScheme;
type FreqScale = AnalyzerFreqScale;
type WindowFunction = AnalyzerWindowFunction;
const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 }; const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
const CANVAS_W = 1100; const CANVAS_W = 1100;
const CANVAS_H = 600; const CANVAS_H = 600;
@@ -420,6 +429,20 @@ async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: Spectr
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName); addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
drawColorBar(ctx, plotHeight, colorScheme); drawColorBar(ctx, plotHeight, colorScheme);
} }
export async function renderSpectrogramToCanvas(canvas: HTMLCanvasElement, options: SpectrogramRenderOptions): Promise<void> {
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<string> {
const canvas = document.createElement("canvas");
await renderSpectrogramToCanvas(canvas, options);
return canvas.toDataURL("image/png");
}
const COLOR_SCHEMES: { const COLOR_SCHEMES: {
value: ColorScheme; value: ColorScheme;
label: string; label: string;
@@ -468,7 +491,15 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
let canceled = false; let canceled = false;
const shouldCancel = () => canceled; const shouldCancel = () => canceled;
if (spectrumData) { if (spectrumData) {
void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel); void renderSpectrogramToCanvas(canvas, {
spectrumData,
sampleRate,
duration,
freqScale,
colorScheme,
fileName,
shouldCancel,
});
} }
else { else {
ctx.fillStyle = "#000000"; ctx.fillStyle = "#000000";
+298 -46
View File
@@ -2,9 +2,21 @@ import { useState, useCallback, useRef, useEffect, type MutableRefObject } from
import type { AnalysisResult } from "@/types/api"; import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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"; import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
function toWindowFunction(value: string): WindowFunction { function toWindowFunction(value: string): WindowFunction {
switch (value) { switch (value) {
case "hamming": case "hamming":
@@ -16,13 +28,16 @@ function toWindowFunction(value: string): WindowFunction {
return "hann"; return "hann";
} }
} }
function fileNameFromPath(filePath: string): string { function fileNameFromPath(filePath: string): string {
const parts = filePath.split(/[/\\]/); const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1] || filePath; return parts[parts.length - 1] || filePath;
} }
function nextUiTick(): Promise<void> { function nextUiTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 0));
} }
async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> { async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> {
const clean = base64.includes(",") ? base64.split(",")[1] : base64; const clean = base64.includes(",") ? base64.split(",")[1] : base64;
const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0; 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 bytes = new Uint8Array(outputLength);
const chunkSize = 4 * 16384; const chunkSize = 4 * 16384;
let writeOffset = 0; let writeOffset = 0;
for (let offset = 0; offset < clean.length; offset += chunkSize) { for (let offset = 0; offset < clean.length; offset += chunkSize) {
if (shouldCancel?.()) { if (shouldCancel?.()) {
throw new Error("Analysis cancelled"); throw new Error("Analysis cancelled");
} }
const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize)); const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize));
const binary = atob(chunk); const binary = atob(chunk);
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) {
bytes[writeOffset++] = binary.charCodeAt(i); bytes[writeOffset++] = binary.charCodeAt(i);
} }
if ((offset / chunkSize) % 4 === 0) { if ((offset / chunkSize) % 4 === 0) {
await nextUiTick(); await nextUiTick();
} }
} }
return bytes.buffer; return bytes.buffer;
} }
let sessionResult: AnalysisResult | null = null; let sessionResult: AnalysisResult | null = null;
let sessionSelectedFilePath = ""; let sessionSelectedFilePath = "";
let sessionError: string | null = null; let sessionError: string | null = null;
let sessionSamples: Float32Array | null = null; let sessionSamples: Float32Array | null = null;
let sessionCurrentAnalysisKey = "";
const sessionSamplesByKey = new Map<string, Float32Array>();
interface ProgressState { interface ProgressState {
percent: number; percent: number;
message: string; message: string;
} }
const DEFAULT_PROGRESS_STATE: ProgressState = { const DEFAULT_PROGRESS_STATE: ProgressState = {
percent: 0, percent: 0,
message: "Preparing analysis...", message: "Preparing analysis...",
}; };
interface CancelToken { interface CancelToken {
cancelled: boolean; 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 { interface WailsWindow extends Window {
go?: { go?: {
main?: { main?: {
@@ -70,6 +109,7 @@ interface WailsWindow extends Window {
}; };
}; };
} }
interface BackendAnalysisDecodeResponse { interface BackendAnalysisDecodeResponse {
pcm_base64: string; pcm_base64: string;
sample_rate: number; sample_rate: number;
@@ -79,34 +119,41 @@ interface BackendAnalysisDecodeResponse {
bitrate_kbps?: number; bitrate_kbps?: number;
bit_depth?: string; bit_depth?: string;
} }
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void { function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
if (tokenRef.current) { if (tokenRef.current) {
tokenRef.current.cancelled = true; tokenRef.current.cancelled = true;
tokenRef.current = null; tokenRef.current = null;
} }
} }
function createToken(tokenRef: MutableRefObject<CancelToken | null>): CancelToken { function createToken(tokenRef: MutableRefObject<CancelToken | null>): CancelToken {
cancelToken(tokenRef); cancelToken(tokenRef);
const token: CancelToken = { cancelled: false }; const token: CancelToken = { cancelled: false };
tokenRef.current = token; tokenRef.current = token;
return token; return token;
} }
function isCancelledError(error: unknown): boolean { function isCancelledError(error: unknown): boolean {
return error instanceof Error && error.message === "Analysis cancelled"; return error instanceof Error && error.message === "Analysis cancelled";
} }
function toProgressState(progress: AnalysisProgress): ProgressState { function toProgressState(progress: AnalysisProgress): ProgressState {
return { return {
percent: Math.round(Math.max(0, Math.min(100, progress.percent))), percent: Math.round(Math.max(0, Math.min(100, progress.percent))),
message: progress.message, message: progress.message,
}; };
} }
function isDecodeFailure(error: unknown): boolean { function isDecodeFailure(error: unknown): boolean {
return error instanceof Error && /decode/i.test(error.message); return error instanceof Error && /decode/i.test(error.message);
} }
function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata { function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata {
const sampleRate = decoded.sample_rate > 0 ? decoded.sample_rate : parsed.sampleRate; 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 bitsPerSample = decoded.bits_per_sample > 0 ? decoded.bits_per_sample : parsed.bitsPerSample;
const duration = decoded.duration > 0 ? decoded.duration : parsed.duration; const duration = decoded.duration > 0 ? decoded.duration : parsed.duration;
return { return {
...parsed, ...parsed,
sampleRate, sampleRate,
@@ -117,6 +164,7 @@ function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: Backe
bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps, bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps,
}; };
} }
export function useAudioAnalysis() { export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE); const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
@@ -125,33 +173,64 @@ export function useAudioAnalysis() {
const [error, setError] = useState<string | null>(() => sessionError); const [error, setError] = useState<string | null>(() => sessionError);
const [spectrumLoading, setSpectrumLoading] = useState(false); const [spectrumLoading, setSpectrumLoading] = useState(false);
const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE); const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
const samplesRef = useRef<Float32Array | null>(sessionSamples); const samplesRef = useRef<Float32Array | null>(sessionSamples);
const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey);
const analysisTokenRef = useRef<CancelToken | null>(null); const analysisTokenRef = useRef<CancelToken | null>(null);
const spectrumTokenRef = useRef<CancelToken | null>(null); const spectrumTokenRef = useRef<CancelToken | null>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
cancelToken(analysisTokenRef); cancelToken(analysisTokenRef);
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
}; };
}, []); }, []);
const setResultWithSession = useCallback((next: AnalysisResult | null) => { const setResultWithSession = useCallback((next: AnalysisResult | null) => {
sessionResult = next; sessionResult = next;
setResult(next); setResult(next);
}, []); }, []);
const setSelectedFilePathWithSession = useCallback((next: string) => { const setSelectedFilePathWithSession = useCallback((next: string) => {
sessionSelectedFilePath = next; sessionSelectedFilePath = next;
setSelectedFilePath(next); setSelectedFilePath(next);
}, []); }, []);
const setErrorWithSession = useCallback((next: string | null) => { const setErrorWithSession = useCallback((next: string | null) => {
sessionError = next; sessionError = next;
setError(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<AnalyzeExecutionOutcome> => {
if (!file) { if (!file) {
setErrorWithSession("No file provided"); const errorMessage = "No file provided";
return null; setErrorWithSession(errorMessage);
return {
result: null,
error: errorMessage,
cancelled: false,
};
} }
const token = createToken(analysisTokenRef); const token = createToken(analysisTokenRef);
const analysisKey = options?.analysisKey || file.name;
const displayPath = options?.displayPath || file.name;
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
setAnalyzing(true); setAnalyzing(true);
setAnalysisProgress({ setAnalysisProgress({
@@ -160,33 +239,53 @@ export function useAudioAnalysis() {
}); });
setErrorWithSession(null); setErrorWithSession(null);
setResultWithSession(null); setResultWithSession(null);
setSelectedFilePathWithSession(file.name); setSelectedFilePathWithSession(displayPath);
setCurrentAnalysisKey(analysisKey);
try { try {
logger.info(`Analyzing audio file (frontend): ${file.name}`); logger.info(`Analyzing audio file (frontend): ${displayPath}`);
const start = Date.now(); const start = Date.now();
const prefs = loadAudioAnalysisPreferences(); const prefs = loadAudioAnalysisPreferences();
const payload = await analyzeAudioFile(file, { const payload = await analyzeAudioFile(file, {
fftSize: prefs.fftSize, fftSize: prefs.fftSize,
windowFunction: prefs.windowFunction, windowFunction: prefs.windowFunction,
}, (progress) => { }, (progress) => {
if (token.cancelled) if (token.cancelled) {
return; return;
}
setAnalysisProgress(toProgressState(progress)); setAnalysisProgress(toProgressState(progress));
}, () => token.cancelled); }, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return null; return {
result: null,
error: null,
cancelled: true,
};
} }
samplesRef.current = payload.samples;
sessionSamples = payload.samples; storeSuccessfulAnalysis(analysisKey, displayPath, payload);
setResultWithSession(payload.result);
const elapsed = ((Date.now() - start) / 1000).toFixed(2); const elapsed = ((Date.now() - start) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`); logger.success(`Audio analysis completed in ${elapsed}s`);
return payload.result;
return {
result: payload.result,
error: null,
cancelled: false,
};
} }
catch (err) { catch (err) {
if (isCancelledError(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"; const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`); logger.error(`Analysis error: ${errorMessage}`);
setErrorWithSession(errorMessage); setErrorWithSession(errorMessage);
@@ -194,10 +293,18 @@ export function useAudioAnalysis() {
percent: 0, percent: 0,
message: "Analysis failed", message: "Analysis failed",
}); });
if (!options?.suppressToast) {
toast.error("Audio Analysis Failed", { toast.error("Audio Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
return null; }
return {
result: null,
error: errorMessage,
cancelled: false,
};
} }
finally { finally {
if (analysisTokenRef.current === token) { if (analysisTokenRef.current === token) {
@@ -205,13 +312,23 @@ export function useAudioAnalysis() {
setAnalyzing(false); setAnalyzing(false);
} }
} }
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
const analyzeFilePath = useCallback(async (filePath: string) => {
const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => {
if (!filePath) { if (!filePath) {
setErrorWithSession("No file path provided"); const errorMessage = "No file path provided";
return null; setErrorWithSession(errorMessage);
return {
result: null,
error: errorMessage,
cancelled: false,
};
} }
const token = createToken(analysisTokenRef); const token = createToken(analysisTokenRef);
const analysisKey = options?.analysisKey || filePath;
const displayPath = options?.displayPath || filePath;
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
setAnalyzing(true); setAnalyzing(true);
setAnalysisProgress({ setAnalysisProgress({
@@ -220,32 +337,50 @@ export function useAudioAnalysis() {
}); });
setErrorWithSession(null); setErrorWithSession(null);
setResultWithSession(null); setResultWithSession(null);
setSelectedFilePathWithSession(filePath); setSelectedFilePathWithSession(displayPath);
setCurrentAnalysisKey(analysisKey);
try { try {
logger.info(`Analyzing audio file (frontend from path): ${filePath}`); logger.info(`Analyzing audio file (frontend from path): ${filePath}`);
const start = Date.now(); const start = Date.now();
const prefs = loadAudioAnalysisPreferences(); const prefs = loadAudioAnalysisPreferences();
const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64; const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64;
if (!readFileAsBase64) { if (!readFileAsBase64) {
throw new Error("ReadFileAsBase64 backend method is unavailable"); throw new Error("ReadFileAsBase64 backend method is unavailable");
} }
let base64Data = await readFileAsBase64(filePath); let base64Data = await readFileAsBase64(filePath);
if (token.cancelled) { if (token.cancelled) {
return null; return {
result: null,
error: null,
cancelled: true,
};
} }
setAnalysisProgress({ setAnalysisProgress({
percent: 10, percent: 10,
message: "File loaded", message: "File loaded",
}); });
const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled); const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled);
base64Data = ""; base64Data = "";
if (token.cancelled) { if (token.cancelled) {
return null; return {
result: null,
error: null,
cancelled: true,
};
} }
setAnalysisProgress({ setAnalysisProgress({
percent: 15, percent: 15,
message: "Preparing audio buffer...", message: "Preparing audio buffer...",
}); });
const fileName = fileNameFromPath(filePath); const fileName = fileNameFromPath(filePath);
const input = { const input = {
fileName, fileName,
@@ -256,16 +391,21 @@ export function useAudioAnalysis() {
fftSize: prefs.fftSize, fftSize: prefs.fftSize,
windowFunction: prefs.windowFunction, windowFunction: prefs.windowFunction,
} as const; } as const;
const updateProgress = (progress: AnalysisProgress) => { const updateProgress = (progress: AnalysisProgress) => {
if (token.cancelled) if (token.cancelled) {
return; return;
}
const mappedPercent = 10 + (progress.percent * 0.9); const mappedPercent = 10 + (progress.percent * 0.9);
setAnalysisProgress({ setAnalysisProgress({
percent: Math.round(Math.max(0, Math.min(100, mappedPercent))), percent: Math.round(Math.max(0, Math.min(100, mappedPercent))),
message: progress.message, message: progress.message,
}); });
}; };
let payload: FrontendAnalysisPayload; let payload: FrontendAnalysisPayload;
try { try {
payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled); payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled);
} }
@@ -273,50 +413,93 @@ export function useAudioAnalysis() {
if (!isDecodeFailure(err)) { if (!isDecodeFailure(err)) {
throw err; throw err;
} }
const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis; const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis;
if (!decodeAudioForAnalysis) { if (!decodeAudioForAnalysis) {
throw err; throw err;
} }
logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`); logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`);
setAnalysisProgress({ setAnalysisProgress({
percent: 18, percent: 18,
message: "Browser decoder failed, trying FFmpeg fallback...", message: "Browser decoder failed, trying FFmpeg fallback...",
}); });
const decoded = await decodeAudioForAnalysis(filePath); const decoded = await decodeAudioForAnalysis(filePath);
if (token.cancelled) { if (token.cancelled) {
return null; return {
result: null,
error: null,
cancelled: true,
};
} }
setAnalysisProgress({ setAnalysisProgress({
percent: 24, percent: 24,
message: "Decoding audio with FFmpeg...", message: "Decoding audio with FFmpeg...",
}); });
const pcmBase64 = decoded.pcm_base64 || ""; const pcmBase64 = decoded.pcm_base64 || "";
if (!pcmBase64) { if (!pcmBase64) {
throw new Error("FFmpeg analysis decode returned no PCM data"); throw new Error("FFmpeg analysis decode returned no PCM data");
} }
const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled); const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return null; return {
result: null,
error: null,
cancelled: true,
};
} }
const parsedMetadata = parseAudioMetadataFromInput(input); const parsedMetadata = parseAudioMetadataFromInput(input);
const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded); const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded);
const samples = pcm16MonoArrayBufferToFloat32Samples(pcmBuffer); 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) { if (token.cancelled) {
return null; return {
result: null,
error: null,
cancelled: true,
};
} }
samplesRef.current = payload.samples;
sessionSamples = payload.samples; storeSuccessfulAnalysis(analysisKey, displayPath, payload);
setResultWithSession(payload.result);
const elapsed = ((Date.now() - start) / 1000).toFixed(2); const elapsed = ((Date.now() - start) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`); logger.success(`Audio analysis completed in ${elapsed}s`);
return payload.result;
return {
result: payload.result,
error: null,
cancelled: false,
};
} }
catch (err) { catch (err) {
if (isCancelledError(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"; const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`); logger.error(`Analysis error: ${errorMessage}`);
setErrorWithSession(errorMessage); setErrorWithSession(errorMessage);
@@ -324,10 +507,18 @@ export function useAudioAnalysis() {
percent: 0, percent: 0,
message: "Analysis failed", message: "Analysis failed",
}); });
if (!options?.suppressToast) {
toast.error("Audio Analysis Failed", { toast.error("Audio Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
return null; }
return {
result: null,
error: errorMessage,
cancelled: false,
};
} }
finally { finally {
if (analysisTokenRef.current === token) { if (analysisTokenRef.current === token) {
@@ -335,39 +526,92 @@ export function useAudioAnalysis() {
setAnalyzing(false); setAnalyzing(false);
} }
} }
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
if (!result || !samplesRef.current) 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; 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); const token = createToken(spectrumTokenRef);
setSpectrumLoading(true); setSpectrumLoading(true);
setSpectrumProgress({ setSpectrumProgress({
percent: 0, percent: 0,
message: "Preparing FFT...", message: "Preparing FFT...",
}); });
try { try {
await new Promise<void>((resolve) => setTimeout(resolve, 0)); await new Promise<void>((resolve) => setTimeout(resolve, 0));
const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, { const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
fftSize, fftSize,
windowFunction: toWindowFunction(windowFunction), windowFunction: toWindowFunction(windowFunction),
}, (progress) => { }, (progress) => {
if (token.cancelled)
return;
setSpectrumProgress(toProgressState(progress));
}, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return; return;
} }
setResult((prev) => {
const next = prev ? { ...prev, spectrum } : prev; setSpectrumProgress(toProgressState(progress));
sessionResult = next; }, () => token.cancelled);
return next;
}); if (token.cancelled) {
return null;
}
const nextResult = {
...result,
spectrum,
};
setResultWithSession(nextResult);
return nextResult;
} }
catch (err) { catch (err) {
if (isCancelledError(err)) { if (isCancelledError(err)) {
return; return null;
} }
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
logger.error(`Spectrum re-analysis error: ${errorMessage}`); logger.error(`Spectrum re-analysis error: ${errorMessage}`);
setSpectrumProgress({ setSpectrumProgress({
@@ -377,6 +621,7 @@ export function useAudioAnalysis() {
toast.error("Spectrum Analysis Failed", { toast.error("Spectrum Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
return null;
} }
finally { finally {
if (spectrumTokenRef.current === token) { if (spectrumTokenRef.current === token) {
@@ -384,7 +629,8 @@ export function useAudioAnalysis() {
setSpectrumLoading(false); setSpectrumLoading(false);
} }
} }
}, [result]); }, [result, setResultWithSession]);
const clearResult = useCallback(() => { const clearResult = useCallback(() => {
cancelToken(analysisTokenRef); cancelToken(analysisTokenRef);
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
@@ -395,9 +641,12 @@ export function useAudioAnalysis() {
setSpectrumLoading(false); setSpectrumLoading(false);
setAnalysisProgress(DEFAULT_PROGRESS_STATE); setAnalysisProgress(DEFAULT_PROGRESS_STATE);
setSpectrumProgress(DEFAULT_PROGRESS_STATE); setSpectrumProgress(DEFAULT_PROGRESS_STATE);
currentAnalysisKeyRef.current = "";
sessionCurrentAnalysisKey = "";
samplesRef.current = null; samplesRef.current = null;
sessionSamples = null; sessionSamples = null;
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
return { return {
analyzing, analyzing,
analysisProgress, analysisProgress,
@@ -408,6 +657,9 @@ export function useAudioAnalysis() {
spectrumProgress, spectrumProgress,
analyzeFile, analyzeFile,
analyzeFilePath, analyzeFilePath,
cancelAnalysis,
loadStoredAnalysis,
clearStoredAnalysis,
reAnalyzeSpectrum, reAnalyzeSpectrum,
clearResult, clearResult,
}; };