.fix audio analyzer alac

This commit is contained in:
afkarxyz
2026-04-02 10:31:56 +07:00
parent cfcb890469
commit 9314b8ec99
4 changed files with 274 additions and 71 deletions
+82 -6
View File
@@ -2,7 +2,7 @@ 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, analyzeSpectrumFromSamples, type AnalysisProgress, } 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 {
@@ -60,6 +60,25 @@ const DEFAULT_PROGRESS_STATE: ProgressState = {
interface CancelToken {
cancelled: boolean;
}
interface WailsWindow extends Window {
go?: {
main?: {
App?: {
ReadFileAsBase64?: (path: string) => Promise<string>;
DecodeAudioForAnalysis?: (path: string) => Promise<BackendAnalysisDecodeResponse>;
};
};
};
}
interface BackendAnalysisDecodeResponse {
pcm_base64: string;
sample_rate: number;
channels: number;
bits_per_sample: number;
duration: number;
bitrate_kbps?: number;
bit_depth?: string;
}
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
if (tokenRef.current) {
tokenRef.current.cancelled = true;
@@ -81,6 +100,23 @@ function toProgressState(progress: AnalysisProgress): ProgressState {
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,
channels: decoded.channels > 0 ? decoded.channels : parsed.channels,
bitsPerSample,
totalSamples: duration > 0 && sampleRate > 0 ? Math.floor(duration * sampleRate) : parsed.totalSamples,
duration,
bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps,
};
}
export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
@@ -189,7 +225,7 @@ export function useAudioAnalysis() {
logger.info(`Analyzing audio file (frontend from path): ${filePath}`);
const start = Date.now();
const prefs = loadAudioAnalysisPreferences();
const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as ((path: string) => Promise<string>) | undefined;
const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64;
if (!readFileAsBase64) {
throw new Error("ReadFileAsBase64 backend method is unavailable");
}
@@ -211,14 +247,16 @@ export function useAudioAnalysis() {
message: "Preparing audio buffer...",
});
const fileName = fileNameFromPath(filePath);
const payload = await analyzeAudioArrayBuffer({
const input = {
fileName,
fileSize: arrayBuffer.byteLength,
arrayBuffer,
}, {
};
const analysisParams = {
fftSize: prefs.fftSize,
windowFunction: prefs.windowFunction,
}, (progress) => {
} as const;
const updateProgress = (progress: AnalysisProgress) => {
if (token.cancelled)
return;
const mappedPercent = 10 + (progress.percent * 0.9);
@@ -226,7 +264,45 @@ export function useAudioAnalysis() {
percent: Math.round(Math.max(0, Math.min(100, mappedPercent))),
message: progress.message,
});
}, () => token.cancelled);
};
let payload: FrontendAnalysisPayload;
try {
payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled);
}
catch (err) {
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;
}
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;
}
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);
}
if (token.cancelled) {
return null;
}
+85 -65
View File
@@ -17,8 +17,8 @@ const MP4_CONTAINER_TYPES = new Set([
"moov", "trak", "mdia", "minf", "stbl", "edts", "dinf",
"udta", "ilst", "meta", "stsd", "wave",
]);
type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
interface ParsedAudioMetadata {
export type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
export interface ParsedAudioMetadata {
fileType: SupportedAudioFileType;
sampleRate: number;
channels: number;
@@ -417,7 +417,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
}
}
}
else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) {
else if ((box.type === "mp4a" || box.type === "aac " || box.type === "alac") && box.offset + 36 <= boxEnd) {
channels = view.getUint16(box.offset + 24, false) || channels;
bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample;
if (!sampleRate) {
@@ -455,7 +455,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
duration,
};
}
function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
export function parseAudioMetadataFromInput(input: AudioArrayBufferInput): ParsedAudioMetadata {
const fileType = detectAudioFileType(input.arrayBuffer, input.fileName);
switch (fileType) {
case "FLAC": return parseFlacMetadata(input.arrayBuffer);
@@ -465,6 +465,15 @@ function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`);
}
}
export function pcm16MonoArrayBufferToFloat32Samples(buffer: ArrayBuffer): Float32Array {
const sampleCount = Math.floor(buffer.byteLength / 2);
const samples = new Float32Array(sampleCount);
const view = new DataView(buffer);
for (let i = 0; i < sampleCount; i++) {
samples[i] = view.getInt16(i * 2, true) / 32768;
}
return samples;
}
function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
const coeffs = new Float32Array(size);
if (size <= 1) {
@@ -649,7 +658,7 @@ export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFA
export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<FrontendAnalysisPayload> {
throwIfCancelled(shouldCancel);
reportProgress(onProgress, "parse", 5, "Parsing audio metadata...");
const metadata = parseAudioMetadata(input);
const metadata = parseAudioMetadataFromInput(input);
throwIfCancelled(shouldCancel);
reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
const audioContext = createAnalysisAudioContext(metadata.sampleRate);
@@ -658,70 +667,81 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para
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;
const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
const dynamicRange = peakDB - rmsDB;
const duration = audioBuffer.duration > 0 ? audioBuffer.duration : metadata.duration;
const totalSamples = metadata.totalSamples > 0
? metadata.totalSamples
: Math.floor(duration * metadata.sampleRate);
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
const spectrum = await analyzeSpectrumFromSamples(samples, metadata.sampleRate, params, (progress) => {
const mappedPercent = 50 + (progress.percent * 0.45);
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
}, shouldCancel);
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
const payload: FrontendAnalysisPayload = {
result: {
file_path: input.fileName,
file_size: input.fileSize,
file_type: metadata.fileType,
sample_rate: metadata.sampleRate,
channels: metadata.channels || audioBuffer.numberOfChannels,
bits_per_sample: metadata.bitsPerSample,
total_samples: totalSamples,
duration,
bit_depth: `${metadata.bitsPerSample}-bit`,
dynamic_range: dynamicRange,
peak_amplitude: peakDB,
rms_level: rmsDB,
codec_mode: metadata.codecMode,
bitrate_kbps: metadata.bitrateKbps,
total_frames: metadata.totalFrames,
codec_version: metadata.codecVersion,
spectrum,
},
samples,
};
reportProgress(onProgress, "finalize", 100, "Analysis complete");
return payload;
return analyzeDecodedSamples(input, metadata, samples, params, onProgress, shouldCancel, audioBuffer.duration);
}
finally {
await audioContext.close();
}
}
export async function analyzeDecodedSamples(input: AudioArrayBufferInput, metadata: ParsedAudioMetadata, samples: Float32Array, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck, durationOverride?: number): Promise<FrontendAnalysisPayload> {
throwIfCancelled(shouldCancel);
const analysisSampleRate = metadata.sampleRate > 0 ? metadata.sampleRate : 44100;
const analysisChannels = metadata.channels > 0 ? metadata.channels : 1;
const bitDepthLabel = metadata.bitsPerSample > 0 ? `${metadata.bitsPerSample}-bit` : "Unknown";
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) / Math.max(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 rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
const dynamicRange = peakDB - rmsDB;
const duration = durationOverride && durationOverride > 0
? durationOverride
: (metadata.duration > 0
? metadata.duration
: (analysisSampleRate > 0 ? samples.length / analysisSampleRate : 0));
const totalSamples = metadata.totalSamples > 0
? metadata.totalSamples
: (duration > 0 ? Math.floor(duration * analysisSampleRate) : samples.length);
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
const spectrum = await analyzeSpectrumFromSamples(samples, analysisSampleRate, params, (progress) => {
const mappedPercent = 50 + (progress.percent * 0.45);
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
}, shouldCancel);
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
const payload: FrontendAnalysisPayload = {
result: {
file_path: input.fileName,
file_size: input.fileSize,
file_type: metadata.fileType,
sample_rate: analysisSampleRate,
channels: analysisChannels,
bits_per_sample: metadata.bitsPerSample,
total_samples: totalSamples,
duration,
bit_depth: bitDepthLabel,
dynamic_range: dynamicRange,
peak_amplitude: peakDB,
rms_level: rmsDB,
codec_mode: metadata.codecMode,
bitrate_kbps: metadata.bitrateKbps,
total_frames: metadata.totalFrames,
codec_version: metadata.codecVersion,
spectrum,
},
samples,
};
reportProgress(onProgress, "finalize", 100, "Analysis complete");
return payload;
}
export const analyzeFlacFile = analyzeAudioFile;
export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;