.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>
|
||||
<ul className="text-sm space-y-1">
|
||||
<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>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
@@ -37,12 +38,14 @@ function fileNameFromPath(filePath: string): string {
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const {
|
||||
analyzing,
|
||||
analysisProgress,
|
||||
result,
|
||||
analyzeFile,
|
||||
analyzeFilePath,
|
||||
clearResult,
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
spectrumProgress,
|
||||
reAnalyzeSpectrum,
|
||||
} = useAudioAnalysis();
|
||||
|
||||
@@ -229,9 +232,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
)}
|
||||
|
||||
{analyzing && !result && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -252,6 +260,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
fileName={fileName}
|
||||
onReAnalyze={reAnalyzeSpectrum}
|
||||
isAnalyzingSpectrum={spectrumLoading}
|
||||
spectrumProgress={spectrumProgress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
loadAudioAnalysisPreferences,
|
||||
saveAudioAnalysisPreferences,
|
||||
@@ -27,6 +28,10 @@ interface SpectrumVisualizationProps {
|
||||
fileName?: string;
|
||||
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
||||
isAnalyzingSpectrum?: boolean;
|
||||
spectrumProgress?: {
|
||||
percent: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
@@ -489,6 +494,7 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
fileName,
|
||||
onReAnalyze,
|
||||
isAnalyzingSpectrum,
|
||||
spectrumProgress,
|
||||
}, ref) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
{isAnalyzingSpectrum && (
|
||||
<div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center z-10 backdrop-blur-sm">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
||||
<p className="text-sm text-foreground">Re-analyzing spectrum...</p>
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-xs space-y-2 px-4">
|
||||
<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>
|
||||
)}
|
||||
<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 { logger } from "@/lib/logger";
|
||||
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";
|
||||
|
||||
type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||
@@ -24,14 +29,34 @@ function fileNameFromPath(filePath: string): string {
|
||||
return parts[parts.length - 1] || filePath;
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
function nextUiTick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> {
|
||||
const clean = base64.includes(",") ? base64.split(",")[1] : base64;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -40,13 +65,63 @@ let sessionSelectedFilePath = "";
|
||||
let sessionError: string | 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() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(() => sessionResult);
|
||||
const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath);
|
||||
const [error, setError] = useState<string | null>(() => sessionError);
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(false);
|
||||
const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||
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) => {
|
||||
sessionResult = next;
|
||||
@@ -69,7 +144,13 @@ export function useAudioAnalysis() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = createToken(analysisTokenRef);
|
||||
cancelToken(spectrumTokenRef);
|
||||
setAnalyzing(true);
|
||||
setAnalysisProgress({
|
||||
percent: 1,
|
||||
message: "Preparing file...",
|
||||
});
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(file.name);
|
||||
@@ -81,7 +162,14 @@ export function useAudioAnalysis() {
|
||||
const payload = await analyzeFlacFile(file, {
|
||||
fftSize: prefs.fftSize,
|
||||
windowFunction: prefs.windowFunction,
|
||||
});
|
||||
}, (progress) => {
|
||||
if (token.cancelled) return;
|
||||
setAnalysisProgress(toProgressState(progress));
|
||||
}, () => token.cancelled);
|
||||
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
@@ -91,16 +179,26 @@ export function useAudioAnalysis() {
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
return payload.result;
|
||||
} catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return null;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
setErrorWithSession(errorMessage);
|
||||
setAnalysisProgress({
|
||||
percent: 0,
|
||||
message: "Analysis failed",
|
||||
});
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
if (analysisTokenRef.current === token) {
|
||||
analysisTokenRef.current = null;
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
|
||||
const analyzeFilePath = useCallback(async (filePath: string) => {
|
||||
@@ -109,7 +207,13 @@ export function useAudioAnalysis() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = createToken(analysisTokenRef);
|
||||
cancelToken(spectrumTokenRef);
|
||||
setAnalyzing(true);
|
||||
setAnalysisProgress({
|
||||
percent: 1,
|
||||
message: "Reading file from disk...",
|
||||
});
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(filePath);
|
||||
@@ -126,8 +230,23 @@ export function useAudioAnalysis() {
|
||||
throw new Error("ReadFileAsBase64 backend method is unavailable");
|
||||
}
|
||||
|
||||
const base64Data = await readFileAsBase64(filePath);
|
||||
const arrayBuffer = base64ToArrayBuffer(base64Data);
|
||||
let base64Data = await readFileAsBase64(filePath);
|
||||
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 payload = await analyzeFlacArrayBuffer(
|
||||
{
|
||||
@@ -139,8 +258,21 @@ export function useAudioAnalysis() {
|
||||
fftSize: prefs.fftSize,
|
||||
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;
|
||||
sessionSamples = payload.samples;
|
||||
setResultWithSession(payload.result);
|
||||
@@ -149,58 +281,98 @@ export function useAudioAnalysis() {
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
return payload.result;
|
||||
} catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return null;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
setErrorWithSession(errorMessage);
|
||||
setAnalysisProgress({
|
||||
percent: 0,
|
||||
message: "Analysis failed",
|
||||
});
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
if (analysisTokenRef.current === token) {
|
||||
analysisTokenRef.current = null;
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
|
||||
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!result || !samplesRef.current) return;
|
||||
|
||||
const token = createToken(spectrumTokenRef);
|
||||
setSpectrumLoading(true);
|
||||
setSpectrumProgress({
|
||||
percent: 0,
|
||||
message: "Preparing FFT...",
|
||||
});
|
||||
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,
|
||||
windowFunction: toWindowFunction(windowFunction),
|
||||
});
|
||||
}, (progress) => {
|
||||
if (token.cancelled) return;
|
||||
setSpectrumProgress(toProgressState(progress));
|
||||
}, () => token.cancelled);
|
||||
|
||||
if (token.cancelled) {
|
||||
return;
|
||||
}
|
||||
setResult((prev) => {
|
||||
const next = prev ? { ...prev, spectrum } : prev;
|
||||
sessionResult = next;
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
|
||||
logger.error(`Spectrum re-analysis error: ${errorMessage}`);
|
||||
setSpectrumProgress({
|
||||
percent: 0,
|
||||
message: "Spectrum analysis failed",
|
||||
});
|
||||
toast.error("Spectrum Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
if (spectrumTokenRef.current === token) {
|
||||
spectrumTokenRef.current = null;
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const clearResult = useCallback(() => {
|
||||
cancelToken(analysisTokenRef);
|
||||
cancelToken(spectrumTokenRef);
|
||||
setAnalyzing(false);
|
||||
setResultWithSession(null);
|
||||
setErrorWithSession(null);
|
||||
setSelectedFilePathWithSession("");
|
||||
setSpectrumLoading(false);
|
||||
setAnalysisProgress(DEFAULT_PROGRESS_STATE);
|
||||
setSpectrumProgress(DEFAULT_PROGRESS_STATE);
|
||||
samplesRef.current = null;
|
||||
sessionSamples = null;
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
|
||||
return {
|
||||
analyzing,
|
||||
analysisProgress,
|
||||
result,
|
||||
error,
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
spectrumProgress,
|
||||
analyzeFile,
|
||||
analyzeFilePath,
|
||||
reAnalyzeSpectrum,
|
||||
|
||||
@@ -10,6 +10,9 @@ const DEFAULT_PARAMS: SpectrumParams = {
|
||||
windowFunction: "hann",
|
||||
};
|
||||
|
||||
const MAX_SPECTRUM_FRAMES = 2200;
|
||||
const METRICS_CHUNK_SIZE = 262144;
|
||||
|
||||
interface FlacStreamInfo {
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
@@ -29,6 +32,49 @@ export interface FlacArrayBufferInput {
|
||||
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 {
|
||||
const data = new Uint8Array(buffer);
|
||||
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 {
|
||||
let bits = 0;
|
||||
while ((1 << bits) < size)
|
||||
bits++;
|
||||
while ((1 << bits) < size) bits++;
|
||||
|
||||
const out = new Uint32Array(size);
|
||||
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,
|
||||
sampleRate: number,
|
||||
params: SpectrumParams,
|
||||
): SpectrumData {
|
||||
onProgress?: AnalysisProgressCallback,
|
||||
shouldCancel?: AnalysisCancelCheck,
|
||||
): Promise<SpectrumData> {
|
||||
throwIfCancelled(shouldCancel);
|
||||
const fftSize = params.fftSize;
|
||||
const hopSize = Math.max(1, Math.floor(fftSize / 4));
|
||||
const rawWindows = Math.floor((samples.length - fftSize) / hopSize);
|
||||
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 duration = sampleRate > 0 ? samples.length / sampleRate : 0;
|
||||
const maxFreq = sampleRate / 2;
|
||||
@@ -191,9 +240,24 @@ export function analyzeSpectrumFromSamples(
|
||||
const imag = new Float32Array(fftSize);
|
||||
const invFFTSizeSquared = 1 / (fftSize * fftSize);
|
||||
|
||||
const timeSlices: TimeSlice[] = new Array(numWindows);
|
||||
for (let i = 0; i < numWindows; i++) {
|
||||
const start = i * hopSize;
|
||||
reportProgress(onProgress, "spectrum", 0, "Preparing FFT...");
|
||||
const windowIndices: number[] = [];
|
||||
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 copyLen = Math.max(0, Math.min(fftSize, remaining));
|
||||
|
||||
@@ -208,7 +272,7 @@ export function analyzeSpectrumFromSamples(
|
||||
|
||||
fftInPlace(real, imag, bitReversal);
|
||||
|
||||
const magnitudes = new Array<number>(freqBins);
|
||||
const magnitudes = new Float32Array(freqBins);
|
||||
for (let j = 0; j < freqBins; j++) {
|
||||
const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared;
|
||||
magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120;
|
||||
@@ -218,8 +282,24 @@ export function analyzeSpectrumFromSamples(
|
||||
time: sampleRate > 0 ? start / sampleRate : 0,
|
||||
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 {
|
||||
time_slices: timeSlices,
|
||||
sample_rate: sampleRate,
|
||||
@@ -232,8 +312,14 @@ export function analyzeSpectrumFromSamples(
|
||||
export async function analyzeFlacFile(
|
||||
file: File,
|
||||
params: SpectrumParams = DEFAULT_PARAMS,
|
||||
onProgress?: AnalysisProgressCallback,
|
||||
shouldCancel?: AnalysisCancelCheck,
|
||||
): Promise<FrontendAnalysisPayload> {
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "read", 2, "Reading file...");
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "read", 10, "File loaded");
|
||||
return analyzeFlacArrayBuffer(
|
||||
{
|
||||
fileName: file.name,
|
||||
@@ -241,28 +327,56 @@ export async function analyzeFlacFile(
|
||||
arrayBuffer,
|
||||
},
|
||||
params,
|
||||
(progress) => {
|
||||
const mappedPercent = 10 + (progress.percent * 0.9);
|
||||
reportProgress(onProgress, progress.phase, mappedPercent, progress.message);
|
||||
},
|
||||
shouldCancel,
|
||||
);
|
||||
}
|
||||
|
||||
export async function analyzeFlacArrayBuffer(
|
||||
input: FlacArrayBufferInput,
|
||||
params: SpectrumParams = DEFAULT_PARAMS,
|
||||
onProgress?: AnalysisProgressCallback,
|
||||
shouldCancel?: AnalysisCancelCheck,
|
||||
): Promise<FrontendAnalysisPayload> {
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "parse", 5, "Parsing FLAC metadata...");
|
||||
const streamInfo = parseFlacStreamInfo(input.arrayBuffer);
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
|
||||
const audioContext = new AudioContext({ sampleRate: streamInfo.sampleRate });
|
||||
|
||||
try {
|
||||
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "decode", 35, "Audio decoded");
|
||||
const samples = audioBuffer.getChannelData(0);
|
||||
|
||||
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
|
||||
let peak = 0;
|
||||
let sumSquares = 0;
|
||||
let lastMetricsYieldAt = nowMs();
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
throwIfCancelled(shouldCancel);
|
||||
const sample = samples[i];
|
||||
const absSample = Math.abs(sample);
|
||||
if (absSample > peak)
|
||||
peak = absSample;
|
||||
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;
|
||||
@@ -270,12 +384,24 @@ export async function analyzeFlacArrayBuffer(
|
||||
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
||||
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 duration = durationFromBuffer > 0 ? durationFromBuffer : streamInfo.duration;
|
||||
const totalSamples = streamInfo.totalSamples > 0 ? streamInfo.totalSamples : Math.floor(duration * streamInfo.sampleRate);
|
||||
|
||||
return {
|
||||
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
|
||||
|
||||
const payload: FrontendAnalysisPayload = {
|
||||
result: {
|
||||
file_path: input.fileName,
|
||||
file_size: input.fileSize,
|
||||
@@ -292,8 +418,10 @@ export async function analyzeFlacArrayBuffer(
|
||||
},
|
||||
samples,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
|
||||
reportProgress(onProgress, "finalize", 100, "Analysis complete");
|
||||
return payload;
|
||||
} finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export interface HealthResponse {
|
||||
}
|
||||
export interface TimeSlice {
|
||||
time: number;
|
||||
magnitudes: number[];
|
||||
magnitudes: number[] | Float32Array;
|
||||
}
|
||||
export interface SpectrumData {
|
||||
time_slices: TimeSlice[];
|
||||
|
||||
Reference in New Issue
Block a user