.batch audio quality analyzer

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