.refine progressbar audio quality analyzer

This commit is contained in:
afkarxyz
2026-03-25 19:25:26 +07:00
parent c342c3f9ee
commit 724520f51f
6 changed files with 358 additions and 36 deletions
+1 -1
View File
@@ -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">
+12 -3
View File
@@ -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
+186 -14
View File
@@ -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,
+140 -12
View File
@@ -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();
} }
} }
+1 -1
View File
@@ -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[];