From e3f8f7be0abdfd17f2e7ff348f27ad69818b21d1 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Wed, 25 Mar 2026 20:53:26 +0700 Subject: [PATCH] .cleanup --- backend/resample.go | 7 +- frontend/src/App.tsx | 37 +-- frontend/src/components/AboutPage.tsx | 7 +- frontend/src/components/AudioAnalysis.tsx | 107 ++----- frontend/src/components/AudioAnalysisPage.tsx | 175 +++------- .../src/components/AudioResamplerPage.tsx | 42 ++- frontend/src/components/SettingsPage.tsx | 14 +- frontend/src/components/Sidebar.tsx | 3 - .../src/components/SpectrumVisualization.tsx | 262 +++++---------- frontend/src/components/ui/audio-lines.tsx | 161 ++++------ frontend/src/hooks/useAudioAnalysis.ts | 115 +++---- .../src/lib/audio-analysis-preferences.ts | 12 - frontend/src/lib/flac-analysis.ts | 302 +++++------------- 13 files changed, 386 insertions(+), 858 deletions(-) diff --git a/backend/resample.go b/backend/resample.go index 3913f4f..9d53ec1 100644 --- a/backend/resample.go +++ b/backend/resample.go @@ -10,14 +10,12 @@ import ( "sync" ) -// FlacInfo holds basic audio properties of a FLAC file. type FlacInfo struct { Path string `json:"path"` - SampleRate uint32 `json:"sample_rate"` // e.g. 44100 - BitsPerSample uint8 `json:"bits_per_sample"` // e.g. 16, 24 + SampleRate uint32 `json:"sample_rate"` + BitsPerSample uint8 `json:"bits_per_sample"` } -// GetFlacInfoBatch reads sample rate and bit depth for multiple files in parallel. func GetFlacInfoBatch(paths []string) []FlacInfo { results := make([]FlacInfo, len(paths)) var wg sync.WaitGroup @@ -170,7 +168,6 @@ func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) { return } - // Output is always FLAC (lossless resampling). outputFile := filepath.Join(outputDir, baseName+".flac") result.OutputFile = outputFile diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e559f0..104fb33 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,13 +36,14 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; - -function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } | null { +function extractSpotifyEntityFromURL(url: string): { + type: string; + id: string; +} | null { const trimmed = url.trim(); if (!trimmed) { return null; } - const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i); if (spotifyUriMatch) { return { @@ -50,7 +51,6 @@ function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } id: spotifyUriMatch[2], }; } - try { const parsed = new URL(trimmed); const segments = parsed.pathname.split("/").filter(Boolean); @@ -60,7 +60,6 @@ function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } if (!supportedTypes.has(segment)) { continue; } - const id = segments[i + 1]; if (id) { return { type: segment, id }; @@ -69,15 +68,12 @@ function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } } catch { } - return null; } - function normalizeHistoryURL(url: string): string { const trimmed = url.trim(); if (!trimmed) return trimmed; - const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, ""); const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery); if (spotifyEntity) { @@ -85,17 +81,14 @@ function normalizeHistoryURL(url: string): string { } return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1"); } - function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string { const normalizedUrl = normalizeHistoryURL(url); const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl); if (spotifyEntity) { return `${type}:${spotifyEntity.id}`; } - return `${type}:${normalizedUrl}`; } - function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { const seen = new Set(); const deduped: HistoryItem[] = []; @@ -109,7 +102,6 @@ function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { } return deduped; } - function App() { const [currentPage, setCurrentPage] = useState("main"); const [spotifyUrl, setSpotifyUrl] = useState(""); @@ -623,17 +615,13 @@ function App() { FFmpeg Required - {brewPath ? ( - <> + {brewPath ? (<> FFmpeg is essential for SpotiFLAC to function properly. Homebrew detected. Recommended: brew install ffmpeg - - ) : ( - <> + ) : (<> FFmpeg is essential for SpotiFLAC to function properly. This setup will download about 100-200MB of data. - - )} + )} @@ -665,16 +653,11 @@ function App() { {!isInstallingFFmpeg && ()} - {brewPath ? ( - - - ) : ( - ) : ( - )} + )} diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index 555b3ee..bb374eb 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -15,7 +15,6 @@ import KofiLogo from "@/assets/ko-fi.gif"; import KofiSvg from "@/assets/kofi_symbol.svg"; import UsdtBarcode from "@/assets/usdt.jpg"; import { langColors } from "@/assets/github-lang-colors"; - export function AboutPage() { const [activeTab, setActiveTab] = useState<"projects" | "support">("projects"); const [repoStats, setRepoStats] = useState>({}); @@ -248,9 +247,9 @@ export function AboutPage() { {repoStats["SpotiFLAC-Next"] && ( {repoStats["SpotiFLAC-Next"].languages?.length > 0 && (
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => ( + backgroundColor: getLangColor(lang) + "20", + color: getLangColor(lang), + }}> {lang} ))}
)} diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx index bfbd27d..e2be418 100644 --- a/frontend/src/components/AudioAnalysis.tsx +++ b/frontend/src/components/AudioAnalysis.tsx @@ -3,7 +3,6 @@ import { Spinner } from "@/components/ui/spinner"; import { Button } from "@/components/ui/button"; import { Activity } from "lucide-react"; import type { AnalysisResult } from "@/types/api"; - interface AudioAnalysisProps { result: AnalysisResult | null; analyzing: boolean; @@ -11,83 +10,65 @@ interface AudioAnalysisProps { showAnalyzeButton?: boolean; filePath?: string; } - export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) { if (analyzing) { - return ( - + return (
Analyzing audio quality...
-
- ); +
); } - if (!result && showAnalyzeButton) { - return ( - + return (
- +

Audio Quality Analysis

Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files

- {onAnalyze && ( - - )} + )}
-
- ); +
); } - if (!result) { return null; } - const formatDuration = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; }; - const formatNumber = (num: number) => { return num.toFixed(2); }; - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 B"; + if (bytes === 0) + return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; }; - const nyquistFreq = result.sample_rate / 2; const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A"; const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:"; - const hasCodecMeta = result.file_type === "MP3" && ( - Boolean(result.codec_mode) || + const hasCodecMeta = result.file_type === "MP3" && (Boolean(result.codec_mode) || typeof result.bitrate_kbps === "number" || typeof result.total_frames === "number" || - Boolean(result.codec_version) - ); - - return ( - + Boolean(result.codec_version)); + return ( - {filePath && ( -

{filePath}

- )} + {filePath && (

{filePath}

)}
@@ -95,12 +76,10 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton

Format

    - {result.file_type && ( -
  • + {result.file_type && (
  • Type: {result.file_type} -
  • - )} + )}
  • Sample Rate: {(result.sample_rate / 1000).toFixed(1)} kHz @@ -117,12 +96,10 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton Duration: {formatDuration(result.duration)}
  • - {result.file_size > 0 && ( -
  • + {result.file_size > 0 && (
  • Size: {formatFileSize(result.file_size)} -
  • - )} + )}
