.refine progressbar audio quality analyzer
This commit is contained in:
@@ -148,7 +148,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
|||||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
<li className="flex justify-between">
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Analysis Frames:</span>
|
<span className="text-muted-foreground">Display Frames:</span>
|
||||||
<span className="font-medium font-mono">{frames.toLocaleString()}</span>
|
<span className="font-medium font-mono">{frames.toLocaleString()}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex justify-between">
|
<li className="flex justify-between">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||||
@@ -37,12 +38,14 @@ function fileNameFromPath(filePath: string): string {
|
|||||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
const {
|
const {
|
||||||
analyzing,
|
analyzing,
|
||||||
|
analysisProgress,
|
||||||
result,
|
result,
|
||||||
analyzeFile,
|
analyzeFile,
|
||||||
analyzeFilePath,
|
analyzeFilePath,
|
||||||
clearResult,
|
clearResult,
|
||||||
selectedFilePath,
|
selectedFilePath,
|
||||||
spectrumLoading,
|
spectrumLoading,
|
||||||
|
spectrumProgress,
|
||||||
reAnalyzeSpectrum,
|
reAnalyzeSpectrum,
|
||||||
} = useAudioAnalysis();
|
} = useAudioAnalysis();
|
||||||
|
|
||||||
@@ -229,9 +232,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{analyzing && !result && (
|
{analyzing && !result && (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
<div className="w-full max-w-md space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>Processing...</span>
|
||||||
|
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={analysisProgress.percent} className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -252,6 +260,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
onReAnalyze={reAnalyzeSpectrum}
|
onReAnalyze={reAnalyzeSpectrum}
|
||||||
isAnalyzingSpectrum={spectrumLoading}
|
isAnalyzingSpectrum={spectrumLoading}
|
||||||
|
spectrumProgress={spectrumProgress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||||
import type { SpectrumData } from "@/types/api";
|
import type { SpectrumData } from "@/types/api";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
loadAudioAnalysisPreferences,
|
loadAudioAnalysisPreferences,
|
||||||
saveAudioAnalysisPreferences,
|
saveAudioAnalysisPreferences,
|
||||||
@@ -27,6 +28,10 @@ interface SpectrumVisualizationProps {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
||||||
isAnalyzingSpectrum?: boolean;
|
isAnalyzingSpectrum?: boolean;
|
||||||
|
spectrumProgress?: {
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type ColorScheme = AnalyzerColorScheme;
|
type ColorScheme = AnalyzerColorScheme;
|
||||||
@@ -489,6 +494,7 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
|||||||
fileName,
|
fileName,
|
||||||
onReAnalyze,
|
onReAnalyze,
|
||||||
isAnalyzingSpectrum,
|
isAnalyzingSpectrum,
|
||||||
|
spectrumProgress,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const preferencesRef = useRef(loadAudioAnalysisPreferences());
|
const preferencesRef = useRef(loadAudioAnalysisPreferences());
|
||||||
@@ -565,6 +571,8 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
|
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
|
||||||
@@ -638,9 +646,14 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
|||||||
|
|
||||||
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||||
{isAnalyzingSpectrum && (
|
{isAnalyzingSpectrum && (
|
||||||
<div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center z-10 backdrop-blur-sm">
|
<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
<div className="w-full max-w-xs space-y-2 px-4">
|
||||||
<p className="text-sm text-foreground">Re-analyzing spectrum...</p>
|
<div className="flex items-center justify-between text-sm text-foreground/90">
|
||||||
|
<span>Processing...</span>
|
||||||
|
<span className="tabular-nums">{spectrumPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={spectrumPercent} className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef, useEffect, type MutableRefObject } from "react";
|
||||||
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 { analyzeFlacArrayBuffer, analyzeFlacFile, analyzeSpectrumFromSamples } from "@/lib/flac-analysis";
|
import {
|
||||||
|
analyzeFlacArrayBuffer,
|
||||||
|
analyzeFlacFile,
|
||||||
|
analyzeSpectrumFromSamples,
|
||||||
|
type AnalysisProgress,
|
||||||
|
} 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";
|
||||||
@@ -24,14 +29,34 @@ function fileNameFromPath(filePath: string): string {
|
|||||||
return parts[parts.length - 1] || filePath;
|
return parts[parts.length - 1] || filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
function nextUiTick(): Promise<void> {
|
||||||
const clean = base64.includes(",") ? base64.split(",")[1] : base64;
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
const binary = atob(clean);
|
|
||||||
const len = binary.length;
|
|
||||||
const bytes = new Uint8Array(len);
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> {
|
||||||
|
const clean = base64.includes(",") ? base64.split(",")[1] : base64;
|
||||||
|
const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
|
||||||
|
const outputLength = Math.floor((clean.length * 3) / 4) - padding;
|
||||||
|
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;
|
return bytes.buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +65,63 @@ let sessionSelectedFilePath = "";
|
|||||||
let sessionError: string | null = null;
|
let sessionError: string | null = null;
|
||||||
let sessionSamples: Float32Array | null = null;
|
let sessionSamples: Float32Array | null = null;
|
||||||
|
|
||||||
|
interface ProgressState {
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROGRESS_STATE: ProgressState = {
|
||||||
|
percent: 0,
|
||||||
|
message: "Preparing analysis...",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CancelToken {
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
|
||||||
|
if (tokenRef.current) {
|
||||||
|
tokenRef.current.cancelled = true;
|
||||||
|
tokenRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToken(tokenRef: MutableRefObject<CancelToken | null>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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 [result, setResult] = useState<AnalysisResult | null>(() => sessionResult);
|
const [result, setResult] = useState<AnalysisResult | null>(() => sessionResult);
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath);
|
const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath);
|
||||||
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 samplesRef = useRef<Float32Array | null>(sessionSamples);
|
const samplesRef = useRef<Float32Array | null>(sessionSamples);
|
||||||
|
const analysisTokenRef = useRef<CancelToken | null>(null);
|
||||||
|
const spectrumTokenRef = useRef<CancelToken | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancelToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setResultWithSession = useCallback((next: AnalysisResult | null) => {
|
const setResultWithSession = useCallback((next: AnalysisResult | null) => {
|
||||||
sessionResult = next;
|
sessionResult = next;
|
||||||
@@ -69,7 +144,13 @@ export function useAudioAnalysis() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = createToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 1,
|
||||||
|
message: "Preparing file...",
|
||||||
|
});
|
||||||
setErrorWithSession(null);
|
setErrorWithSession(null);
|
||||||
setResultWithSession(null);
|
setResultWithSession(null);
|
||||||
setSelectedFilePathWithSession(file.name);
|
setSelectedFilePathWithSession(file.name);
|
||||||
@@ -81,7 +162,14 @@ export function useAudioAnalysis() {
|
|||||||
const payload = await analyzeFlacFile(file, {
|
const payload = await analyzeFlacFile(file, {
|
||||||
fftSize: prefs.fftSize,
|
fftSize: prefs.fftSize,
|
||||||
windowFunction: prefs.windowFunction,
|
windowFunction: prefs.windowFunction,
|
||||||
});
|
}, (progress) => {
|
||||||
|
if (token.cancelled) return;
|
||||||
|
setAnalysisProgress(toProgressState(progress));
|
||||||
|
}, () => token.cancelled);
|
||||||
|
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
samplesRef.current = payload.samples;
|
samplesRef.current = payload.samples;
|
||||||
sessionSamples = payload.samples;
|
sessionSamples = payload.samples;
|
||||||
@@ -91,16 +179,26 @@ export function useAudioAnalysis() {
|
|||||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
return payload.result;
|
return payload.result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isCancelledError(err)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
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);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Analysis failed",
|
||||||
|
});
|
||||||
toast.error("Audio Analysis Failed", {
|
toast.error("Audio Analysis Failed", {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (analysisTokenRef.current === token) {
|
||||||
|
analysisTokenRef.current = null;
|
||||||
setAnalyzing(false);
|
setAnalyzing(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
|
|
||||||
const analyzeFilePath = useCallback(async (filePath: string) => {
|
const analyzeFilePath = useCallback(async (filePath: string) => {
|
||||||
@@ -109,7 +207,13 @@ export function useAudioAnalysis() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = createToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 1,
|
||||||
|
message: "Reading file from disk...",
|
||||||
|
});
|
||||||
setErrorWithSession(null);
|
setErrorWithSession(null);
|
||||||
setResultWithSession(null);
|
setResultWithSession(null);
|
||||||
setSelectedFilePathWithSession(filePath);
|
setSelectedFilePathWithSession(filePath);
|
||||||
@@ -126,8 +230,23 @@ export function useAudioAnalysis() {
|
|||||||
throw new Error("ReadFileAsBase64 backend method is unavailable");
|
throw new Error("ReadFileAsBase64 backend method is unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64Data = await readFileAsBase64(filePath);
|
let base64Data = await readFileAsBase64(filePath);
|
||||||
const arrayBuffer = base64ToArrayBuffer(base64Data);
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 10,
|
||||||
|
message: "File loaded",
|
||||||
|
});
|
||||||
|
const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled);
|
||||||
|
base64Data = "";
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 15,
|
||||||
|
message: "Preparing audio buffer...",
|
||||||
|
});
|
||||||
const fileName = fileNameFromPath(filePath);
|
const fileName = fileNameFromPath(filePath);
|
||||||
const payload = await analyzeFlacArrayBuffer(
|
const payload = await analyzeFlacArrayBuffer(
|
||||||
{
|
{
|
||||||
@@ -139,8 +258,21 @@ export function useAudioAnalysis() {
|
|||||||
fftSize: prefs.fftSize,
|
fftSize: prefs.fftSize,
|
||||||
windowFunction: prefs.windowFunction,
|
windowFunction: prefs.windowFunction,
|
||||||
},
|
},
|
||||||
|
(progress) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => token.cancelled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
samplesRef.current = payload.samples;
|
samplesRef.current = payload.samples;
|
||||||
sessionSamples = payload.samples;
|
sessionSamples = payload.samples;
|
||||||
setResultWithSession(payload.result);
|
setResultWithSession(payload.result);
|
||||||
@@ -149,58 +281,98 @@ export function useAudioAnalysis() {
|
|||||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
return payload.result;
|
return payload.result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isCancelledError(err)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
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);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Analysis failed",
|
||||||
|
});
|
||||||
toast.error("Audio Analysis Failed", {
|
toast.error("Audio Analysis Failed", {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (analysisTokenRef.current === token) {
|
||||||
|
analysisTokenRef.current = null;
|
||||||
setAnalyzing(false);
|
setAnalyzing(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
|
|
||||||
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||||
if (!result || !samplesRef.current) return;
|
if (!result || !samplesRef.current) return;
|
||||||
|
|
||||||
|
const token = createToken(spectrumTokenRef);
|
||||||
setSpectrumLoading(true);
|
setSpectrumLoading(true);
|
||||||
|
setSpectrumProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Preparing FFT...",
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const spectrum = analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
|
||||||
fftSize,
|
fftSize,
|
||||||
windowFunction: toWindowFunction(windowFunction),
|
windowFunction: toWindowFunction(windowFunction),
|
||||||
});
|
}, (progress) => {
|
||||||
|
if (token.cancelled) return;
|
||||||
|
setSpectrumProgress(toProgressState(progress));
|
||||||
|
}, () => token.cancelled);
|
||||||
|
|
||||||
|
if (token.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setResult((prev) => {
|
setResult((prev) => {
|
||||||
const next = prev ? { ...prev, spectrum } : prev;
|
const next = prev ? { ...prev, spectrum } : prev;
|
||||||
sessionResult = next;
|
sessionResult = next;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isCancelledError(err)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
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({
|
||||||
|
percent: 0,
|
||||||
|
message: "Spectrum analysis failed",
|
||||||
|
});
|
||||||
toast.error("Spectrum Analysis Failed", {
|
toast.error("Spectrum Analysis Failed", {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
if (spectrumTokenRef.current === token) {
|
||||||
|
spectrumTokenRef.current = null;
|
||||||
setSpectrumLoading(false);
|
setSpectrumLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [result]);
|
}, [result]);
|
||||||
|
|
||||||
const clearResult = useCallback(() => {
|
const clearResult = useCallback(() => {
|
||||||
|
cancelToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
|
setAnalyzing(false);
|
||||||
setResultWithSession(null);
|
setResultWithSession(null);
|
||||||
setErrorWithSession(null);
|
setErrorWithSession(null);
|
||||||
setSelectedFilePathWithSession("");
|
setSelectedFilePathWithSession("");
|
||||||
setSpectrumLoading(false);
|
setSpectrumLoading(false);
|
||||||
|
setAnalysisProgress(DEFAULT_PROGRESS_STATE);
|
||||||
|
setSpectrumProgress(DEFAULT_PROGRESS_STATE);
|
||||||
samplesRef.current = null;
|
samplesRef.current = null;
|
||||||
sessionSamples = null;
|
sessionSamples = null;
|
||||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
analyzing,
|
analyzing,
|
||||||
|
analysisProgress,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
selectedFilePath,
|
selectedFilePath,
|
||||||
spectrumLoading,
|
spectrumLoading,
|
||||||
|
spectrumProgress,
|
||||||
analyzeFile,
|
analyzeFile,
|
||||||
analyzeFilePath,
|
analyzeFilePath,
|
||||||
reAnalyzeSpectrum,
|
reAnalyzeSpectrum,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const DEFAULT_PARAMS: SpectrumParams = {
|
|||||||
windowFunction: "hann",
|
windowFunction: "hann",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_SPECTRUM_FRAMES = 2200;
|
||||||
|
const METRICS_CHUNK_SIZE = 262144;
|
||||||
|
|
||||||
interface FlacStreamInfo {
|
interface FlacStreamInfo {
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
channels: number;
|
channels: number;
|
||||||
@@ -29,6 +32,49 @@ export interface FlacArrayBufferInput {
|
|||||||
arrayBuffer: ArrayBuffer;
|
arrayBuffer: ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AnalysisPhase = "read" | "parse" | "decode" | "metrics" | "spectrum" | "finalize";
|
||||||
|
|
||||||
|
export interface AnalysisProgress {
|
||||||
|
phase: AnalysisPhase;
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnalysisProgressCallback = (progress: AnalysisProgress) => void;
|
||||||
|
export type AnalysisCancelCheck = () => boolean;
|
||||||
|
|
||||||
|
function reportProgress(
|
||||||
|
callback: AnalysisProgressCallback | undefined,
|
||||||
|
phase: AnalysisPhase,
|
||||||
|
percent: number,
|
||||||
|
message: string,
|
||||||
|
): void {
|
||||||
|
if (!callback) return;
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
callback({
|
||||||
|
phase,
|
||||||
|
percent: clamped,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfCancelled(cancelCheck?: AnalysisCancelCheck): void {
|
||||||
|
if (cancelCheck?.()) {
|
||||||
|
throw new Error("Analysis cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowMs(): number {
|
||||||
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTick(): Promise<void> {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo {
|
function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo {
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) {
|
if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) {
|
||||||
@@ -111,8 +157,7 @@ function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["w
|
|||||||
|
|
||||||
function buildBitReversal(size: number): Uint32Array {
|
function buildBitReversal(size: number): Uint32Array {
|
||||||
let bits = 0;
|
let bits = 0;
|
||||||
while ((1 << bits) < size)
|
while ((1 << bits) < size) bits++;
|
||||||
bits++;
|
|
||||||
|
|
||||||
const out = new Uint32Array(size);
|
const out = new Uint32Array(size);
|
||||||
for (let i = 0; i < size; i++) {
|
for (let i = 0; i < size; i++) {
|
||||||
@@ -172,15 +217,19 @@ function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function analyzeSpectrumFromSamples(
|
export async function analyzeSpectrumFromSamples(
|
||||||
samples: Float32Array,
|
samples: Float32Array,
|
||||||
sampleRate: number,
|
sampleRate: number,
|
||||||
params: SpectrumParams,
|
params: SpectrumParams,
|
||||||
): SpectrumData {
|
onProgress?: AnalysisProgressCallback,
|
||||||
|
shouldCancel?: AnalysisCancelCheck,
|
||||||
|
): Promise<SpectrumData> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
const fftSize = params.fftSize;
|
const fftSize = params.fftSize;
|
||||||
const hopSize = Math.max(1, Math.floor(fftSize / 4));
|
const hopSize = Math.max(1, Math.floor(fftSize / 4));
|
||||||
const rawWindows = Math.floor((samples.length - fftSize) / hopSize);
|
const rawWindows = Math.floor((samples.length - fftSize) / hopSize);
|
||||||
const numWindows = Math.max(1, rawWindows);
|
const numWindows = Math.max(1, rawWindows);
|
||||||
|
const frameStride = Math.max(1, Math.ceil(numWindows / MAX_SPECTRUM_FRAMES));
|
||||||
const freqBins = Math.floor(fftSize / 2) + 1;
|
const freqBins = Math.floor(fftSize / 2) + 1;
|
||||||
const duration = sampleRate > 0 ? samples.length / sampleRate : 0;
|
const duration = sampleRate > 0 ? samples.length / sampleRate : 0;
|
||||||
const maxFreq = sampleRate / 2;
|
const maxFreq = sampleRate / 2;
|
||||||
@@ -191,9 +240,24 @@ export function analyzeSpectrumFromSamples(
|
|||||||
const imag = new Float32Array(fftSize);
|
const imag = new Float32Array(fftSize);
|
||||||
const invFFTSizeSquared = 1 / (fftSize * fftSize);
|
const invFFTSizeSquared = 1 / (fftSize * fftSize);
|
||||||
|
|
||||||
const timeSlices: TimeSlice[] = new Array(numWindows);
|
reportProgress(onProgress, "spectrum", 0, "Preparing FFT...");
|
||||||
for (let i = 0; i < numWindows; i++) {
|
const windowIndices: number[] = [];
|
||||||
const start = i * hopSize;
|
for (let windowIndex = 0; windowIndex < numWindows; windowIndex += frameStride) {
|
||||||
|
windowIndices.push(windowIndex);
|
||||||
|
}
|
||||||
|
if (windowIndices[windowIndices.length - 1] !== numWindows - 1) {
|
||||||
|
windowIndices.push(numWindows - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSlices = windowIndices.length;
|
||||||
|
const timeSlices: TimeSlice[] = new Array(totalSlices);
|
||||||
|
let lastReportedPercent = -1;
|
||||||
|
let lastYieldAt = nowMs();
|
||||||
|
|
||||||
|
for (let i = 0; i < totalSlices; i++) {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
const windowIndex = windowIndices[i];
|
||||||
|
const start = windowIndex * hopSize;
|
||||||
const remaining = samples.length - start;
|
const remaining = samples.length - start;
|
||||||
const copyLen = Math.max(0, Math.min(fftSize, remaining));
|
const copyLen = Math.max(0, Math.min(fftSize, remaining));
|
||||||
|
|
||||||
@@ -208,7 +272,7 @@ export function analyzeSpectrumFromSamples(
|
|||||||
|
|
||||||
fftInPlace(real, imag, bitReversal);
|
fftInPlace(real, imag, bitReversal);
|
||||||
|
|
||||||
const magnitudes = new Array<number>(freqBins);
|
const magnitudes = new Float32Array(freqBins);
|
||||||
for (let j = 0; j < freqBins; j++) {
|
for (let j = 0; j < freqBins; j++) {
|
||||||
const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared;
|
const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared;
|
||||||
magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120;
|
magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120;
|
||||||
@@ -218,8 +282,24 @@ export function analyzeSpectrumFromSamples(
|
|||||||
time: sampleRate > 0 ? start / sampleRate : 0,
|
time: sampleRate > 0 ? start / sampleRate : 0,
|
||||||
magnitudes,
|
magnitudes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentPercent = Math.floor(((i + 1) / totalSlices) * 100);
|
||||||
|
if (currentPercent > lastReportedPercent) {
|
||||||
|
lastReportedPercent = currentPercent;
|
||||||
|
reportProgress(onProgress, "spectrum", currentPercent, "Analyzing spectrum...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((i + 1) % 8 === 0) {
|
||||||
|
const now = nowMs();
|
||||||
|
if (now - lastYieldAt >= 16) {
|
||||||
|
await nextTick();
|
||||||
|
lastYieldAt = nowMs();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportProgress(onProgress, "spectrum", 100, "Spectrum analysis complete");
|
||||||
return {
|
return {
|
||||||
time_slices: timeSlices,
|
time_slices: timeSlices,
|
||||||
sample_rate: sampleRate,
|
sample_rate: sampleRate,
|
||||||
@@ -232,8 +312,14 @@ export function analyzeSpectrumFromSamples(
|
|||||||
export async function analyzeFlacFile(
|
export async function analyzeFlacFile(
|
||||||
file: File,
|
file: File,
|
||||||
params: SpectrumParams = DEFAULT_PARAMS,
|
params: SpectrumParams = DEFAULT_PARAMS,
|
||||||
|
onProgress?: AnalysisProgressCallback,
|
||||||
|
shouldCancel?: AnalysisCancelCheck,
|
||||||
): Promise<FrontendAnalysisPayload> {
|
): Promise<FrontendAnalysisPayload> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "read", 2, "Reading file...");
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "read", 10, "File loaded");
|
||||||
return analyzeFlacArrayBuffer(
|
return analyzeFlacArrayBuffer(
|
||||||
{
|
{
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@@ -241,28 +327,56 @@ export async function analyzeFlacFile(
|
|||||||
arrayBuffer,
|
arrayBuffer,
|
||||||
},
|
},
|
||||||
params,
|
params,
|
||||||
|
(progress) => {
|
||||||
|
const mappedPercent = 10 + (progress.percent * 0.9);
|
||||||
|
reportProgress(onProgress, progress.phase, mappedPercent, progress.message);
|
||||||
|
},
|
||||||
|
shouldCancel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeFlacArrayBuffer(
|
export async function analyzeFlacArrayBuffer(
|
||||||
input: FlacArrayBufferInput,
|
input: FlacArrayBufferInput,
|
||||||
params: SpectrumParams = DEFAULT_PARAMS,
|
params: SpectrumParams = DEFAULT_PARAMS,
|
||||||
|
onProgress?: AnalysisProgressCallback,
|
||||||
|
shouldCancel?: AnalysisCancelCheck,
|
||||||
): Promise<FrontendAnalysisPayload> {
|
): Promise<FrontendAnalysisPayload> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "parse", 5, "Parsing FLAC metadata...");
|
||||||
const streamInfo = parseFlacStreamInfo(input.arrayBuffer);
|
const streamInfo = parseFlacStreamInfo(input.arrayBuffer);
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
|
||||||
const audioContext = new AudioContext({ sampleRate: streamInfo.sampleRate });
|
const audioContext = new AudioContext({ sampleRate: streamInfo.sampleRate });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
|
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "decode", 35, "Audio decoded");
|
||||||
const samples = audioBuffer.getChannelData(0);
|
const samples = audioBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
let sumSquares = 0;
|
let sumSquares = 0;
|
||||||
|
let lastMetricsYieldAt = nowMs();
|
||||||
for (let i = 0; i < samples.length; i++) {
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
const sample = samples[i];
|
const sample = samples[i];
|
||||||
const absSample = Math.abs(sample);
|
const absSample = Math.abs(sample);
|
||||||
if (absSample > peak)
|
if (absSample > peak)
|
||||||
peak = absSample;
|
peak = absSample;
|
||||||
sumSquares += sample * sample;
|
sumSquares += sample * sample;
|
||||||
|
|
||||||
|
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) {
|
||||||
|
const metricsProgress = 40 + (((i + 1) / samples.length) * 10);
|
||||||
|
reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS...");
|
||||||
|
|
||||||
|
const now = nowMs();
|
||||||
|
if (now - lastMetricsYieldAt >= 16) {
|
||||||
|
await nextTick();
|
||||||
|
lastMetricsYieldAt = nowMs();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
|
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
|
||||||
@@ -270,12 +384,24 @@ export async function analyzeFlacArrayBuffer(
|
|||||||
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
||||||
const dynamicRange = peakDB - rmsDB;
|
const dynamicRange = peakDB - rmsDB;
|
||||||
|
|
||||||
const spectrum = analyzeSpectrumFromSamples(samples, streamInfo.sampleRate, params);
|
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
|
||||||
|
const spectrum = await analyzeSpectrumFromSamples(
|
||||||
|
samples,
|
||||||
|
streamInfo.sampleRate,
|
||||||
|
params,
|
||||||
|
(progress) => {
|
||||||
|
const mappedPercent = 50 + (progress.percent * 0.45);
|
||||||
|
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
|
||||||
|
},
|
||||||
|
shouldCancel,
|
||||||
|
);
|
||||||
const durationFromBuffer = audioBuffer.duration;
|
const durationFromBuffer = audioBuffer.duration;
|
||||||
const duration = durationFromBuffer > 0 ? durationFromBuffer : streamInfo.duration;
|
const duration = durationFromBuffer > 0 ? durationFromBuffer : streamInfo.duration;
|
||||||
const totalSamples = streamInfo.totalSamples > 0 ? streamInfo.totalSamples : Math.floor(duration * streamInfo.sampleRate);
|
const totalSamples = streamInfo.totalSamples > 0 ? streamInfo.totalSamples : Math.floor(duration * streamInfo.sampleRate);
|
||||||
|
|
||||||
return {
|
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
|
||||||
|
|
||||||
|
const payload: FrontendAnalysisPayload = {
|
||||||
result: {
|
result: {
|
||||||
file_path: input.fileName,
|
file_path: input.fileName,
|
||||||
file_size: input.fileSize,
|
file_size: input.fileSize,
|
||||||
@@ -292,8 +418,10 @@ export async function analyzeFlacArrayBuffer(
|
|||||||
},
|
},
|
||||||
samples,
|
samples,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
finally {
|
reportProgress(onProgress, "finalize", 100, "Analysis complete");
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
await audioContext.close();
|
await audioContext.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export interface HealthResponse {
|
|||||||
}
|
}
|
||||||
export interface TimeSlice {
|
export interface TimeSlice {
|
||||||
time: number;
|
time: number;
|
||||||
magnitudes: number[];
|
magnitudes: number[] | Float32Array;
|
||||||
}
|
}
|
||||||
export interface SpectrumData {
|
export interface SpectrumData {
|
||||||
time_slices: TimeSlice[];
|
time_slices: TimeSlice[];
|
||||||
|
|||||||
Reference in New Issue
Block a user