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