@@ -152,45 +129,33 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton - {hasCodecMeta && ( -
+ {hasCodecMeta && (

MP3 Meta

    - {result.codec_mode && ( -
  • + {result.codec_mode && (
  • Mode: {result.codec_mode} -
  • - )} - {typeof result.bitrate_kbps === "number" && ( -
  • +
  • )} + {typeof result.bitrate_kbps === "number" && (
  • Bitrate: {result.bitrate_kbps} kbps -
  • - )} - {typeof result.total_frames === "number" && result.total_frames > 0 && ( -
  • +
  • )} + {typeof result.total_frames === "number" && result.total_frames > 0 && (
  • Frames: {result.total_frames.toLocaleString()} -
  • - )} - {result.codec_version && ( -
  • +
  • )} + {result.codec_version && (
  • Version: {result.codec_version} -
  • - )} + )}
-
- )} +
)} {result.spectrum && (() => { - const frames = result.spectrum.time_slices.length; - const fftSize = (result.spectrum.freq_bins - 1) * 2; - const freqRes = result.sample_rate / fftSize; - - return ( -
+ const frames = result.spectrum.time_slices.length; + const fftSize = (result.spectrum.freq_bins - 1) * 2; + const freqRes = result.sample_rate / fftSize; + return (

Spectrum Meta

  • @@ -206,11 +171,9 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton {freqRes.toFixed(2)} Hz/bin
-
- ); - })()} +
); + })()}
-
- ); +
); } diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index 752e970..962b6b1 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -8,11 +8,9 @@ import { useAudioAnalysis } from "@/hooks/useAudioAnalysis"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; - interface AudioAnalysisPageProps { onBack?: () => void; } - const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; const SUPPORTED_AUDIO_ACCEPT = [ ".flac", @@ -29,17 +27,14 @@ const SUPPORTED_AUDIO_ACCEPT = [ "audio/aacp", ].join(","); const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC"; - function isSupportedAudioPath(filePath: string): boolean { const normalized = filePath.toLowerCase(); return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext)); } - function isSupportedAudioFile(file: File): boolean { const normalizedName = file.name.toLowerCase(); const normalizedType = file.type.toLowerCase(); - return ( - SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) || + return (SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) || normalizedType === "audio/flac" || normalizedType === "audio/x-flac" || normalizedType === "audio/mpeg" || @@ -47,38 +42,23 @@ function isSupportedAudioFile(file: File): boolean { normalizedType === "audio/mp4" || normalizedType === "audio/x-m4a" || normalizedType === "audio/aac" || - normalizedType === "audio/aacp" - ); + normalizedType === "audio/aacp"); } - function isAbsolutePath(filePath: string): boolean { return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath); } - function fileNameFromPath(filePath: string): string { const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] || filePath; } - export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { - analyzing, - analysisProgress, - result, - analyzeFile, - analyzeFilePath, - clearResult, - selectedFilePath, - spectrumLoading, - spectrumProgress, - reAnalyzeSpectrum, - } = useAudioAnalysis(); - + const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis(); const [isDragging, setIsDragging] = useState(false); const [isExporting, setIsExporting] = useState(false); const fileInputRef = useRef(null); - const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null); - + const spectrumRef = useRef<{ + getCanvasDataURL: () => string | null; + }>(null); const analyzeSelectedPath = useCallback(async (filePath: string) => { if (!isSupportedAudioPath(filePath)) { toast.error("Invalid File Type", { @@ -88,7 +68,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { } await analyzeFilePath(filePath); }, [analyzeFilePath]); - const analyzeSelectedFile = useCallback(async (file: File) => { if (!isSupportedAudioFile(file)) { toast.error("Invalid File Type", { @@ -98,7 +77,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { } await analyzeFile(file); }, [analyzeFile]); - const handleSelectFile = useCallback(async () => { try { const filePath = await SelectFile(); @@ -106,48 +84,46 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { return; } await analyzeSelectedPath(filePath); - } catch { + } + catch { fileInputRef.current?.click(); } }, [analyzeSelectedPath]); - const handleInputChange = useCallback(async (e: ChangeEvent) => { const file = e.target.files?.[0]; - if (!file) return; + if (!file) + return; await analyzeSelectedFile(file); e.target.value = ""; }, [analyzeSelectedFile]); - const handleHtmlDrop = useCallback(async (e: DragEvent) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files?.[0]; - if (!file) return; + if (!file) + return; await analyzeSelectedFile(file); }, [analyzeSelectedFile]); - useEffect(() => { OnFileDrop((_x, _y, paths) => { setIsDragging(false); const droppedPath = paths?.[0]; - if (!droppedPath) return; + if (!droppedPath) + return; void analyzeSelectedPath(droppedPath); }, true); - return () => { OnFileDropOff(); }; }, [analyzeSelectedPath]); - const handleExport = useCallback(async () => { - if (!spectrumRef.current) return; - + if (!spectrumRef.current) + return; const dataUrl = spectrumRef.current.getCanvasDataURL(); if (!dataUrl) { toast.error("Export Failed", { description: "Cannot get canvas data" }); return; } - setIsExporting(true); try { if (selectedFilePath && isAbsolutePath(selectedFilePath)) { @@ -157,7 +133,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); return; } - const base = selectedFilePath ? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "") : "spectrogram"; @@ -170,128 +145,82 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { toast.success("Exported Successfully", { description: "Spectrogram image downloaded", }); - } catch (err) { + } + catch (err) { toast.error("Export Failed", { description: err instanceof Error ? err.message : "Failed to export image", }); - } finally { + } + finally { setIsExporting(false); } }, [selectedFilePath]); - const handleAnalyzeAnother = () => { clearResult(); }; - const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined; - - return ( -
- + return (
+
- {onBack && ( - - )} + {onBack && ()}

Audio Quality Analyzer

- {result && ( -
- -
- )} +
)}
- {!result && !analyzing && ( -
{ - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={(e) => { - e.preventDefault(); - setIsDragging(false); - }} - onDrop={handleHtmlDrop} - style={{ "--wails-drop-target": "drop" } as CSSProperties} - > + {!result && !analyzing && (
{ + e.preventDefault(); + setIsDragging(true); + }} onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
- +

{isDragging - ? "Drop your audio file here" - : "Drag and drop an audio file here, or click the button below to select"} + ? "Drop your audio file here" + : "Drag and drop an audio file here, or click the button below to select"}

Supported formats: FLAC, MP3, M4A, AAC

-
- )} +
)} - {analyzing && !result && ( -
+ {analyzing && !result && (
Processing... {analysisProgress.percent}%
- +
-
- )} +
)} - {result && ( -
- + {result && (
+ - -
- )} -
- ); + +
)} + ); } diff --git a/frontend/src/components/AudioResamplerPage.tsx b/frontend/src/components/AudioResamplerPage.tsx index 3615b87..55a0bc7 100644 --- a/frontend/src/components/AudioResamplerPage.tsx +++ b/frontend/src/components/AudioResamplerPage.tsx @@ -8,7 +8,6 @@ import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } fr import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { AudioLinesIcon } from "@/components/ui/audio-lines"; - interface AudioFile { path: string; name: string; @@ -20,7 +19,6 @@ interface AudioFile { srcSampleRate?: number; srcBitDepth?: number; } - function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B"; @@ -29,7 +27,6 @@ function formatFileSize(bytes: number): string { const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; } - function formatSampleRate(sr: number): string { if (!sr) return ""; @@ -39,21 +36,17 @@ function formatSampleRate(sr: number): string { return `${sr / 1000}kHz`; return `${sr}Hz`; } - const SAMPLE_RATE_OPTIONS = [ { value: "44100", label: "44.1kHz" }, { value: "48000", label: "48kHz" }, { value: "96000", label: "96kHz" }, { value: "192000", label: "192kHz" }, ]; - const BIT_DEPTH_OPTIONS = [ { value: "16", label: "16-bit" }, { value: "24", label: "24-bit" }, ]; - const STORAGE_KEY = "spotiflac_audio_resampler_state"; - export function AudioResamplerPage() { const [files, setFiles] = useState(() => { try { @@ -132,8 +125,11 @@ export function AudioResamplerPage() { return; try { const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"]; - const infos: Array<{ path: string; sample_rate: number; bits_per_sample: number; }> = - await GetFlacInfoBatch(paths); + const infos: Array<{ + path: string; + sample_rate: number; + bits_per_sample: number; + }> = await GetFlacInfoBatch(paths); setFiles((prev) => prev.map((f) => { const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase()); if (info) { @@ -389,9 +385,9 @@ export function AudioResamplerPage() {
{ - if (value) - setBitDepth(value); - }}> + if (value) + setBitDepth(value); + }}> {BIT_DEPTH_OPTIONS.map((option) => ( {option.label} ))} @@ -401,9 +397,9 @@ export function AudioResamplerPage() {
{ - if (value) - setSampleRate(value); - }}> + if (value) + setSampleRate(value); + }}> {SAMPLE_RATE_OPTIONS.map((option) => ( {option.label} ))} @@ -420,13 +416,13 @@ export function AudioResamplerPage() {
{files.map((file) => { - const srcParts: string[] = []; - if (file.srcBitDepth) - srcParts.push(`${file.srcBitDepth}-bit`); - if (file.srcSampleRate) - srcParts.push(formatSampleRate(file.srcSampleRate)); - const srcSpec = srcParts.join(" / "); - return (
+ const srcParts: string[] = []; + if (file.srcBitDepth) + srcParts.push(`${file.srcBitDepth}-bit`); + if (file.srcSampleRate) + srcParts.push(formatSampleRate(file.srcSampleRate)); + const srcSpec = srcParts.join(" / "); + return (
{getStatusIcon(file.status)}

{file.name}

@@ -451,7 +447,7 @@ export function AudioResamplerPage() { )}
); - })} + })}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index c4303c4..9836577 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -130,12 +130,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin

Settings

- diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index fcc7862..b6b35e1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -17,18 +17,15 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; - export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history"; interface SidebarProps { currentPage: PageType; onPageChange: (page: PageType) => void; } - interface AnimatedIconHandle { startAnimation: () => void; stopAnimation: () => void; } - export function Sidebar({ currentPage, onPageChange }: SidebarProps) { const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false); const [hasIssueAgreement, setHasIssueAgreement] = useState(false); diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index bcc5b9c..af4e15a 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -2,25 +2,11 @@ import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "re import type { SpectrumData } from "@/types/api"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; -import { - loadAudioAnalysisPreferences, - saveAudioAnalysisPreferences, - type AnalyzerColorScheme, - type AnalyzerFreqScale, - type AnalyzerWindowFunction, -} from "@/lib/audio-analysis-preferences"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - +import { loadAudioAnalysisPreferences, saveAudioAnalysisPreferences, type AnalyzerColorScheme, type AnalyzerFreqScale, type AnalyzerWindowFunction, } from "@/lib/audio-analysis-preferences"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; export interface SpectrumVisualizationHandle { getCanvasDataURL: () => string | null; } - interface SpectrumVisualizationProps { sampleRate: number; duration: number; @@ -33,22 +19,26 @@ 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; const MAX_RENDER_HEIGHT = 1080; - function clamp01(value: number): number { return Math.max(0, Math.min(1, value)); } - -function spekColorMap(t: number): [number, number, number] { - const colors: Array<[number, number, number]> = [ +function spekColorMap(t: number): [ + number, + number, + number +] { + const colors: Array<[ + number, + number, + number + ]> = [ [0, 0, 0], [0, 0, 25], [0, 0, 50], @@ -73,15 +63,12 @@ function spekColorMap(t: number): [number, number, number] { [255, 255, 200], [255, 255, 255], ]; - const scaled = t * (colors.length - 1); const idx = Math.floor(scaled); const fraction = scaled - idx; - if (idx >= colors.length - 1) { return colors[colors.length - 1]; } - const c1 = colors[idx]; const c2 = colors[idx + 1]; return [ @@ -90,9 +77,16 @@ function spekColorMap(t: number): [number, number, number] { Math.round(c1[2] + (c2[2] - c1[2]) * fraction), ]; } - -function viridisColorMap(t: number): [number, number, number] { - const colors: Array<[number, number, number]> = [ +function viridisColorMap(t: number): [ + number, + number, + number +] { + const colors: Array<[ + number, + number, + number + ]> = [ [68, 1, 84], [70, 20, 100], [72, 40, 120], @@ -113,15 +107,12 @@ function viridisColorMap(t: number): [number, number, number] { [216, 227, 41], [253, 231, 37], ]; - const scaled = t * (colors.length - 1); const idx = Math.floor(scaled); const fraction = scaled - idx; - if (idx >= colors.length - 1) { return colors[colors.length - 1]; } - const c1 = colors[idx]; const c2 = colors[idx + 1]; return [ @@ -130,8 +121,11 @@ function viridisColorMap(t: number): [number, number, number] { Math.floor(c1[2] + (c2[2] - c1[2]) * fraction), ]; } - -function hotColorMap(t: number): [number, number, number] { +function hotColorMap(t: number): [ + number, + number, + number +] { if (t < 0.33) { return [Math.floor(t * 3 * 255), 0, 0]; } @@ -140,12 +134,18 @@ function hotColorMap(t: number): [number, number, number] { } return [255, 255, Math.floor((t - 0.66) * 3 * 255)]; } - -function coolColorMap(t: number): [number, number, number] { +function coolColorMap(t: number): [ + number, + number, + number +] { return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255]; } - -function getColorValues(norm: number, scheme: ColorScheme): [number, number, number] { +function getColorValues(norm: number, scheme: ColorScheme): [ + number, + number, + number +] { const value = clamp01(norm); switch (scheme) { case "spek": @@ -163,74 +163,61 @@ function getColorValues(norm: number, scheme: ColorScheme): [number, number, num } } } - function getColorString(norm: number, scheme: ColorScheme): string { const [r, g, b] = getColorValues(norm, scheme); return `rgb(${r},${g},${b})`; } - -function addAxisLabels( - ctx: CanvasRenderingContext2D, - plotWidth: number, - plotHeight: number, - sampleRate: number, - duration: number, - freqScale: FreqScale, - fileName?: string, -) { +function addAxisLabels(ctx: CanvasRenderingContext2D, plotWidth: number, plotHeight: number, sampleRate: number, duration: number, freqScale: FreqScale, fileName?: string) { ctx.fillStyle = "#ffffff"; ctx.font = "12px Segoe UI"; - ctx.textAlign = "center"; const widthFactor = plotWidth / 1000; let timeStep: number; - if (duration <= 10) { timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5); - } else if (duration <= 30) { + } + else if (duration <= 30) { timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1); - } else if (duration <= 120) { + } + else if (duration <= 120) { timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5); - } else if (duration <= 600) { + } + else if (duration <= 600) { timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20); - } else { + } + else { timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40); } - if (duration > 0) { for (let time = 0; time <= duration + 1e-9; time += timeStep) { const timeProgress = time / duration; const x = MARGIN.left + timeProgress * (plotWidth - 1); const y = CANVAS_H - MARGIN.bottom + 20; - ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, MARGIN.top + plotHeight); ctx.lineTo(x, MARGIN.top + plotHeight + 5); ctx.stroke(); - let label: string; if (timeStep >= 60) { const minutes = Math.floor(time / 60); const seconds = time % 60; label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; - } else { + } + else { label = `${time}s`; } ctx.fillText(label, x, y); } } - ctx.textAlign = "right"; const maxFreq = sampleRate / 2; - if (freqScale === "log2") { const heightFactor = plotHeight / 500; const minFreq = 20; const frequencies: number[] = []; const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2); - let octaveCount = 0; for (let freq = minFreq; freq <= maxFreq; freq *= 2) { if (octaveCount % octaveStep === 0) { @@ -238,134 +225,106 @@ function addAxisLabels( } octaveCount++; } - for (const freq of frequencies) { const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq); const y = MARGIN.top + plotHeight * (1 - freqNormalized); - ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(MARGIN.left - 5, y); ctx.lineTo(MARGIN.left, y); ctx.stroke(); - const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`; ctx.fillText(label, MARGIN.left - 10, y + 4); } - } else { + } + else { const heightFactor = plotHeight / 500; let freqStep: number; - if (maxFreq <= 8000) { freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500); - } else if (maxFreq <= 16000) { + } + else if (maxFreq <= 16000) { freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000); - } else if (maxFreq <= 24000) { + } + else if (maxFreq <= 24000) { freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000); - } else { + } + else { freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000); } - for (let freq = 0; freq <= maxFreq; freq += freqStep) { const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4; const x = MARGIN.left - 15; - ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(MARGIN.left - 5, y - 4); ctx.lineTo(MARGIN.left, y - 4); ctx.stroke(); - let label: string; if (freq === 0) { label = "0"; - } else if (freq >= 1000) { + } + else if (freq >= 1000) { label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`; - } else { + } + else { label = `${freq}`; } ctx.fillText(label, x, y); } } - ctx.textAlign = "center"; ctx.font = "14px Segoe UI"; ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15); - ctx.save(); ctx.translate(25, CANVAS_H / 2); ctx.rotate(-Math.PI / 2); ctx.fillText("Frequency (Hz)", 0, 0); ctx.restore(); - ctx.font = "12px Segoe UI"; if (fileName) { ctx.textAlign = "left"; ctx.fillText(fileName, MARGIN.left + 15, 25); } - ctx.textAlign = "right"; ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25); } - -function drawColorBar( - ctx: CanvasRenderingContext2D, - plotHeight: number, - colorScheme: ColorScheme, -) { +function drawColorBar(ctx: CanvasRenderingContext2D, plotHeight: number, colorScheme: ColorScheme) { const colorBarWidth = 20; const colorBarX = CANVAS_W - MARGIN.right + 30; const colorBarY = MARGIN.top; const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY); - for (let i = 0; i <= 100; i++) { const value = i / 100; gradient.addColorStop(value, getColorString(value, colorScheme)); } - ctx.fillStyle = gradient; ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight); - ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1; ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight); - ctx.fillStyle = "#ffffff"; ctx.font = "10px Segoe UI"; ctx.textAlign = "left"; ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12); ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5); } - -async function renderSpectrogram( - ctx: CanvasRenderingContext2D, - spectrum: SpectrumData, - sampleRate: number, - duration: number, - freqScale: FreqScale, - colorScheme: ColorScheme, - fileName: string | undefined, - shouldCancel: () => boolean, -) { +async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: SpectrumData, sampleRate: number, duration: number, freqScale: FreqScale, colorScheme: ColorScheme, fileName: string | undefined, shouldCancel: () => boolean) { const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right; const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom; - ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); - const spectrogramData = spectrum.time_slices; const numTimeFrames = spectrogramData.length; const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0; if (numTimeFrames === 0 || numFreqBins === 0) { return; } - let minMag = Number.POSITIVE_INFINITY; let maxMag = Number.NEGATIVE_INFINITY; const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1; - for (let i = 0; i < numTimeFrames; i += sampleStep) { const frame = spectrogramData[i].magnitudes; for (const mag of frame) { @@ -377,24 +336,19 @@ async function renderSpectrogram( } } } - if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) { minMag = -120; maxMag = 0; } - const magRange = maxMag - minMag; const safeMagRange = magRange > 0 ? magRange : 1; - const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT); const highResData = highResImageData.data; const CHUNK_SIZE = 50; - for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) { if (shouldCancel()) { return; } - const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth); for (let x = xStart; x < xEnd; x++) { const timeProgress = x / (plotWidth - 1); @@ -402,13 +356,10 @@ async function renderSpectrogram( const timeIdx = Math.floor(exactTimePos); const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1); const timeFrac = exactTimePos - timeIdx; - const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes; const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1; - for (let y = 0; y < MAX_RENDER_HEIGHT; y++) { let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1); - if (freqScale === "log2") { const minFreq = 20; const maxFreq = sampleRate / 2; @@ -417,26 +368,23 @@ async function renderSpectrogram( const freq = minFreq * Math.pow(2, octave); freqProgress = freq / maxFreq; } - const exactFreqPos = freqProgress * (numFreqBins - 1); const freqIdx = Math.floor(exactFreqPos); const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1); const freqFrac = exactFreqPos - freqIdx; - let magnitude: number; if (timeFrac === 0 && freqFrac === 0) { magnitude = frame1[freqIdx] ?? 0; - } else { + } + else { const mag11 = frame1[freqIdx] ?? 0; const mag12 = frame1[freqIdx2] ?? 0; const mag21 = frame2[freqIdx] ?? 0; const mag22 = frame2[freqIdx2] ?? 0; - const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac; const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac; magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac; } - const normalizedMag = clamp01((magnitude - minMag) / safeMagRange); const [r, g, b] = getColorValues(normalizedMag, colorScheme); const pixelIdx = (y * plotWidth + x) * 4; @@ -446,25 +394,20 @@ async function renderSpectrogram( highResData[pixelIdx + 3] = 255; } } - if (xStart + CHUNK_SIZE < plotWidth) { await new Promise((resolve) => setTimeout(resolve, 1)); } } - if (shouldCancel()) { return; } - const finalImageData = ctx.createImageData(plotWidth, plotHeight); const finalData = finalImageData.data; - for (let y = 0; y < plotHeight; y++) { for (let x = 0; x < plotWidth; x++) { const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT); const highResIdx = (highResY * plotWidth + x) * 4; const finalIdx = (y * plotWidth + x) * 4; - if (highResIdx < highResData.length) { finalData[finalIdx] = highResData[highResIdx]; finalData[finalIdx + 1] = highResData[highResIdx + 1]; @@ -473,32 +416,24 @@ async function renderSpectrogram( } } } - ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top); addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName); drawColorBar(ctx, plotHeight, colorScheme); } - -const COLOR_SCHEMES: { value: ColorScheme; label: string; gradient: string; }[] = [ +const COLOR_SCHEMES: { + value: ColorScheme; + label: string; + gradient: string; +}[] = [ { value: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" }, { value: "viridis", label: "Viridis", gradient: "linear-gradient(to right, #440154, #31688e, #35b779, #fde725)" }, { value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" }, { value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" }, { value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" }, ]; - -export const SpectrumVisualization = forwardRef(({ - sampleRate, - duration, - spectrumData, - fileName, - onReAnalyze, - isAnalyzingSpectrum, - spectrumProgress, -}, ref) => { +export const SpectrumVisualization = forwardRef(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => { const canvasRef = useRef(null); const preferencesRef = useRef(loadAudioAnalysisPreferences()); - useImperativeHandle(ref, () => ({ getCanvasDataURL: () => { if (!canvasRef.current) @@ -506,18 +441,15 @@ export const SpectrumVisualization = forwardRef(preferencesRef.current.freqScale); const [colorScheme, setColorScheme] = useState(preferencesRef.current.colorScheme); const [fftSize, setFftSize] = useState(() => String(preferencesRef.current.fftSize)); const [windowFunction, setWindowFunction] = useState(preferencesRef.current.windowFunction); - useEffect(() => { if (spectrumData?.freq_bins) { setFftSize(String((spectrumData.freq_bins - 1) * 2)); } }, [spectrumData]); - useEffect(() => { saveAudioAnalysisPreferences({ colorScheme, @@ -526,7 +458,6 @@ export const SpectrumVisualization = forwardRef { const canvas = canvasRef.current; if (!canvas) @@ -534,22 +465,12 @@ export const SpectrumVisualization = forwardRef canceled; - if (spectrumData) { - void renderSpectrogram( - ctx, - spectrumData, - sampleRate, - duration, - freqScale, - colorScheme, - fileName, - shouldCancel, - ); - } else { + void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel); + } + else { ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); ctx.fillStyle = "#444444"; @@ -557,12 +478,10 @@ export const SpectrumVisualization = forwardRef { canceled = true; }; }, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]); - const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => { setFftSize(newFftSize); setWindowFunction(newWindowFunc as WindowFunction); @@ -570,11 +489,8 @@ export const SpectrumVisualization = forwardRef + return (
@@ -583,17 +499,12 @@ export const SpectrumVisualization = forwardRef - {COLOR_SCHEMES.map((scheme) => ( - + {COLOR_SCHEMES.map((scheme) => (
-
+
{scheme.label}
- - ))} + ))}
@@ -645,25 +556,16 @@ export const SpectrumVisualization = forwardRef
- {isAnalyzingSpectrum && ( -
+ {isAnalyzingSpectrum && (
Processing... {spectrumPercent}%
- +
-
- )} - +
)} +
-
- ); +
); }); diff --git a/frontend/src/components/ui/audio-lines.tsx b/frontend/src/components/ui/audio-lines.tsx index b94126c..0040347 100644 --- a/frontend/src/components/ui/audio-lines.tsx +++ b/frontend/src/components/ui/audio-lines.tsx @@ -1,138 +1,87 @@ "use client"; - import { motion, useAnimation } from "motion/react"; import type { HTMLAttributes } from "react"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; - import { cn } from "@/lib/utils"; - export interface AudioLinesIconHandle { - startAnimation: () => void; - stopAnimation: () => void; + startAnimation: () => void; + stopAnimation: () => void; } - interface AudioLinesIconProps extends HTMLAttributes { - size?: number; + size?: number; } - -const AudioLinesIcon = forwardRef( - ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { +const AudioLinesIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); - useImperativeHandle(ref, () => { - isControlledRef.current = true; - - return { - startAnimation: () => controls.start("animate"), - stopAnimation: () => controls.start("normal"), - }; + isControlledRef.current = true; + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; }); - - const handleMouseEnter = useCallback( - (e: React.MouseEvent) => { + const handleMouseEnter = useCallback((e: React.MouseEvent) => { if (isControlledRef.current) { - onMouseEnter?.(e); - } else { - controls.start("animate"); + onMouseEnter?.(e); } - }, - [controls, onMouseEnter] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { + else { + controls.start("animate"); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (isControlledRef.current) { - onMouseLeave?.(e); - } else { - controls.start("normal"); + onMouseLeave?.(e); } - }, - [controls, onMouseLeave] - ); - - return ( -
- - - + + + - + - + - + - + }, + }}/> + -
- ); - } -); - +
); +}); AudioLinesIcon.displayName = "AudioLinesIcon"; - export { AudioLinesIcon }; diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index 10b41a0..fd4838c 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -2,16 +2,9 @@ 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, analyzeSpectrumFromSamples, type AnalysisProgress, } 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": @@ -23,87 +16,71 @@ 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 { return new Promise((resolve) => setTimeout(resolve, 0)); } - async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise { const clean = base64.includes(",") ? base64.split(",")[1] : base64; const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0; const outputLength = Math.floor((clean.length * 3) / 4) - padding; const bytes = new Uint8Array(outputLength); const chunkSize = 4 * 16384; - let writeOffset = 0; for (let offset = 0; offset < clean.length; offset += chunkSize) { if (shouldCancel?.()) { throw new Error("Analysis cancelled"); } - const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize)); const binary = atob(chunk); for (let i = 0; i < binary.length; i++) { bytes[writeOffset++] = binary.charCodeAt(i); } - if ((offset / chunkSize) % 4 === 0) { await nextUiTick(); } } - return bytes.buffer; } - let sessionResult: AnalysisResult | null = null; 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): void { if (tokenRef.current) { tokenRef.current.cancelled = true; tokenRef.current = null; } } - function createToken(tokenRef: MutableRefObject): 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(DEFAULT_PROGRESS_STATE); @@ -115,35 +92,29 @@ export function useAudioAnalysis() { const samplesRef = useRef(sessionSamples); const analysisTokenRef = useRef(null); const spectrumTokenRef = useRef(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) => { if (!file) { setErrorWithSession("No file provided"); return null; } - const token = createToken(analysisTokenRef); cancelToken(spectrumTokenRef); setAnalyzing(true); @@ -154,7 +125,6 @@ export function useAudioAnalysis() { setErrorWithSession(null); setResultWithSession(null); setSelectedFilePathWithSession(file.name); - try { logger.info(`Analyzing audio file (frontend): ${file.name}`); const start = Date.now(); @@ -163,22 +133,21 @@ export function useAudioAnalysis() { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, }, (progress) => { - if (token.cancelled) return; + if (token.cancelled) + return; setAnalysisProgress(toProgressState(progress)); }, () => token.cancelled); - if (token.cancelled) { return null; } - samplesRef.current = payload.samples; sessionSamples = payload.samples; setResultWithSession(payload.result); - const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); return payload.result; - } catch (err) { + } + catch (err) { if (isCancelledError(err)) { return null; } @@ -193,20 +162,19 @@ export function useAudioAnalysis() { description: errorMessage, }); return null; - } finally { + } + finally { if (analysisTokenRef.current === token) { analysisTokenRef.current = null; setAnalyzing(false); } } }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - const analyzeFilePath = useCallback(async (filePath: string) => { if (!filePath) { setErrorWithSession("No file path provided"); return null; } - const token = createToken(analysisTokenRef); cancelToken(spectrumTokenRef); setAnalyzing(true); @@ -217,19 +185,14 @@ export function useAudioAnalysis() { setErrorWithSession(null); setResultWithSession(null); setSelectedFilePathWithSession(filePath); - try { 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) - | undefined; + const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as ((path: string) => Promise) | undefined; if (!readFileAsBase64) { throw new Error("ReadFileAsBase64 backend method is unavailable"); } - let base64Data = await readFileAsBase64(filePath); if (token.cancelled) { return null; @@ -248,39 +211,33 @@ export function useAudioAnalysis() { message: "Preparing audio buffer...", }); const fileName = fileNameFromPath(filePath); - const payload = await analyzeAudioArrayBuffer( - { - fileName, - fileSize: arrayBuffer.byteLength, - arrayBuffer, - }, - { - 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, - ); - + const payload = await analyzeAudioArrayBuffer({ + fileName, + fileSize: arrayBuffer.byteLength, + arrayBuffer, + }, { + 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); - const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); return payload.result; - } catch (err) { + } + catch (err) { if (isCancelledError(err)) { return null; } @@ -295,17 +252,17 @@ export function useAudioAnalysis() { description: errorMessage, }); return null; - } finally { + } + 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; - + if (!result || !samplesRef.current) + return; const token = createToken(spectrumTokenRef); setSpectrumLoading(true); setSpectrumProgress({ @@ -318,10 +275,10 @@ export function useAudioAnalysis() { fftSize, windowFunction: toWindowFunction(windowFunction), }, (progress) => { - if (token.cancelled) return; + if (token.cancelled) + return; setSpectrumProgress(toProgressState(progress)); }, () => token.cancelled); - if (token.cancelled) { return; } @@ -330,7 +287,8 @@ export function useAudioAnalysis() { sessionResult = next; return next; }); - } catch (err) { + } + catch (err) { if (isCancelledError(err)) { return; } @@ -343,14 +301,14 @@ export function useAudioAnalysis() { toast.error("Spectrum Analysis Failed", { description: errorMessage, }); - } finally { + } + finally { if (spectrumTokenRef.current === token) { spectrumTokenRef.current = null; setSpectrumLoading(false); } } }, [result]); - const clearResult = useCallback(() => { cancelToken(analysisTokenRef); cancelToken(spectrumTokenRef); @@ -364,7 +322,6 @@ export function useAudioAnalysis() { samplesRef.current = null; sessionSamples = null; }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - return { analyzing, analysisProgress, diff --git a/frontend/src/lib/audio-analysis-preferences.ts b/frontend/src/lib/audio-analysis-preferences.ts index 5e92bd8..66bb3d1 100644 --- a/frontend/src/lib/audio-analysis-preferences.ts +++ b/frontend/src/lib/audio-analysis-preferences.ts @@ -1,52 +1,42 @@ export type AnalyzerColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale"; export type AnalyzerFreqScale = "linear" | "log2"; export type AnalyzerWindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; - export interface AudioAnalysisPreferences { colorScheme: AnalyzerColorScheme; freqScale: AnalyzerFreqScale; fftSize: number; windowFunction: AnalyzerWindowFunction; } - const STORAGE_KEY = "spotiflac_audio_analysis_preferences"; - const DEFAULT_PREFERENCES: AudioAnalysisPreferences = { colorScheme: "spek", freqScale: "linear", fftSize: 4096, windowFunction: "hann", }; - const FFT_SIZE_SET = new Set([512, 1024, 2048, 4096]); - function toColorScheme(value: unknown): AnalyzerColorScheme { return value === "viridis" || value === "hot" || value === "cool" || value === "grayscale" ? value : "spek"; } - function toFreqScale(value: unknown): AnalyzerFreqScale { return value === "log2" ? "log2" : "linear"; } - function toFFTSize(value: unknown): number { const num = typeof value === "number" ? value : Number(value); return FFT_SIZE_SET.has(num) ? num : 4096; } - function toWindowFunction(value: unknown): AnalyzerWindowFunction { return value === "hamming" || value === "blackman" || value === "rectangular" ? value : "hann"; } - export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return DEFAULT_PREFERENCES; - const parsed = JSON.parse(raw) as Partial; return { colorScheme: toColorScheme(parsed.colorScheme), @@ -59,7 +49,6 @@ export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences { return DEFAULT_PREFERENCES; } } - export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferences): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ @@ -70,6 +59,5 @@ export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferenc })); } catch { - // Ignore persistence errors. } } diff --git a/frontend/src/lib/flac-analysis.ts b/frontend/src/lib/flac-analysis.ts index b66c80d..d3bc39c 100644 --- a/frontend/src/lib/flac-analysis.ts +++ b/frontend/src/lib/flac-analysis.ts @@ -1,29 +1,23 @@ import type { AnalysisResult, SpectrumData, TimeSlice } from "@/types/api"; - export interface SpectrumParams { fftSize: number; windowFunction: "hann" | "hamming" | "blackman" | "rectangular"; } - const DEFAULT_PARAMS: SpectrumParams = { fftSize: 4096, windowFunction: "hann", }; - const MAX_SPECTRUM_FRAMES = 2200; const METRICS_CHUNK_SIZE = 262144; const AAC_SAMPLE_RATES = [ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, ] as const; - 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 { fileType: SupportedAudioFileType; sampleRate: number; @@ -36,104 +30,77 @@ interface ParsedAudioMetadata { totalFrames?: number; codecVersion?: string; } - interface Mp4BoxInfo { offset: number; size: number; headerSize: number; type: string; } - export interface FrontendAnalysisPayload { result: AnalysisResult; samples: Float32Array; } - export interface AudioArrayBufferInput { fileName: string; fileSize: number; 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; +function reportProgress(callback: AnalysisProgressCallback | undefined, phase: AnalysisPhase, percent: number, message: string): void { + if (!callback) + return; callback({ phase, percent: Math.max(0, Math.min(100, percent)), 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 { if (typeof requestAnimationFrame === "function") { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } return new Promise((resolve) => setTimeout(resolve, 0)); } - function readFourCC(view: DataView, offset: number): string { - return String.fromCharCode( - view.getUint8(offset), - view.getUint8(offset + 1), - view.getUint8(offset + 2), - view.getUint8(offset + 3), - ); + return String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3)); } - function fileExtension(fileName: string): string { const normalized = fileName.toLowerCase(); const dotIndex = normalized.lastIndexOf("."); return dotIndex >= 0 ? normalized.slice(dotIndex) : ""; } - function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudioFileType { const view = new DataView(buffer); - if (view.byteLength >= 4 && view.getUint32(0, false) === 0x664c6143) { return "FLAC"; } - if (view.byteLength >= 3 && view.getUint8(0) === 0x49 && view.getUint8(1) === 0x44 && view.getUint8(2) === 0x33) { return "MP3"; } - if (view.byteLength >= 8 && readFourCC(view, 4) === "ftyp") { return "M4A"; } - if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xf6) === 0xf0) { return "AAC"; } - for (let offset = 0; offset < Math.min(4096, view.byteLength - 4); offset++) { const header = view.getUint32(offset, false); if ((header >>> 21) === 0x7ff) { @@ -145,7 +112,6 @@ function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudio } } } - switch (fileExtension(fileName)) { case ".flac": return "FLAC"; case ".mp3": return "MP3"; @@ -155,36 +121,31 @@ function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudio default: throw new Error(`Unsupported audio format: ${fileName || "unknown"}`); } } - function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { const data = new Uint8Array(buffer); if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) { throw new Error("Invalid FLAC file"); } - let offset = 4; while (offset + 4 <= data.length) { const blockHeader = data[offset]; const blockType = blockHeader & 0x7f; const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; offset += 4; - - if (offset + blockLength > data.length) break; - + if (offset + blockLength > data.length) + break; if (blockType === 0 && blockLength >= 18) { const streamInfo = data.subarray(offset, offset + blockLength); const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4); const channels = ((streamInfo[12] >> 1) & 0x07) + 1; const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1; - const totalSamplesBig = - (BigInt(streamInfo[13] & 0x0f) << 32n) | + const totalSamplesBig = (BigInt(streamInfo[13] & 0x0f) << 32n) | (BigInt(streamInfo[14]) << 24n) | (BigInt(streamInfo[15]) << 16n) | (BigInt(streamInfo[16]) << 8n) | BigInt(streamInfo[17]); const totalSamples = Number(totalSamplesBig); const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0; - return { fileType: "FLAC", sampleRate, @@ -194,13 +155,10 @@ function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { duration, }; } - offset += blockLength; } - throw new Error("FLAC STREAMINFO metadata not found"); } - function skipId3v2Tag(view: DataView): number { if (view.byteLength < 10 || view.getUint8(0) !== 0x49 || @@ -208,20 +166,16 @@ function skipId3v2Tag(view: DataView): number { view.getUint8(2) !== 0x33) { return 0; } - - const size = - ((view.getUint8(6) & 0x7f) << 21) | + const size = ((view.getUint8(6) & 0x7f) << 21) | ((view.getUint8(7) & 0x7f) << 14) | ((view.getUint8(8) & 0x7f) << 7) | (view.getUint8(9) & 0x7f); - let offset = 10 + size; if ((view.getUint8(5) & 0x10) !== 0) { offset += 10; } return offset < view.byteLength ? offset : 0; } - function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): number { const tables: Record> = { 1: { @@ -235,17 +189,16 @@ function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): nu 3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], }, }; - const normalizedVersion = version === 2.5 ? 2 : version; return tables[normalizedVersion]?.[layer]?.[bitrateIndex] ?? 0; } - function getMp3SamplesPerFrame(version: number, layer: number): number { - if (layer === 1) return 384; - if (version === 1) return 1152; + if (layer === 1) + return 384; + if (version === 1) + return 1152; return 576; } - interface Mp3FrameInfo { version: number; versionName: string; @@ -256,24 +209,26 @@ interface Mp3FrameInfo { frameSize: number; samplesPerFrame: number; } - function parseMp3FrameHeader(header: number): Mp3FrameInfo | null { - if (((header >>> 21) & 0x7ff) !== 0x7ff) return null; - + if (((header >>> 21) & 0x7ff) !== 0x7ff) + return null; const versionBits = (header >>> 19) & 0x03; const layerBits = (header >>> 17) & 0x03; const bitrateIndex = (header >>> 12) & 0x0f; const sampleRateIndex = (header >>> 10) & 0x03; const padding = (header >>> 9) & 0x01; const channelMode = (header >>> 6) & 0x03; - const versions = [2.5, null, 2, 1] as const; const layers = [null, 3, 2, 1] as const; const version = versions[versionBits]; const layer = layers[layerBits]; - if (version === null || layer === null || sampleRateIndex === 3) return null; - - const sampleRateTables: Record<1 | 2 | 25, [number, number, number]> = { + if (version === null || layer === null || sampleRateIndex === 3) + return null; + const sampleRateTables: Record<1 | 2 | 25, [ + number, + number, + number + ]> = { 1: [44100, 48000, 32000], 2: [22050, 24000, 16000], 25: [11025, 12000, 8000], @@ -282,8 +237,8 @@ function parseMp3FrameHeader(header: number): Mp3FrameInfo | null { const sampleRate = sampleRateTables[sampleRateKey][sampleRateIndex]; const bitrate = getMp3Bitrate(version, layer, bitrateIndex); const samplesPerFrame = getMp3SamplesPerFrame(version, layer); - if (!sampleRate || !bitrate || !samplesPerFrame) return null; - + if (!sampleRate || !bitrate || !samplesPerFrame) + return null; return { version, versionName: `MPEG-${version === 1 ? "1" : version === 2 ? "2" : "2.5"}`, @@ -295,21 +250,19 @@ function parseMp3FrameHeader(header: number): Mp3FrameInfo | null { samplesPerFrame, }; } - function getMp3SideInfoSize(frameInfo: Mp3FrameInfo): number { if (frameInfo.version === 1) { return frameInfo.channels === 1 ? 17 : 32; } return frameInfo.channels === 1 ? 9 : 17; } - function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { - if (offset + 16 > view.byteLength) return null; + if (offset + 16 > view.byteLength) + return null; const flags = view.getUint32(offset + 4, false); let pos = offset + 8; let totalFrames = 0; let totalBytes = 0; - if ((flags & 0x01) !== 0 && pos + 4 <= view.byteLength) { totalFrames = view.getUint32(pos, false); pos += 4; @@ -317,10 +270,8 @@ function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameI if ((flags & 0x02) !== 0 && pos + 4 <= view.byteLength) { totalBytes = view.getUint32(pos, false); } - const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; const avgBitrate = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate; - return { codecMode: "VBR (Xing)", totalFrames, @@ -328,9 +279,9 @@ function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameI bitrateKbps: avgBitrate, }; } - function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { - if (offset + 18 > view.byteLength) return null; + if (offset + 18 > view.byteLength) + return null; const totalBytes = view.getUint32(offset + 10, false); const totalFrames = view.getUint32(offset + 14, false); const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; @@ -342,43 +293,27 @@ function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameI bitrateKbps, }; } - function parseMp3VbrInfo(view: DataView, frameOffset: number, frameInfo: Mp3FrameInfo) { const sideInfoSize = getMp3SideInfoSize(frameInfo); const xingOffset = frameOffset + 4 + sideInfoSize; - if (xingOffset + 4 <= view.byteLength) { - const xingTag = String.fromCharCode( - view.getUint8(xingOffset), - view.getUint8(xingOffset + 1), - view.getUint8(xingOffset + 2), - view.getUint8(xingOffset + 3), - ); + const xingTag = String.fromCharCode(view.getUint8(xingOffset), view.getUint8(xingOffset + 1), view.getUint8(xingOffset + 2), view.getUint8(xingOffset + 3)); if (xingTag === "Xing" || xingTag === "Info") { return parseMp3XingHeader(view, xingOffset, frameInfo); } } - const vbriOffset = frameOffset + 36; if (vbriOffset + 4 <= view.byteLength) { - const vbriTag = String.fromCharCode( - view.getUint8(vbriOffset), - view.getUint8(vbriOffset + 1), - view.getUint8(vbriOffset + 2), - view.getUint8(vbriOffset + 3), - ); + const vbriTag = String.fromCharCode(view.getUint8(vbriOffset), view.getUint8(vbriOffset + 1), view.getUint8(vbriOffset + 2), view.getUint8(vbriOffset + 3)); if (vbriTag === "VBRI") { return parseMp3VbriHeader(view, vbriOffset, frameInfo); } } - return null; } - function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata { const view = new DataView(buffer); const startOffset = skipId3v2Tag(view); - for (let offset = startOffset; offset <= view.byteLength - 4; offset++) { const header = view.getUint32(offset, false); const frameInfo = parseMp3FrameHeader(header); @@ -389,7 +324,6 @@ function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata { const totalFrames = vbrInfo?.totalFrames ?? Math.floor(estimatedAudioDataSize / estimatedFrameSize); const duration = vbrInfo?.duration ?? ((totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate); const bitrateKbps = vbrInfo?.bitrateKbps ?? frameInfo.bitrate; - return { fileType: "MP3", sampleRate: frameInfo.sampleRate, @@ -404,21 +338,18 @@ function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata { }; } } - throw new Error("No valid MP3 frame found"); } - function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { const data = new Uint8Array(buffer); - for (let offset = 0; offset <= data.length - 7; offset++) { - if (data[offset] !== 0xff || (data[offset + 1] & 0xf6) !== 0xf0) continue; - + if (data[offset] !== 0xff || (data[offset + 1] & 0xf6) !== 0xf0) + continue; const sampleRateIndex = (data[offset + 2] >> 2) & 0x0f; const sampleRate = AAC_SAMPLE_RATES[sampleRateIndex]; const channels = ((data[offset + 2] & 0x01) << 2) | ((data[offset + 3] >> 6) & 0x03); - if (!sampleRate) continue; - + if (!sampleRate) + continue; return { fileType: "AAC", sampleRate, @@ -428,48 +359,43 @@ function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { duration: 0, }; } - throw new Error("No valid AAC ADTS header found"); } - function readMp4Box(view: DataView, offset: number, limit: number): Mp4BoxInfo | null { - if (offset + 8 > limit) return null; - + if (offset + 8 > limit) + return null; let size = view.getUint32(offset, false); const type = readFourCC(view, offset + 4); let headerSize = 8; - if (size === 1) { - if (offset + 16 > limit) return null; + if (offset + 16 > limit) + return null; const high = view.getUint32(offset + 8, false); const low = view.getUint32(offset + 12, false); size = high * 4294967296 + low; headerSize = 16; - } else if (size === 0) { + } + else if (size === 0) { size = limit - offset; } - - if (size < headerSize || offset + size > limit) return null; - + if (size < headerSize || offset + size > limit) + return null; return { offset, size, headerSize, type }; } - function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { const view = new DataView(buffer); let sampleRate = 0; let channels = 0; let bitsPerSample = 0; let duration = 0; - const scanBoxes = (start: number, end: number): void => { let offset = start; while (offset + 8 <= end) { const box = readMp4Box(view, offset, end); - if (!box) break; - + if (!box) + break; const boxEnd = box.offset + box.size; const contentStart = box.offset + box.headerSize; - if (box.type === "mdhd" && contentStart + 24 <= boxEnd) { const version = view.getUint8(contentStart); if (version === 0 && contentStart + 24 <= boxEnd) { @@ -479,7 +405,8 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { sampleRate = timeScale; duration = durationValue / timeScale; } - } else if (version === 1 && contentStart + 36 <= boxEnd) { + } + else if (version === 1 && contentStart + 36 <= boxEnd) { const timeScale = view.getUint32(contentStart + 20, false); const durationHigh = view.getUint32(contentStart + 24, false); const durationLow = view.getUint32(contentStart + 28, false); @@ -489,7 +416,8 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { duration = durationValue / timeScale; } } - } else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) { + } + else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) { channels = view.getUint16(box.offset + 24, false) || channels; bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample; if (!sampleRate) { @@ -499,24 +427,25 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { } } } - if (MP4_CONTAINER_TYPES.has(box.type)) { let childStart = contentStart; - if (box.type === "meta") childStart = Math.min(boxEnd, contentStart + 4); - else if (box.type === "stsd") childStart = Math.min(boxEnd, contentStart + 8); - if (childStart < boxEnd) scanBoxes(childStart, boxEnd); + if (box.type === "meta") + childStart = Math.min(boxEnd, contentStart + 4); + else if (box.type === "stsd") + childStart = Math.min(boxEnd, contentStart + 8); + if (childStart < boxEnd) + scanBoxes(childStart, boxEnd); } - offset = boxEnd; } }; - scanBoxes(0, view.byteLength); - - if (sampleRate <= 0) sampleRate = 44100; - if (channels <= 0) channels = 2; - if (bitsPerSample <= 0) bitsPerSample = 16; - + if (sampleRate <= 0) + sampleRate = 44100; + if (channels <= 0) + channels = 2; + if (bitsPerSample <= 0) + bitsPerSample = 16; return { fileType: "M4A", sampleRate, @@ -526,10 +455,8 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { duration, }; } - function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata { const fileType = detectAudioFileType(input.arrayBuffer, input.fileName); - switch (fileType) { case "FLAC": return parseFlacMetadata(input.arrayBuffer); case "MP3": return parseMp3Metadata(input.arrayBuffer); @@ -538,14 +465,12 @@ function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata { default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`); } } - function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array { const coeffs = new Float32Array(size); if (size <= 1) { coeffs.fill(1); return coeffs; } - for (let i = 0; i < size; i++) { switch (windowFunction) { case "hamming": @@ -554,8 +479,8 @@ function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["w case "blackman": coeffs[i] = 0.42 - - 0.5 * Math.cos((2 * Math.PI * i) / (size - 1)) + - 0.08 * Math.cos((4 * Math.PI * i) / (size - 1)); + 0.5 * Math.cos((2 * Math.PI * i) / (size - 1)) + + 0.08 * Math.cos((4 * Math.PI * i) / (size - 1)); break; case "rectangular": coeffs[i] = 1; @@ -566,14 +491,12 @@ function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["w break; } } - return coeffs; } - 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++) { let x = i; @@ -586,44 +509,36 @@ function buildBitReversal(size: number): Uint32Array { } return out; } - function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32Array): void { const size = real.length; - for (let i = 1; i < size; i++) { const j = bitReversal[i]; if (i < j) { const tr = real[i]; real[i] = real[j]; real[j] = tr; - const ti = imag[i]; imag[i] = imag[j]; imag[j] = ti; } } - for (let len = 2; len <= size; len <<= 1) { const wLen = (-2 * Math.PI) / len; const wLenReal = Math.cos(wLen); const wLenImag = Math.sin(wLen); - for (let i = 0; i < size; i += len) { let wReal = 1; let wImag = 0; const half = len >> 1; - for (let j = 0; j < half; j++) { const uReal = real[i + j]; const uImag = imag[i + j]; const vReal = real[i + j + half] * wReal - imag[i + j + half] * wImag; const vImag = real[i + j + half] * wImag + imag[i + j + half] * wReal; - real[i + j] = uReal + vReal; imag[i + j] = uImag + vImag; real[i + j + half] = uReal - vReal; imag[i + j + half] = uImag - vImag; - const tempReal = wReal * wLenReal - wImag * wLenImag; wImag = wReal * wLenImag + wImag * wLenReal; wReal = tempReal; @@ -631,14 +546,7 @@ function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32A } } } - -export async function analyzeSpectrumFromSamples( - samples: Float32Array, - sampleRate: number, - params: SpectrumParams, - onProgress?: AnalysisProgressCallback, - shouldCancel?: AnalysisCancelCheck, -): Promise { +export async function analyzeSpectrumFromSamples(samples: Float32Array, sampleRate: number, params: SpectrumParams, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise { throwIfCancelled(shouldCancel); const fftSize = params.fftSize; const hopSize = Math.max(1, Math.floor(fftSize / 4)); @@ -648,13 +556,11 @@ export async function analyzeSpectrumFromSamples( const freqBins = Math.floor(fftSize / 2) + 1; const duration = sampleRate > 0 ? samples.length / sampleRate : 0; const maxFreq = sampleRate / 2; - const windowCoeffs = buildWindowCoefficients(fftSize, params.windowFunction); const bitReversal = buildBitReversal(fftSize); const real = new Float32Array(fftSize); const imag = new Float32Array(fftSize); const invFFTSizeSquared = 1 / (fftSize * fftSize); - reportProgress(onProgress, "spectrum", 0, "Preparing FFT..."); const windowIndices: number[] = []; for (let windowIndex = 0; windowIndex < numWindows; windowIndex += frameStride) { @@ -663,19 +569,16 @@ export async function analyzeSpectrumFromSamples( 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)); - for (let j = 0; j < copyLen; j++) { real[j] = samples[start + j] * windowCoeffs[j]; imag[j] = 0; @@ -684,26 +587,21 @@ export async function analyzeSpectrumFromSamples( real[j] = 0; imag[j] = 0; } - fftInPlace(real, imag, bitReversal); - 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; } - timeSlices[i] = { 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) { @@ -713,7 +611,6 @@ export async function analyzeSpectrumFromSamples( } } } - reportProgress(onProgress, "spectrum", 100, "Spectrum analysis complete"); return { time_slices: timeSlices, @@ -723,63 +620,44 @@ export async function analyzeSpectrumFromSamples( max_freq: maxFreq, }; } - function createAnalysisAudioContext(sampleRate: number): AudioContext { if (sampleRate > 0) { try { return new AudioContext({ sampleRate }); - } catch { + } + catch { return new AudioContext(); } } return new AudioContext(); } - -export async function analyzeAudioFile( - file: File, - params: SpectrumParams = DEFAULT_PARAMS, - onProgress?: AnalysisProgressCallback, - shouldCancel?: AnalysisCancelCheck, -): Promise { +export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise { throwIfCancelled(shouldCancel); reportProgress(onProgress, "read", 2, "Reading file..."); const arrayBuffer = await file.arrayBuffer(); throwIfCancelled(shouldCancel); reportProgress(onProgress, "read", 10, "File loaded"); - return analyzeAudioArrayBuffer( - { - fileName: file.name, - fileSize: file.size, - arrayBuffer, - }, - params, - (progress) => { - const mappedPercent = 10 + (progress.percent * 0.9); - reportProgress(onProgress, progress.phase, mappedPercent, progress.message); - }, - shouldCancel, - ); + return analyzeAudioArrayBuffer({ + fileName: file.name, + fileSize: file.size, + arrayBuffer, + }, params, (progress) => { + const mappedPercent = 10 + (progress.percent * 0.9); + reportProgress(onProgress, progress.phase, mappedPercent, progress.message); + }, shouldCancel); } - -export async function analyzeAudioArrayBuffer( - input: AudioArrayBufferInput, - params: SpectrumParams = DEFAULT_PARAMS, - onProgress?: AnalysisProgressCallback, - shouldCancel?: AnalysisCancelCheck, -): Promise { +export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise { throwIfCancelled(shouldCancel); reportProgress(onProgress, "parse", 5, "Parsing audio metadata..."); const metadata = parseAudioMetadata(input); throwIfCancelled(shouldCancel); reportProgress(onProgress, "decode", 15, "Decoding audio stream..."); const audioContext = createAnalysisAudioContext(metadata.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; @@ -788,13 +666,12 @@ export async function analyzeAudioArrayBuffer( throwIfCancelled(shouldCancel); const sample = samples[i]; const absSample = Math.abs(sample); - if (absSample > peak) peak = absSample; + 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(); @@ -803,7 +680,6 @@ export async function analyzeAudioArrayBuffer( } } } - 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; @@ -812,21 +688,12 @@ export async function analyzeAudioArrayBuffer( 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, - ); - + 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, @@ -849,13 +716,12 @@ export async function analyzeAudioArrayBuffer( }, samples, }; - reportProgress(onProgress, "finalize", 100, "Analysis complete"); return payload; - } finally { + } + finally { await audioContext.close(); } } - export const analyzeFlacFile = analyzeAudioFile; export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;