This commit is contained in:
afkarxyz
2026-03-25 20:53:26 +07:00
parent 5ebd28982b
commit e3f8f7be0a
13 changed files with 386 additions and 858 deletions
+2 -5
View File
@@ -10,14 +10,12 @@ import (
"sync" "sync"
) )
// FlacInfo holds basic audio properties of a FLAC file.
type FlacInfo struct { type FlacInfo struct {
Path string `json:"path"` Path string `json:"path"`
SampleRate uint32 `json:"sample_rate"` // e.g. 44100 SampleRate uint32 `json:"sample_rate"`
BitsPerSample uint8 `json:"bits_per_sample"` // e.g. 16, 24 BitsPerSample uint8 `json:"bits_per_sample"`
} }
// GetFlacInfoBatch reads sample rate and bit depth for multiple files in parallel.
func GetFlacInfoBatch(paths []string) []FlacInfo { func GetFlacInfoBatch(paths []string) []FlacInfo {
results := make([]FlacInfo, len(paths)) results := make([]FlacInfo, len(paths))
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -170,7 +168,6 @@ func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) {
return return
} }
// Output is always FLAC (lossless resampling).
outputFile := filepath.Join(outputDir, baseName+".flac") outputFile := filepath.Join(outputDir, baseName+".flac")
result.OutputFile = outputFile result.OutputFile = outputFile
+10 -27
View File
@@ -36,13 +36,14 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { useDownloadProgress } from "@/hooks/useDownloadProgress";
const HISTORY_KEY = "spotiflac_fetch_history"; const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5; const MAX_HISTORY = 5;
function extractSpotifyEntityFromURL(url: string): {
function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } | null { type: string;
id: string;
} | null {
const trimmed = url.trim(); const trimmed = url.trim();
if (!trimmed) { if (!trimmed) {
return null; return null;
} }
const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i); const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i);
if (spotifyUriMatch) { if (spotifyUriMatch) {
return { return {
@@ -50,7 +51,6 @@ function extractSpotifyEntityFromURL(url: string): { type: string; id: string; }
id: spotifyUriMatch[2], id: spotifyUriMatch[2],
}; };
} }
try { try {
const parsed = new URL(trimmed); const parsed = new URL(trimmed);
const segments = parsed.pathname.split("/").filter(Boolean); const segments = parsed.pathname.split("/").filter(Boolean);
@@ -60,7 +60,6 @@ function extractSpotifyEntityFromURL(url: string): { type: string; id: string; }
if (!supportedTypes.has(segment)) { if (!supportedTypes.has(segment)) {
continue; continue;
} }
const id = segments[i + 1]; const id = segments[i + 1];
if (id) { if (id) {
return { type: segment, id }; return { type: segment, id };
@@ -69,15 +68,12 @@ function extractSpotifyEntityFromURL(url: string): { type: string; id: string; }
} }
catch { catch {
} }
return null; return null;
} }
function normalizeHistoryURL(url: string): string { function normalizeHistoryURL(url: string): string {
const trimmed = url.trim(); const trimmed = url.trim();
if (!trimmed) if (!trimmed)
return trimmed; return trimmed;
const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, ""); const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, "");
const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery); const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery);
if (spotifyEntity) { if (spotifyEntity) {
@@ -85,17 +81,14 @@ function normalizeHistoryURL(url: string): string {
} }
return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1"); return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1");
} }
function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string { function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string {
const normalizedUrl = normalizeHistoryURL(url); const normalizedUrl = normalizeHistoryURL(url);
const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl); const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl);
if (spotifyEntity) { if (spotifyEntity) {
return `${type}:${spotifyEntity.id}`; return `${type}:${spotifyEntity.id}`;
} }
return `${type}:${normalizedUrl}`; return `${type}:${normalizedUrl}`;
} }
function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] {
const seen = new Set<string>(); const seen = new Set<string>();
const deduped: HistoryItem[] = []; const deduped: HistoryItem[] = [];
@@ -109,7 +102,6 @@ function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] {
} }
return deduped; return deduped;
} }
function App() { function App() {
const [currentPage, setCurrentPage] = useState<PageType>("main"); const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState(""); const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -623,17 +615,13 @@ function App() {
FFmpeg Required FFmpeg Required
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal"> <DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
{brewPath ? ( {brewPath ? (<>
<>
FFmpeg is essential for SpotiFLAC to function properly. FFmpeg is essential for SpotiFLAC to function properly.
Homebrew detected. Recommended: <span className="text-foreground font-semibold">brew install ffmpeg</span> Homebrew detected. Recommended: <span className="text-foreground font-semibold">brew install ffmpeg</span>
</> </>) : (<>
) : (
<>
FFmpeg is essential for SpotiFLAC to function properly. FFmpeg is essential for SpotiFLAC to function properly.
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data. This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
</> </>)}
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -665,16 +653,11 @@ function App() {
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}> {!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
Exit Exit
</Button>)} </Button>)}
{brewPath ? ( {brewPath ? (<Button className="flex-1 h-11 text-sm font-bold shadow-lg shadow-primary/10" onClick={() => handleInstallFFmpeg(true)} disabled={isInstallingFFmpeg}>
<Button className="flex-1 h-11 text-sm font-bold shadow-lg shadow-primary/10" onClick={() => handleInstallFFmpeg(true)} disabled={isInstallingFFmpeg}>
{isInstallingFFmpeg ? "Installing..." : "Install via Homebrew"} {isInstallingFFmpeg ? "Installing..." : "Install via Homebrew"}
</Button> </Button>) : (<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={() => handleInstallFFmpeg(false)} disabled={isInstallingFFmpeg}>
) : (
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={() => handleInstallFFmpeg(false)} disabled={isInstallingFFmpeg}>
{isInstallingFFmpeg ? "Installing..." : "Install now"} {isInstallingFFmpeg ? "Installing..." : "Install now"}
</Button> </Button>)}
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+3 -4
View File
@@ -15,7 +15,6 @@ import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg"; import KofiSvg from "@/assets/kofi_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg"; import UsdtBarcode from "@/assets/usdt.jpg";
import { langColors } from "@/assets/github-lang-colors"; import { langColors } from "@/assets/github-lang-colors";
export function AboutPage() { export function AboutPage() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects"); const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
const [repoStats, setRepoStats] = useState<Record<string, any>>({}); const [repoStats, setRepoStats] = useState<Record<string, any>>({});
@@ -248,9 +247,9 @@ export function AboutPage() {
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2"> {repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs"> {repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ {repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20", backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang), color: getLangColor(lang),
}}> }}>
{lang} {lang}
</span>))} </span>))}
</div>)} </div>)}
+35 -72
View File
@@ -3,7 +3,6 @@ import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Activity } from "lucide-react"; import { Activity } from "lucide-react";
import type { AnalysisResult } from "@/types/api"; import type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps { interface AudioAnalysisProps {
result: AnalysisResult | null; result: AnalysisResult | null;
analyzing: boolean; analyzing: boolean;
@@ -11,83 +10,65 @@ interface AudioAnalysisProps {
showAnalyzeButton?: boolean; showAnalyzeButton?: boolean;
filePath?: string; filePath?: string;
} }
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) { export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
if (analyzing) { if (analyzing) {
return ( return (<Card>
<Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex items-center justify-center py-8 gap-3"> <div className="flex items-center justify-center py-8 gap-3">
<Spinner /> <Spinner />
<span className="text-muted-foreground">Analyzing audio quality...</span> <span className="text-muted-foreground">Analyzing audio quality...</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>);
);
} }
if (!result && showAnalyzeButton) { if (!result && showAnalyzeButton) {
return ( return (<Card>
<Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex flex-col items-center justify-center py-8 gap-4"> <div className="flex flex-col items-center justify-center py-8 gap-4">
<Activity className="h-12 w-12 text-primary" /> <Activity className="h-12 w-12 text-primary"/>
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<p className="font-medium">Audio Quality Analysis</p> <p className="font-medium">Audio Quality Analysis</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files
</p> </p>
</div> </div>
{onAnalyze && ( {onAnalyze && (<Button onClick={onAnalyze}>
<Button onClick={onAnalyze}> <Activity className="h-4 w-4"/>
<Activity className="h-4 w-4" />
Analyze Audio Analyze Audio
</Button> </Button>)}
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>);
);
} }
if (!result) { if (!result) {
return null; return null;
} }
const formatDuration = (seconds: number) => { const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`; return `${mins}:${secs.toString().padStart(2, "0")}`;
}; };
const formatNumber = (num: number) => { const formatNumber = (num: number) => {
return num.toFixed(2); return num.toFixed(2);
}; };
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B"; if (bytes === 0)
return "0 B";
const k = 1024; const k = 1024;
const sizes = ["B", "KB", "MB", "GB"]; const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}; };
const nyquistFreq = result.sample_rate / 2; const nyquistFreq = result.sample_rate / 2;
const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A"; const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A";
const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:"; const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:";
const hasCodecMeta = result.file_type === "MP3" && ( const hasCodecMeta = result.file_type === "MP3" && (Boolean(result.codec_mode) ||
Boolean(result.codec_mode) ||
typeof result.bitrate_kbps === "number" || typeof result.bitrate_kbps === "number" ||
typeof result.total_frames === "number" || typeof result.total_frames === "number" ||
Boolean(result.codec_version) Boolean(result.codec_version));
); return (<Card className="gap-2">
return (
<Card className="gap-2">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
{filePath && ( {filePath && (<p className="text-sm font-mono break-all text-muted-foreground">{filePath}</p>)}
<p className="text-sm font-mono break-all text-muted-foreground">{filePath}</p>
)}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -95,12 +76,10 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p> <p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{result.file_type && ( {result.file_type && (<li className="flex justify-between">
<li className="flex justify-between">
<span className="text-muted-foreground">Type:</span> <span className="text-muted-foreground">Type:</span>
<span className="font-medium font-mono">{result.file_type}</span> <span className="font-medium font-mono">{result.file_type}</span>
</li> </li>)}
)}
<li className="flex justify-between"> <li className="flex justify-between">
<span className="text-muted-foreground">Sample Rate:</span> <span className="text-muted-foreground">Sample Rate:</span>
<span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span> <span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
@@ -117,12 +96,10 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
<span className="text-muted-foreground">Duration:</span> <span className="text-muted-foreground">Duration:</span>
<span className="font-medium font-mono">{formatDuration(result.duration)}</span> <span className="font-medium font-mono">{formatDuration(result.duration)}</span>
</li> </li>
{result.file_size > 0 && ( {result.file_size > 0 && (<li className="flex justify-between">
<li className="flex justify-between">
<span className="text-muted-foreground">Size:</span> <span className="text-muted-foreground">Size:</span>
<span className="font-medium font-mono">{formatFileSize(result.file_size)}</span> <span className="font-medium font-mono">{formatFileSize(result.file_size)}</span>
</li> </li>)}
)}
</ul> </ul>
</div> </div>
@@ -152,45 +129,33 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
</ul> </ul>
</div> </div>
{hasCodecMeta && ( {hasCodecMeta && (<div className="space-y-2">
<div className="space-y-2">
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">MP3 Meta</p> <p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">MP3 Meta</p>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{result.codec_mode && ( {result.codec_mode && (<li className="flex justify-between">
<li className="flex justify-between">
<span className="text-muted-foreground">Mode:</span> <span className="text-muted-foreground">Mode:</span>
<span className="font-medium font-mono">{result.codec_mode}</span> <span className="font-medium font-mono">{result.codec_mode}</span>
</li> </li>)}
)} {typeof result.bitrate_kbps === "number" && (<li className="flex justify-between">
{typeof result.bitrate_kbps === "number" && (
<li className="flex justify-between">
<span className="text-muted-foreground">Bitrate:</span> <span className="text-muted-foreground">Bitrate:</span>
<span className="font-medium font-mono">{result.bitrate_kbps} kbps</span> <span className="font-medium font-mono">{result.bitrate_kbps} kbps</span>
</li> </li>)}
)} {typeof result.total_frames === "number" && result.total_frames > 0 && (<li className="flex justify-between">
{typeof result.total_frames === "number" && result.total_frames > 0 && (
<li className="flex justify-between">
<span className="text-muted-foreground">Frames:</span> <span className="text-muted-foreground">Frames:</span>
<span className="font-medium font-mono">{result.total_frames.toLocaleString()}</span> <span className="font-medium font-mono">{result.total_frames.toLocaleString()}</span>
</li> </li>)}
)} {result.codec_version && (<li className="flex justify-between">
{result.codec_version && (
<li className="flex justify-between">
<span className="text-muted-foreground">Version:</span> <span className="text-muted-foreground">Version:</span>
<span className="font-medium font-mono">{result.codec_version}</span> <span className="font-medium font-mono">{result.codec_version}</span>
</li> </li>)}
)}
</ul> </ul>
</div> </div>)}
)}
{result.spectrum && (() => { {result.spectrum && (() => {
const frames = result.spectrum.time_slices.length; const frames = result.spectrum.time_slices.length;
const fftSize = (result.spectrum.freq_bins - 1) * 2; const fftSize = (result.spectrum.freq_bins - 1) * 2;
const freqRes = result.sample_rate / fftSize; const freqRes = result.sample_rate / fftSize;
return (<div className="space-y-2">
return (
<div className="space-y-2">
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p> <p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
<li className="flex justify-between"> <li className="flex justify-between">
@@ -206,11 +171,9 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
<span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span> <span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span>
</li> </li>
</ul> </ul>
</div> </div>);
); })()}
})()}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>);
);
} }
+52 -123
View File
@@ -8,11 +8,9 @@ import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App"; import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps { interface AudioAnalysisPageProps {
onBack?: () => void; onBack?: () => void;
} }
const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
const SUPPORTED_AUDIO_ACCEPT = [ const SUPPORTED_AUDIO_ACCEPT = [
".flac", ".flac",
@@ -29,17 +27,14 @@ const SUPPORTED_AUDIO_ACCEPT = [
"audio/aacp", "audio/aacp",
].join(","); ].join(",");
const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC"; const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC";
function isSupportedAudioPath(filePath: string): boolean { function isSupportedAudioPath(filePath: string): boolean {
const normalized = filePath.toLowerCase(); const normalized = filePath.toLowerCase();
return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext)); return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext));
} }
function isSupportedAudioFile(file: File): boolean { function isSupportedAudioFile(file: File): boolean {
const normalizedName = file.name.toLowerCase(); const normalizedName = file.name.toLowerCase();
const normalizedType = file.type.toLowerCase(); const normalizedType = file.type.toLowerCase();
return ( return (SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) ||
SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) ||
normalizedType === "audio/flac" || normalizedType === "audio/flac" ||
normalizedType === "audio/x-flac" || normalizedType === "audio/x-flac" ||
normalizedType === "audio/mpeg" || normalizedType === "audio/mpeg" ||
@@ -47,38 +42,23 @@ function isSupportedAudioFile(file: File): boolean {
normalizedType === "audio/mp4" || normalizedType === "audio/mp4" ||
normalizedType === "audio/x-m4a" || normalizedType === "audio/x-m4a" ||
normalizedType === "audio/aac" || normalizedType === "audio/aac" ||
normalizedType === "audio/aacp" normalizedType === "audio/aacp");
);
} }
function isAbsolutePath(filePath: string): boolean { function isAbsolutePath(filePath: string): boolean {
return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath); return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath);
} }
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;
} }
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis();
analyzing,
analysisProgress,
result,
analyzeFile,
analyzeFilePath,
clearResult,
selectedFilePath,
spectrumLoading,
spectrumProgress,
reAnalyzeSpectrum,
} = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null); const spectrumRef = useRef<{
getCanvasDataURL: () => string | null;
}>(null);
const analyzeSelectedPath = useCallback(async (filePath: string) => { const analyzeSelectedPath = useCallback(async (filePath: string) => {
if (!isSupportedAudioPath(filePath)) { if (!isSupportedAudioPath(filePath)) {
toast.error("Invalid File Type", { toast.error("Invalid File Type", {
@@ -88,7 +68,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
} }
await analyzeFilePath(filePath); await analyzeFilePath(filePath);
}, [analyzeFilePath]); }, [analyzeFilePath]);
const analyzeSelectedFile = useCallback(async (file: File) => { const analyzeSelectedFile = useCallback(async (file: File) => {
if (!isSupportedAudioFile(file)) { if (!isSupportedAudioFile(file)) {
toast.error("Invalid File Type", { toast.error("Invalid File Type", {
@@ -98,7 +77,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
} }
await analyzeFile(file); await analyzeFile(file);
}, [analyzeFile]); }, [analyzeFile]);
const handleSelectFile = useCallback(async () => { const handleSelectFile = useCallback(async () => {
try { try {
const filePath = await SelectFile(); const filePath = await SelectFile();
@@ -106,48 +84,46 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
return; return;
} }
await analyzeSelectedPath(filePath); await analyzeSelectedPath(filePath);
} catch { }
catch {
fileInputRef.current?.click(); fileInputRef.current?.click();
} }
}, [analyzeSelectedPath]); }, [analyzeSelectedPath]);
const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => { const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file)
return;
await analyzeSelectedFile(file); await analyzeSelectedFile(file);
e.target.value = ""; e.target.value = "";
}, [analyzeSelectedFile]); }, [analyzeSelectedFile]);
const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => { const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
const file = e.dataTransfer.files?.[0]; const file = e.dataTransfer.files?.[0];
if (!file) return; if (!file)
return;
await analyzeSelectedFile(file); await analyzeSelectedFile(file);
}, [analyzeSelectedFile]); }, [analyzeSelectedFile]);
useEffect(() => { useEffect(() => {
OnFileDrop((_x, _y, paths) => { OnFileDrop((_x, _y, paths) => {
setIsDragging(false); setIsDragging(false);
const droppedPath = paths?.[0]; const droppedPath = paths?.[0];
if (!droppedPath) return; if (!droppedPath)
return;
void analyzeSelectedPath(droppedPath); void analyzeSelectedPath(droppedPath);
}, true); }, true);
return () => { return () => {
OnFileDropOff(); OnFileDropOff();
}; };
}, [analyzeSelectedPath]); }, [analyzeSelectedPath]);
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
if (!spectrumRef.current) return; if (!spectrumRef.current)
return;
const dataUrl = spectrumRef.current.getCanvasDataURL(); const dataUrl = spectrumRef.current.getCanvasDataURL();
if (!dataUrl) { if (!dataUrl) {
toast.error("Export Failed", { description: "Cannot get canvas data" }); toast.error("Export Failed", { description: "Cannot get canvas data" });
return; return;
} }
setIsExporting(true); setIsExporting(true);
try { try {
if (selectedFilePath && isAbsolutePath(selectedFilePath)) { if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
@@ -157,7 +133,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
}); });
return; return;
} }
const base = selectedFilePath const base = selectedFilePath
? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "") ? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "")
: "spectrogram"; : "spectrogram";
@@ -170,128 +145,82 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
toast.success("Exported Successfully", { toast.success("Exported Successfully", {
description: "Spectrogram image downloaded", description: "Spectrogram image downloaded",
}); });
} catch (err) { }
catch (err) {
toast.error("Export Failed", { toast.error("Export Failed", {
description: err instanceof Error ? err.message : "Failed to export image", description: err instanceof Error ? err.message : "Failed to export image",
}); });
} finally { }
finally {
setIsExporting(false); setIsExporting(false);
} }
}, [selectedFilePath]); }, [selectedFilePath]);
const handleAnalyzeAnother = () => { const handleAnalyzeAnother = () => {
clearResult(); clearResult();
}; };
const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined; const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
return (<div className="space-y-6">
return ( <input ref={fileInputRef} type="file" accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
<div className="space-y-6">
<input
ref={fileInputRef}
type="file"
accept={SUPPORTED_AUDIO_ACCEPT}
className="hidden"
onChange={handleInputChange}
/>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{onBack && ( {onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
<Button variant="ghost" size="icon" onClick={onBack}> <ArrowLeft className="h-5 w-5"/>
<ArrowLeft className="h-5 w-5" /> </Button>)}
</Button>
)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1> <h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div> </div>
{result && ( {result && (<div className="flex gap-2">
<div className="flex gap-2"> <Button onClick={handleExport} variant="outline" size="sm" disabled={isExporting || spectrumLoading}>
<Button <Download className="h-4 w-4 mr-1"/>
onClick={handleExport}
variant="outline"
size="sm"
disabled={isExporting || spectrumLoading}
>
<Download className="h-4 w-4 mr-1" />
{isExporting ? "Exporting..." : "Export PNG"} {isExporting ? "Exporting..." : "Export PNG"}
</Button> </Button>
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm"> <Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
<Trash2 className="h-4 w-4 mr-1" /> <Trash2 className="h-4 w-4 mr-1"/>
Clear Clear
</Button> </Button>
</div> </div>)}
)}
</div> </div>
{!result && !analyzing && ( {!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
<div ? "border-primary bg-primary/10"
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${ : "border-muted-foreground/30"}`} onDragOver={(e) => {
isDragging e.preventDefault();
? "border-primary bg-primary/10" setIsDragging(true);
: "border-muted-foreground/30" }} onDragLeave={(e) => {
}`} e.preventDefault();
onDragOver={(e) => { setIsDragging(false);
e.preventDefault(); }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={handleHtmlDrop}
style={{ "--wails-drop-target": "drop" } as CSSProperties}
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary" /> <Upload className="h-8 w-8 text-primary"/>
</div> </div>
<p className="text-sm text-muted-foreground mb-4 text-center"> <p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging {isDragging
? "Drop your audio file here" ? "Drop your audio file here"
: "Drag and drop an audio file here, or click the button below to select"} : "Drag and drop an audio file here, or click the button below to select"}
</p> </p>
<Button onClick={handleSelectFile} size="lg"> <Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" /> <Upload className="h-5 w-5"/>
Select Audio File Select Audio File
</Button> </Button>
<p className="text-xs text-muted-foreground mt-4 text-center"> <p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3, M4A, AAC Supported formats: FLAC, MP3, M4A, AAC
</p> </p>
</div> </div>)}
)}
{analyzing && !result && ( {analyzing && !result && (<div className="flex h-[400px] items-center justify-center">
<div className="flex h-[400px] items-center justify-center">
<div className="w-full max-w-md space-y-2"> <div className="w-full max-w-md space-y-2">
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Processing...</span> <span>Processing...</span>
<span className="tabular-nums">{analysisProgress.percent}%</span> <span className="tabular-nums">{analysisProgress.percent}%</span>
</div> </div>
<Progress value={analysisProgress.percent} className="h-2 w-full" /> <Progress value={analysisProgress.percent} className="h-2 w-full"/>
</div> </div>
</div> </div>)}
)}
{result && ( {result && (<div className="space-y-4">
<div className="space-y-4"> <AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
<AudioAnalysis
result={result}
analyzing={analyzing}
showAnalyzeButton={false}
filePath={selectedFilePath}
/>
<SpectrumVisualization <SpectrumVisualization ref={spectrumRef} sampleRate={result.sample_rate} duration={result.duration} spectrumData={result.spectrum} fileName={fileName} onReAnalyze={reAnalyzeSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
ref={spectrumRef} </div>)}
sampleRate={result.sample_rate} </div>);
duration={result.duration}
spectrumData={result.spectrum}
fileName={fileName}
onReAnalyze={reAnalyzeSpectrum}
isAnalyzingSpectrum={spectrumLoading}
spectrumProgress={spectrumProgress}
/>
</div>
)}
</div>
);
} }
+19 -23
View File
@@ -8,7 +8,6 @@ import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } fr
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
import { AudioLinesIcon } from "@/components/ui/audio-lines"; import { AudioLinesIcon } from "@/components/ui/audio-lines";
interface AudioFile { interface AudioFile {
path: string; path: string;
name: string; name: string;
@@ -20,7 +19,6 @@ interface AudioFile {
srcSampleRate?: number; srcSampleRate?: number;
srcBitDepth?: number; srcBitDepth?: number;
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
if (bytes === 0) if (bytes === 0)
return "0 B"; return "0 B";
@@ -29,7 +27,6 @@ function formatFileSize(bytes: number): string {
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
} }
function formatSampleRate(sr: number): string { function formatSampleRate(sr: number): string {
if (!sr) if (!sr)
return ""; return "";
@@ -39,21 +36,17 @@ function formatSampleRate(sr: number): string {
return `${sr / 1000}kHz`; return `${sr / 1000}kHz`;
return `${sr}Hz`; return `${sr}Hz`;
} }
const SAMPLE_RATE_OPTIONS = [ const SAMPLE_RATE_OPTIONS = [
{ value: "44100", label: "44.1kHz" }, { value: "44100", label: "44.1kHz" },
{ value: "48000", label: "48kHz" }, { value: "48000", label: "48kHz" },
{ value: "96000", label: "96kHz" }, { value: "96000", label: "96kHz" },
{ value: "192000", label: "192kHz" }, { value: "192000", label: "192kHz" },
]; ];
const BIT_DEPTH_OPTIONS = [ const BIT_DEPTH_OPTIONS = [
{ value: "16", label: "16-bit" }, { value: "16", label: "16-bit" },
{ value: "24", label: "24-bit" }, { value: "24", label: "24-bit" },
]; ];
const STORAGE_KEY = "spotiflac_audio_resampler_state"; const STORAGE_KEY = "spotiflac_audio_resampler_state";
export function AudioResamplerPage() { export function AudioResamplerPage() {
const [files, setFiles] = useState<AudioFile[]>(() => { const [files, setFiles] = useState<AudioFile[]>(() => {
try { try {
@@ -132,8 +125,11 @@ export function AudioResamplerPage() {
return; return;
try { try {
const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"]; const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"];
const infos: Array<{ path: string; sample_rate: number; bits_per_sample: number; }> = const infos: Array<{
await GetFlacInfoBatch(paths); path: string;
sample_rate: number;
bits_per_sample: number;
}> = await GetFlacInfoBatch(paths);
setFiles((prev) => prev.map((f) => { setFiles((prev) => prev.map((f) => {
const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase()); const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase());
if (info) { if (info) {
@@ -389,9 +385,9 @@ export function AudioResamplerPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bit Depth:</Label> <Label className="whitespace-nowrap">Bit Depth:</Label>
<ToggleGroup type="single" variant="outline" value={bitDepth} onValueChange={(value) => { <ToggleGroup type="single" variant="outline" value={bitDepth} onValueChange={(value) => {
if (value) if (value)
setBitDepth(value); setBitDepth(value);
}}> }}>
{BIT_DEPTH_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}> {BIT_DEPTH_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label} {option.label}
</ToggleGroupItem>))} </ToggleGroupItem>))}
@@ -401,9 +397,9 @@ export function AudioResamplerPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Sample Rate:</Label> <Label className="whitespace-nowrap">Sample Rate:</Label>
<ToggleGroup type="single" variant="outline" value={sampleRate} onValueChange={(value) => { <ToggleGroup type="single" variant="outline" value={sampleRate} onValueChange={(value) => {
if (value) if (value)
setSampleRate(value); setSampleRate(value);
}}> }}>
{SAMPLE_RATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}> {SAMPLE_RATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label} {option.label}
</ToggleGroupItem>))} </ToggleGroupItem>))}
@@ -420,13 +416,13 @@ export function AudioResamplerPage() {
<div className="flex-1 space-y-2 overflow-y-auto min-h-0"> <div className="flex-1 space-y-2 overflow-y-auto min-h-0">
{files.map((file) => { {files.map((file) => {
const srcParts: string[] = []; const srcParts: string[] = [];
if (file.srcBitDepth) if (file.srcBitDepth)
srcParts.push(`${file.srcBitDepth}-bit`); srcParts.push(`${file.srcBitDepth}-bit`);
if (file.srcSampleRate) if (file.srcSampleRate)
srcParts.push(formatSampleRate(file.srcSampleRate)); srcParts.push(formatSampleRate(file.srcSampleRate));
const srcSpec = srcParts.join(" / "); const srcSpec = srcParts.join(" / ");
return (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3"> return (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
{getStatusIcon(file.status)} {getStatusIcon(file.status)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p> <p className="truncate text-sm font-medium">{file.name}</p>
@@ -451,7 +447,7 @@ export function AudioResamplerPage() {
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
</Button>)} </Button>)}
</div>); </div>);
})} })}
</div> </div>
<div className="flex justify-center pt-4 border-t shrink-0"> <div className="flex justify-center pt-4 border-t shrink-0">
+8 -6
View File
@@ -130,12 +130,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<div className="flex items-center justify-between shrink-0"> <div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={async () => { try { <Button variant="outline" onClick={async () => {
await OpenConfigFolder(); try {
} await OpenConfigFolder();
catch (e) { }
toast.error(`Failed to open config folder: ${e}`); catch (e) {
} }} className="gap-1.5"> toast.error(`Failed to open config folder: ${e}`);
}
}} className="gap-1.5">
<FolderLock className="h-4 w-4"/> <FolderLock className="h-4 w-4"/>
Open Config Folder Open Config Folder
</Button> </Button>
-3
View File
@@ -17,18 +17,15 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
interface SidebarProps { interface SidebarProps {
currentPage: PageType; currentPage: PageType;
onPageChange: (page: PageType) => void; onPageChange: (page: PageType) => void;
} }
interface AnimatedIconHandle { interface AnimatedIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
export function Sidebar({ currentPage, onPageChange }: SidebarProps) { export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false); const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false);
const [hasIssueAgreement, setHasIssueAgreement] = useState(false); const [hasIssueAgreement, setHasIssueAgreement] = useState(false);
+82 -180
View File
@@ -2,25 +2,11 @@ import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "re
import type { SpectrumData } from "@/types/api"; import type { SpectrumData } from "@/types/api";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { import { loadAudioAnalysisPreferences, saveAudioAnalysisPreferences, type AnalyzerColorScheme, type AnalyzerFreqScale, type AnalyzerWindowFunction, } from "@/lib/audio-analysis-preferences";
loadAudioAnalysisPreferences, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
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 { export interface SpectrumVisualizationHandle {
getCanvasDataURL: () => string | null; getCanvasDataURL: () => string | null;
} }
interface SpectrumVisualizationProps { interface SpectrumVisualizationProps {
sampleRate: number; sampleRate: number;
duration: number; duration: number;
@@ -33,22 +19,26 @@ interface SpectrumVisualizationProps {
message: string; message: string;
}; };
} }
type ColorScheme = AnalyzerColorScheme; type ColorScheme = AnalyzerColorScheme;
type FreqScale = AnalyzerFreqScale; type FreqScale = AnalyzerFreqScale;
type WindowFunction = AnalyzerWindowFunction; 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;
const MAX_RENDER_HEIGHT = 1080; const MAX_RENDER_HEIGHT = 1080;
function clamp01(value: number): number { function clamp01(value: number): number {
return Math.max(0, Math.min(1, value)); return Math.max(0, Math.min(1, value));
} }
function spekColorMap(t: number): [
function spekColorMap(t: number): [number, number, number] { number,
const colors: Array<[number, number, number]> = [ number,
number
] {
const colors: Array<[
number,
number,
number
]> = [
[0, 0, 0], [0, 0, 0],
[0, 0, 25], [0, 0, 25],
[0, 0, 50], [0, 0, 50],
@@ -73,15 +63,12 @@ function spekColorMap(t: number): [number, number, number] {
[255, 255, 200], [255, 255, 200],
[255, 255, 255], [255, 255, 255],
]; ];
const scaled = t * (colors.length - 1); const scaled = t * (colors.length - 1);
const idx = Math.floor(scaled); const idx = Math.floor(scaled);
const fraction = scaled - idx; const fraction = scaled - idx;
if (idx >= colors.length - 1) { if (idx >= colors.length - 1) {
return colors[colors.length - 1]; return colors[colors.length - 1];
} }
const c1 = colors[idx]; const c1 = colors[idx];
const c2 = colors[idx + 1]; const c2 = colors[idx + 1];
return [ return [
@@ -90,9 +77,16 @@ function spekColorMap(t: number): [number, number, number] {
Math.round(c1[2] + (c2[2] - c1[2]) * fraction), Math.round(c1[2] + (c2[2] - c1[2]) * fraction),
]; ];
} }
function viridisColorMap(t: number): [
function viridisColorMap(t: number): [number, number, number] { number,
const colors: Array<[number, number, number]> = [ number,
number
] {
const colors: Array<[
number,
number,
number
]> = [
[68, 1, 84], [68, 1, 84],
[70, 20, 100], [70, 20, 100],
[72, 40, 120], [72, 40, 120],
@@ -113,15 +107,12 @@ function viridisColorMap(t: number): [number, number, number] {
[216, 227, 41], [216, 227, 41],
[253, 231, 37], [253, 231, 37],
]; ];
const scaled = t * (colors.length - 1); const scaled = t * (colors.length - 1);
const idx = Math.floor(scaled); const idx = Math.floor(scaled);
const fraction = scaled - idx; const fraction = scaled - idx;
if (idx >= colors.length - 1) { if (idx >= colors.length - 1) {
return colors[colors.length - 1]; return colors[colors.length - 1];
} }
const c1 = colors[idx]; const c1 = colors[idx];
const c2 = colors[idx + 1]; const c2 = colors[idx + 1];
return [ return [
@@ -130,8 +121,11 @@ function viridisColorMap(t: number): [number, number, number] {
Math.floor(c1[2] + (c2[2] - c1[2]) * fraction), Math.floor(c1[2] + (c2[2] - c1[2]) * fraction),
]; ];
} }
function hotColorMap(t: number): [
function hotColorMap(t: number): [number, number, number] { number,
number,
number
] {
if (t < 0.33) { if (t < 0.33) {
return [Math.floor(t * 3 * 255), 0, 0]; 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)]; return [255, 255, Math.floor((t - 0.66) * 3 * 255)];
} }
function coolColorMap(t: number): [
function coolColorMap(t: number): [number, number, number] { number,
number,
number
] {
return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255]; return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255];
} }
function getColorValues(norm: number, scheme: ColorScheme): [
function getColorValues(norm: number, scheme: ColorScheme): [number, number, number] { number,
number,
number
] {
const value = clamp01(norm); const value = clamp01(norm);
switch (scheme) { switch (scheme) {
case "spek": case "spek":
@@ -163,74 +163,61 @@ function getColorValues(norm: number, scheme: ColorScheme): [number, number, num
} }
} }
} }
function getColorString(norm: number, scheme: ColorScheme): string { function getColorString(norm: number, scheme: ColorScheme): string {
const [r, g, b] = getColorValues(norm, scheme); const [r, g, b] = getColorValues(norm, scheme);
return `rgb(${r},${g},${b})`; 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.fillStyle = "#ffffff";
ctx.font = "12px Segoe UI"; ctx.font = "12px Segoe UI";
ctx.textAlign = "center"; ctx.textAlign = "center";
const widthFactor = plotWidth / 1000; const widthFactor = plotWidth / 1000;
let timeStep: number; let timeStep: number;
if (duration <= 10) { if (duration <= 10) {
timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5); 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); 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); 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); timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20);
} else { }
else {
timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40); timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40);
} }
if (duration > 0) { if (duration > 0) {
for (let time = 0; time <= duration + 1e-9; time += timeStep) { for (let time = 0; time <= duration + 1e-9; time += timeStep) {
const timeProgress = time / duration; const timeProgress = time / duration;
const x = MARGIN.left + timeProgress * (plotWidth - 1); const x = MARGIN.left + timeProgress * (plotWidth - 1);
const y = CANVAS_H - MARGIN.bottom + 20; const y = CANVAS_H - MARGIN.bottom + 20;
ctx.strokeStyle = "#ffffff"; ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, MARGIN.top + plotHeight); ctx.moveTo(x, MARGIN.top + plotHeight);
ctx.lineTo(x, MARGIN.top + plotHeight + 5); ctx.lineTo(x, MARGIN.top + plotHeight + 5);
ctx.stroke(); ctx.stroke();
let label: string; let label: string;
if (timeStep >= 60) { if (timeStep >= 60) {
const minutes = Math.floor(time / 60); const minutes = Math.floor(time / 60);
const seconds = time % 60; const seconds = time % 60;
label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
} else { }
else {
label = `${time}s`; label = `${time}s`;
} }
ctx.fillText(label, x, y); ctx.fillText(label, x, y);
} }
} }
ctx.textAlign = "right"; ctx.textAlign = "right";
const maxFreq = sampleRate / 2; const maxFreq = sampleRate / 2;
if (freqScale === "log2") { if (freqScale === "log2") {
const heightFactor = plotHeight / 500; const heightFactor = plotHeight / 500;
const minFreq = 20; const minFreq = 20;
const frequencies: number[] = []; const frequencies: number[] = [];
const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2); const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2);
let octaveCount = 0; let octaveCount = 0;
for (let freq = minFreq; freq <= maxFreq; freq *= 2) { for (let freq = minFreq; freq <= maxFreq; freq *= 2) {
if (octaveCount % octaveStep === 0) { if (octaveCount % octaveStep === 0) {
@@ -238,134 +225,106 @@ function addAxisLabels(
} }
octaveCount++; octaveCount++;
} }
for (const freq of frequencies) { for (const freq of frequencies) {
const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq); const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq);
const y = MARGIN.top + plotHeight * (1 - freqNormalized); const y = MARGIN.top + plotHeight * (1 - freqNormalized);
ctx.strokeStyle = "#ffffff"; ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(MARGIN.left - 5, y); ctx.moveTo(MARGIN.left - 5, y);
ctx.lineTo(MARGIN.left, y); ctx.lineTo(MARGIN.left, y);
ctx.stroke(); ctx.stroke();
const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`; const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`;
ctx.fillText(label, MARGIN.left - 10, y + 4); ctx.fillText(label, MARGIN.left - 10, y + 4);
} }
} else { }
else {
const heightFactor = plotHeight / 500; const heightFactor = plotHeight / 500;
let freqStep: number; let freqStep: number;
if (maxFreq <= 8000) { if (maxFreq <= 8000) {
freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500); 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); 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); freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000);
} else { }
else {
freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000); freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000);
} }
for (let freq = 0; freq <= maxFreq; freq += freqStep) { for (let freq = 0; freq <= maxFreq; freq += freqStep) {
const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4; const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4;
const x = MARGIN.left - 15; const x = MARGIN.left - 15;
ctx.strokeStyle = "#ffffff"; ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(MARGIN.left - 5, y - 4); ctx.moveTo(MARGIN.left - 5, y - 4);
ctx.lineTo(MARGIN.left, y - 4); ctx.lineTo(MARGIN.left, y - 4);
ctx.stroke(); ctx.stroke();
let label: string; let label: string;
if (freq === 0) { if (freq === 0) {
label = "0"; label = "0";
} else if (freq >= 1000) { }
else if (freq >= 1000) {
label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`; label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`;
} else { }
else {
label = `${freq}`; label = `${freq}`;
} }
ctx.fillText(label, x, y); ctx.fillText(label, x, y);
} }
} }
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.font = "14px Segoe UI"; ctx.font = "14px Segoe UI";
ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15); ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15);
ctx.save(); ctx.save();
ctx.translate(25, CANVAS_H / 2); ctx.translate(25, CANVAS_H / 2);
ctx.rotate(-Math.PI / 2); ctx.rotate(-Math.PI / 2);
ctx.fillText("Frequency (Hz)", 0, 0); ctx.fillText("Frequency (Hz)", 0, 0);
ctx.restore(); ctx.restore();
ctx.font = "12px Segoe UI"; ctx.font = "12px Segoe UI";
if (fileName) { if (fileName) {
ctx.textAlign = "left"; ctx.textAlign = "left";
ctx.fillText(fileName, MARGIN.left + 15, 25); ctx.fillText(fileName, MARGIN.left + 15, 25);
} }
ctx.textAlign = "right"; ctx.textAlign = "right";
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25); 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 colorBarWidth = 20;
const colorBarX = CANVAS_W - MARGIN.right + 30; const colorBarX = CANVAS_W - MARGIN.right + 30;
const colorBarY = MARGIN.top; const colorBarY = MARGIN.top;
const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY); const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY);
for (let i = 0; i <= 100; i++) { for (let i = 0; i <= 100; i++) {
const value = i / 100; const value = i / 100;
gradient.addColorStop(value, getColorString(value, colorScheme)); gradient.addColorStop(value, getColorString(value, colorScheme));
} }
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight); ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
ctx.strokeStyle = "#ffffff"; ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight); ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.font = "10px Segoe UI"; ctx.font = "10px Segoe UI";
ctx.textAlign = "left"; ctx.textAlign = "left";
ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12); ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12);
ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5); 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 plotWidth = CANVAS_W - MARGIN.left - MARGIN.right;
const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom; const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom;
ctx.fillStyle = "#000000"; ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
const spectrogramData = spectrum.time_slices; const spectrogramData = spectrum.time_slices;
const numTimeFrames = spectrogramData.length; const numTimeFrames = spectrogramData.length;
const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0; const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0;
if (numTimeFrames === 0 || numFreqBins === 0) { if (numTimeFrames === 0 || numFreqBins === 0) {
return; return;
} }
let minMag = Number.POSITIVE_INFINITY; let minMag = Number.POSITIVE_INFINITY;
let maxMag = Number.NEGATIVE_INFINITY; let maxMag = Number.NEGATIVE_INFINITY;
const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1; const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1;
for (let i = 0; i < numTimeFrames; i += sampleStep) { for (let i = 0; i < numTimeFrames; i += sampleStep) {
const frame = spectrogramData[i].magnitudes; const frame = spectrogramData[i].magnitudes;
for (const mag of frame) { for (const mag of frame) {
@@ -377,24 +336,19 @@ async function renderSpectrogram(
} }
} }
} }
if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) { if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) {
minMag = -120; minMag = -120;
maxMag = 0; maxMag = 0;
} }
const magRange = maxMag - minMag; const magRange = maxMag - minMag;
const safeMagRange = magRange > 0 ? magRange : 1; const safeMagRange = magRange > 0 ? magRange : 1;
const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT); const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT);
const highResData = highResImageData.data; const highResData = highResImageData.data;
const CHUNK_SIZE = 50; const CHUNK_SIZE = 50;
for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) { for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) {
if (shouldCancel()) { if (shouldCancel()) {
return; return;
} }
const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth); const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth);
for (let x = xStart; x < xEnd; x++) { for (let x = xStart; x < xEnd; x++) {
const timeProgress = x / (plotWidth - 1); const timeProgress = x / (plotWidth - 1);
@@ -402,13 +356,10 @@ async function renderSpectrogram(
const timeIdx = Math.floor(exactTimePos); const timeIdx = Math.floor(exactTimePos);
const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1); const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1);
const timeFrac = exactTimePos - timeIdx; const timeFrac = exactTimePos - timeIdx;
const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes; const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes;
const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1; const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1;
for (let y = 0; y < MAX_RENDER_HEIGHT; y++) { for (let y = 0; y < MAX_RENDER_HEIGHT; y++) {
let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1); let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1);
if (freqScale === "log2") { if (freqScale === "log2") {
const minFreq = 20; const minFreq = 20;
const maxFreq = sampleRate / 2; const maxFreq = sampleRate / 2;
@@ -417,26 +368,23 @@ async function renderSpectrogram(
const freq = minFreq * Math.pow(2, octave); const freq = minFreq * Math.pow(2, octave);
freqProgress = freq / maxFreq; freqProgress = freq / maxFreq;
} }
const exactFreqPos = freqProgress * (numFreqBins - 1); const exactFreqPos = freqProgress * (numFreqBins - 1);
const freqIdx = Math.floor(exactFreqPos); const freqIdx = Math.floor(exactFreqPos);
const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1); const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1);
const freqFrac = exactFreqPos - freqIdx; const freqFrac = exactFreqPos - freqIdx;
let magnitude: number; let magnitude: number;
if (timeFrac === 0 && freqFrac === 0) { if (timeFrac === 0 && freqFrac === 0) {
magnitude = frame1[freqIdx] ?? 0; magnitude = frame1[freqIdx] ?? 0;
} else { }
else {
const mag11 = frame1[freqIdx] ?? 0; const mag11 = frame1[freqIdx] ?? 0;
const mag12 = frame1[freqIdx2] ?? 0; const mag12 = frame1[freqIdx2] ?? 0;
const mag21 = frame2[freqIdx] ?? 0; const mag21 = frame2[freqIdx] ?? 0;
const mag22 = frame2[freqIdx2] ?? 0; const mag22 = frame2[freqIdx2] ?? 0;
const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac; const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac;
const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac; const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac;
magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac; magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac;
} }
const normalizedMag = clamp01((magnitude - minMag) / safeMagRange); const normalizedMag = clamp01((magnitude - minMag) / safeMagRange);
const [r, g, b] = getColorValues(normalizedMag, colorScheme); const [r, g, b] = getColorValues(normalizedMag, colorScheme);
const pixelIdx = (y * plotWidth + x) * 4; const pixelIdx = (y * plotWidth + x) * 4;
@@ -446,25 +394,20 @@ async function renderSpectrogram(
highResData[pixelIdx + 3] = 255; highResData[pixelIdx + 3] = 255;
} }
} }
if (xStart + CHUNK_SIZE < plotWidth) { if (xStart + CHUNK_SIZE < plotWidth) {
await new Promise((resolve) => setTimeout(resolve, 1)); await new Promise((resolve) => setTimeout(resolve, 1));
} }
} }
if (shouldCancel()) { if (shouldCancel()) {
return; return;
} }
const finalImageData = ctx.createImageData(plotWidth, plotHeight); const finalImageData = ctx.createImageData(plotWidth, plotHeight);
const finalData = finalImageData.data; const finalData = finalImageData.data;
for (let y = 0; y < plotHeight; y++) { for (let y = 0; y < plotHeight; y++) {
for (let x = 0; x < plotWidth; x++) { for (let x = 0; x < plotWidth; x++) {
const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT); const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT);
const highResIdx = (highResY * plotWidth + x) * 4; const highResIdx = (highResY * plotWidth + x) * 4;
const finalIdx = (y * plotWidth + x) * 4; const finalIdx = (y * plotWidth + x) * 4;
if (highResIdx < highResData.length) { if (highResIdx < highResData.length) {
finalData[finalIdx] = highResData[highResIdx]; finalData[finalIdx] = highResData[highResIdx];
finalData[finalIdx + 1] = highResData[highResIdx + 1]; finalData[finalIdx + 1] = highResData[highResIdx + 1];
@@ -473,32 +416,24 @@ async function renderSpectrogram(
} }
} }
} }
ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top); ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top);
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName); addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
drawColorBar(ctx, plotHeight, colorScheme); drawColorBar(ctx, plotHeight, colorScheme);
} }
const COLOR_SCHEMES: {
const COLOR_SCHEMES: { value: ColorScheme; label: string; gradient: string; }[] = [ value: ColorScheme;
label: string;
gradient: string;
}[] = [
{ value: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" }, { 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: "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: "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: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" },
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" }, { value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" },
]; ];
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => {
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({
sampleRate,
duration,
spectrumData,
fileName,
onReAnalyze,
isAnalyzingSpectrum,
spectrumProgress,
}, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const preferencesRef = useRef(loadAudioAnalysisPreferences()); const preferencesRef = useRef(loadAudioAnalysisPreferences());
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getCanvasDataURL: () => { getCanvasDataURL: () => {
if (!canvasRef.current) if (!canvasRef.current)
@@ -506,18 +441,15 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
return canvasRef.current.toDataURL("image/png"); return canvasRef.current.toDataURL("image/png");
}, },
})); }));
const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale); const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale);
const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme); const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme);
const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize)); const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize));
const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction); const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction);
useEffect(() => { useEffect(() => {
if (spectrumData?.freq_bins) { if (spectrumData?.freq_bins) {
setFftSize(String((spectrumData.freq_bins - 1) * 2)); setFftSize(String((spectrumData.freq_bins - 1) * 2));
} }
}, [spectrumData]); }, [spectrumData]);
useEffect(() => { useEffect(() => {
saveAudioAnalysisPreferences({ saveAudioAnalysisPreferences({
colorScheme, colorScheme,
@@ -526,7 +458,6 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
windowFunction, windowFunction,
}); });
}, [colorScheme, freqScale, fftSize, windowFunction]); }, [colorScheme, freqScale, fftSize, windowFunction]);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) if (!canvas)
@@ -534,22 +465,12 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) if (!ctx)
return; return;
let canceled = false; let canceled = false;
const shouldCancel = () => canceled; const shouldCancel = () => canceled;
if (spectrumData) { if (spectrumData) {
void renderSpectrogram( void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel);
ctx, }
spectrumData, else {
sampleRate,
duration,
freqScale,
colorScheme,
fileName,
shouldCancel,
);
} else {
ctx.fillStyle = "#000000"; ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
ctx.fillStyle = "#444444"; ctx.fillStyle = "#444444";
@@ -557,12 +478,10 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2); ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
} }
return () => { return () => {
canceled = true; canceled = true;
}; };
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]); }, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => { const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
setFftSize(newFftSize); setFftSize(newFftSize);
setWindowFunction(newWindowFunc as WindowFunction); setWindowFunction(newWindowFunc as WindowFunction);
@@ -570,11 +489,8 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
onReAnalyze(parseInt(newFftSize, 10), newWindowFunc); onReAnalyze(parseInt(newFftSize, 10), newWindowFunc);
} }
}; };
const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0))); const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0)));
return (<div className="space-y-4">
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1"> <div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-sm font-medium">Color Scheme:</Label> <Label className="whitespace-nowrap text-sm font-medium">Color Scheme:</Label>
@@ -583,17 +499,12 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{COLOR_SCHEMES.map((scheme) => ( {COLOR_SCHEMES.map((scheme) => (<SelectItem key={scheme.value} value={scheme.value}>
<SelectItem key={scheme.value} value={scheme.value}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div className="h-4 w-4 rounded-sm border opacity-90" style={{ backgroundImage: scheme.gradient }}/>
className="h-4 w-4 rounded-sm border opacity-90"
style={{ backgroundImage: scheme.gradient }}
/>
<span>{scheme.label}</span> <span>{scheme.label}</span>
</div> </div>
</SelectItem> </SelectItem>))}
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -645,25 +556,16 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
</div> </div>
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl"> <div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
{isAnalyzingSpectrum && ( {isAnalyzingSpectrum && (<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-xs space-y-2 px-4"> <div className="w-full max-w-xs space-y-2 px-4">
<div className="flex items-center justify-between text-sm text-foreground/90"> <div className="flex items-center justify-between text-sm text-foreground/90">
<span>Processing...</span> <span>Processing...</span>
<span className="tabular-nums">{spectrumPercent}%</span> <span className="tabular-nums">{spectrumPercent}%</span>
</div> </div>
<Progress value={spectrumPercent} className="h-2 w-full" /> <Progress value={spectrumPercent} className="h-2 w-full"/>
</div> </div>
</div> </div>)}
)} <canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
<canvas
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
className="w-full h-auto"
style={{ imageRendering: "auto" }}
/>
</div> </div>
</div> </div>);
);
}); });
+55 -106
View File
@@ -1,138 +1,87 @@
"use client"; "use client";
import { motion, useAnimation } from "motion/react"; import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface AudioLinesIconHandle { export interface AudioLinesIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface AudioLinesIconProps extends HTMLAttributes<HTMLDivElement> { interface AudioLinesIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
} }
const AudioLinesIcon = forwardRef<AudioLinesIconHandle, AudioLinesIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const AudioLinesIcon = forwardRef<AudioLinesIconHandle, AudioLinesIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation(); const controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: () => controls.start("animate"),
startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"),
stopAnimation: () => controls.start("normal"), };
};
}); });
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) { if (isControlledRef.current) {
onMouseEnter?.(e); onMouseEnter?.(e);
} else {
controls.start("animate");
} }
}, else {
[controls, onMouseEnter] controls.start("animate");
); }
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) { if (isControlledRef.current) {
onMouseLeave?.(e); onMouseLeave?.(e);
} else {
controls.start("normal");
} }
}, else {
[controls, onMouseLeave] controls.start("normal");
); }
}, [controls, onMouseLeave]);
return ( return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<div <svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
className={cn(className)} <path d="M2 10v3"/>
onMouseEnter={handleMouseEnter} <motion.path animate={controls} d="M6 6v11" variants={{
onMouseLeave={handleMouseLeave} normal: { d: "M6 6v11" },
{...props} animate: {
>
<svg
fill="none"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2 10v3" />
<motion.path
animate={controls}
d="M6 6v11"
variants={{
normal: { d: "M6 6v11" },
animate: {
d: ["M6 6v11", "M6 10v3", "M6 6v11"], d: ["M6 6v11", "M6 10v3", "M6 6v11"],
transition: { transition: {
duration: 1.5, duration: 1.5,
repeat: Number.POSITIVE_INFINITY, repeat: Number.POSITIVE_INFINITY,
}, },
}, },
}} }}/>
/> <motion.path animate={controls} d="M10 3v18" variants={{
<motion.path normal: { d: "M10 3v18" },
animate={controls} animate: {
d="M10 3v18"
variants={{
normal: { d: "M10 3v18" },
animate: {
d: ["M10 3v18", "M10 9v5", "M10 3v18"], d: ["M10 3v18", "M10 9v5", "M10 3v18"],
transition: { transition: {
duration: 1, duration: 1,
repeat: Number.POSITIVE_INFINITY, repeat: Number.POSITIVE_INFINITY,
}, },
}, },
}} }}/>
/> <motion.path animate={controls} d="M14 8v7" variants={{
<motion.path normal: { d: "M14 8v7" },
animate={controls} animate: {
d="M14 8v7"
variants={{
normal: { d: "M14 8v7" },
animate: {
d: ["M14 8v7", "M14 6v11", "M14 8v7"], d: ["M14 8v7", "M14 6v11", "M14 8v7"],
transition: { transition: {
duration: 0.8, duration: 0.8,
repeat: Number.POSITIVE_INFINITY, repeat: Number.POSITIVE_INFINITY,
}, },
}, },
}} }}/>
/> <motion.path animate={controls} d="M18 5v13" variants={{
<motion.path normal: { d: "M18 5v13" },
animate={controls} animate: {
d="M18 5v13"
variants={{
normal: { d: "M18 5v13" },
animate: {
d: ["M18 5v13", "M18 7v9", "M18 5v13"], d: ["M18 5v13", "M18 7v9", "M18 5v13"],
transition: { transition: {
duration: 1.5, duration: 1.5,
repeat: Number.POSITIVE_INFINITY, repeat: Number.POSITIVE_INFINITY,
}, },
}, },
}} }}/>
/> <path d="M22 10v3"/>
<path d="M22 10v3" />
</svg> </svg>
</div> </div>);
); });
}
);
AudioLinesIcon.displayName = "AudioLinesIcon"; AudioLinesIcon.displayName = "AudioLinesIcon";
export { AudioLinesIcon }; export { AudioLinesIcon };
+36 -79
View File
@@ -2,16 +2,9 @@ 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 { import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeSpectrumFromSamples, type AnalysisProgress, } from "@/lib/flac-analysis";
analyzeAudioArrayBuffer,
analyzeAudioFile,
analyzeSpectrumFromSamples,
type AnalysisProgress,
} 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":
@@ -23,87 +16,71 @@ 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;
const outputLength = Math.floor((clean.length * 3) / 4) - padding; const outputLength = Math.floor((clean.length * 3) / 4) - padding;
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;
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;
} }
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,
}; };
} }
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);
@@ -115,35 +92,29 @@ export function useAudioAnalysis() {
const samplesRef = useRef<Float32Array | null>(sessionSamples); const samplesRef = useRef<Float32Array | null>(sessionSamples);
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 analyzeFile = useCallback(async (file: File) => {
if (!file) { if (!file) {
setErrorWithSession("No file provided"); setErrorWithSession("No file provided");
return null; return null;
} }
const token = createToken(analysisTokenRef); const token = createToken(analysisTokenRef);
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
setAnalyzing(true); setAnalyzing(true);
@@ -154,7 +125,6 @@ export function useAudioAnalysis() {
setErrorWithSession(null); setErrorWithSession(null);
setResultWithSession(null); setResultWithSession(null);
setSelectedFilePathWithSession(file.name); setSelectedFilePathWithSession(file.name);
try { try {
logger.info(`Analyzing audio file (frontend): ${file.name}`); logger.info(`Analyzing audio file (frontend): ${file.name}`);
const start = Date.now(); const start = Date.now();
@@ -163,22 +133,21 @@ export function useAudioAnalysis() {
fftSize: prefs.fftSize, fftSize: prefs.fftSize,
windowFunction: prefs.windowFunction, windowFunction: prefs.windowFunction,
}, (progress) => { }, (progress) => {
if (token.cancelled) return; if (token.cancelled)
return;
setAnalysisProgress(toProgressState(progress)); setAnalysisProgress(toProgressState(progress));
}, () => token.cancelled); }, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return null; return null;
} }
samplesRef.current = payload.samples; samplesRef.current = payload.samples;
sessionSamples = payload.samples; sessionSamples = payload.samples;
setResultWithSession(payload.result); 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 payload.result;
} catch (err) { }
catch (err) {
if (isCancelledError(err)) { if (isCancelledError(err)) {
return null; return null;
} }
@@ -193,20 +162,19 @@ export function useAudioAnalysis() {
description: errorMessage, description: errorMessage,
}); });
return null; return null;
} finally { }
finally {
if (analysisTokenRef.current === token) { if (analysisTokenRef.current === token) {
analysisTokenRef.current = null; analysisTokenRef.current = null;
setAnalyzing(false); setAnalyzing(false);
} }
} }
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
const analyzeFilePath = useCallback(async (filePath: string) => { const analyzeFilePath = useCallback(async (filePath: string) => {
if (!filePath) { if (!filePath) {
setErrorWithSession("No file path provided"); setErrorWithSession("No file path provided");
return null; return null;
} }
const token = createToken(analysisTokenRef); const token = createToken(analysisTokenRef);
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
setAnalyzing(true); setAnalyzing(true);
@@ -217,19 +185,14 @@ export function useAudioAnalysis() {
setErrorWithSession(null); setErrorWithSession(null);
setResultWithSession(null); setResultWithSession(null);
setSelectedFilePathWithSession(filePath); setSelectedFilePathWithSession(filePath);
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 any)?.go?.main?.App?.ReadFileAsBase64 as ((path: string) => Promise<string>) | undefined;
const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as
| ((path: string) => Promise<string>)
| undefined;
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 null;
@@ -248,39 +211,33 @@ export function useAudioAnalysis() {
message: "Preparing audio buffer...", message: "Preparing audio buffer...",
}); });
const fileName = fileNameFromPath(filePath); const fileName = fileNameFromPath(filePath);
const payload = await analyzeAudioArrayBuffer( const payload = await analyzeAudioArrayBuffer({
{ fileName,
fileName, fileSize: arrayBuffer.byteLength,
fileSize: arrayBuffer.byteLength, arrayBuffer,
arrayBuffer, }, {
}, fftSize: prefs.fftSize,
{ windowFunction: prefs.windowFunction,
fftSize: prefs.fftSize, }, (progress) => {
windowFunction: prefs.windowFunction, if (token.cancelled)
}, return;
(progress) => { const mappedPercent = 10 + (progress.percent * 0.9);
if (token.cancelled) return; setAnalysisProgress({
const mappedPercent = 10 + (progress.percent * 0.9); percent: Math.round(Math.max(0, Math.min(100, mappedPercent))),
setAnalysisProgress({ message: progress.message,
percent: Math.round(Math.max(0, Math.min(100, mappedPercent))), });
message: progress.message, }, () => token.cancelled);
});
},
() => token.cancelled,
);
if (token.cancelled) { if (token.cancelled) {
return null; return null;
} }
samplesRef.current = payload.samples; samplesRef.current = payload.samples;
sessionSamples = payload.samples; sessionSamples = payload.samples;
setResultWithSession(payload.result); 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 payload.result;
} catch (err) { }
catch (err) {
if (isCancelledError(err)) { if (isCancelledError(err)) {
return null; return null;
} }
@@ -295,17 +252,17 @@ export function useAudioAnalysis() {
description: errorMessage, description: errorMessage,
}); });
return null; return null;
} finally { }
finally {
if (analysisTokenRef.current === token) { if (analysisTokenRef.current === token) {
analysisTokenRef.current = null; analysisTokenRef.current = null;
setAnalyzing(false); setAnalyzing(false);
} }
} }
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
if (!result || !samplesRef.current) return; if (!result || !samplesRef.current)
return;
const token = createToken(spectrumTokenRef); const token = createToken(spectrumTokenRef);
setSpectrumLoading(true); setSpectrumLoading(true);
setSpectrumProgress({ setSpectrumProgress({
@@ -318,10 +275,10 @@ export function useAudioAnalysis() {
fftSize, fftSize,
windowFunction: toWindowFunction(windowFunction), windowFunction: toWindowFunction(windowFunction),
}, (progress) => { }, (progress) => {
if (token.cancelled) return; if (token.cancelled)
return;
setSpectrumProgress(toProgressState(progress)); setSpectrumProgress(toProgressState(progress));
}, () => token.cancelled); }, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return; return;
} }
@@ -330,7 +287,8 @@ export function useAudioAnalysis() {
sessionResult = next; sessionResult = next;
return next; return next;
}); });
} catch (err) { }
catch (err) {
if (isCancelledError(err)) { if (isCancelledError(err)) {
return; return;
} }
@@ -343,14 +301,14 @@ export function useAudioAnalysis() {
toast.error("Spectrum Analysis Failed", { toast.error("Spectrum Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
} finally { }
finally {
if (spectrumTokenRef.current === token) { if (spectrumTokenRef.current === token) {
spectrumTokenRef.current = null; spectrumTokenRef.current = null;
setSpectrumLoading(false); setSpectrumLoading(false);
} }
} }
}, [result]); }, [result]);
const clearResult = useCallback(() => { const clearResult = useCallback(() => {
cancelToken(analysisTokenRef); cancelToken(analysisTokenRef);
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
@@ -364,7 +322,6 @@ export function useAudioAnalysis() {
samplesRef.current = null; samplesRef.current = null;
sessionSamples = null; sessionSamples = null;
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
return { return {
analyzing, analyzing,
analysisProgress, analysisProgress,
@@ -1,52 +1,42 @@
export type AnalyzerColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale"; export type AnalyzerColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale";
export type AnalyzerFreqScale = "linear" | "log2"; export type AnalyzerFreqScale = "linear" | "log2";
export type AnalyzerWindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; export type AnalyzerWindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
export interface AudioAnalysisPreferences { export interface AudioAnalysisPreferences {
colorScheme: AnalyzerColorScheme; colorScheme: AnalyzerColorScheme;
freqScale: AnalyzerFreqScale; freqScale: AnalyzerFreqScale;
fftSize: number; fftSize: number;
windowFunction: AnalyzerWindowFunction; windowFunction: AnalyzerWindowFunction;
} }
const STORAGE_KEY = "spotiflac_audio_analysis_preferences"; const STORAGE_KEY = "spotiflac_audio_analysis_preferences";
const DEFAULT_PREFERENCES: AudioAnalysisPreferences = { const DEFAULT_PREFERENCES: AudioAnalysisPreferences = {
colorScheme: "spek", colorScheme: "spek",
freqScale: "linear", freqScale: "linear",
fftSize: 4096, fftSize: 4096,
windowFunction: "hann", windowFunction: "hann",
}; };
const FFT_SIZE_SET = new Set([512, 1024, 2048, 4096]); const FFT_SIZE_SET = new Set([512, 1024, 2048, 4096]);
function toColorScheme(value: unknown): AnalyzerColorScheme { function toColorScheme(value: unknown): AnalyzerColorScheme {
return value === "viridis" || value === "hot" || value === "cool" || value === "grayscale" return value === "viridis" || value === "hot" || value === "cool" || value === "grayscale"
? value ? value
: "spek"; : "spek";
} }
function toFreqScale(value: unknown): AnalyzerFreqScale { function toFreqScale(value: unknown): AnalyzerFreqScale {
return value === "log2" ? "log2" : "linear"; return value === "log2" ? "log2" : "linear";
} }
function toFFTSize(value: unknown): number { function toFFTSize(value: unknown): number {
const num = typeof value === "number" ? value : Number(value); const num = typeof value === "number" ? value : Number(value);
return FFT_SIZE_SET.has(num) ? num : 4096; return FFT_SIZE_SET.has(num) ? num : 4096;
} }
function toWindowFunction(value: unknown): AnalyzerWindowFunction { function toWindowFunction(value: unknown): AnalyzerWindowFunction {
return value === "hamming" || value === "blackman" || value === "rectangular" return value === "hamming" || value === "blackman" || value === "rectangular"
? value ? value
: "hann"; : "hann";
} }
export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences { export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) if (!raw)
return DEFAULT_PREFERENCES; return DEFAULT_PREFERENCES;
const parsed = JSON.parse(raw) as Partial<AudioAnalysisPreferences>; const parsed = JSON.parse(raw) as Partial<AudioAnalysisPreferences>;
return { return {
colorScheme: toColorScheme(parsed.colorScheme), colorScheme: toColorScheme(parsed.colorScheme),
@@ -59,7 +49,6 @@ export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences {
return DEFAULT_PREFERENCES; return DEFAULT_PREFERENCES;
} }
} }
export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferences): void { export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferences): void {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ localStorage.setItem(STORAGE_KEY, JSON.stringify({
@@ -70,6 +59,5 @@ export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferenc
})); }));
} }
catch { catch {
// Ignore persistence errors.
} }
} }
+84 -218
View File
@@ -1,29 +1,23 @@
import type { AnalysisResult, SpectrumData, TimeSlice } from "@/types/api"; import type { AnalysisResult, SpectrumData, TimeSlice } from "@/types/api";
export interface SpectrumParams { export interface SpectrumParams {
fftSize: number; fftSize: number;
windowFunction: "hann" | "hamming" | "blackman" | "rectangular"; windowFunction: "hann" | "hamming" | "blackman" | "rectangular";
} }
const DEFAULT_PARAMS: SpectrumParams = { const DEFAULT_PARAMS: SpectrumParams = {
fftSize: 4096, fftSize: 4096,
windowFunction: "hann", windowFunction: "hann",
}; };
const MAX_SPECTRUM_FRAMES = 2200; const MAX_SPECTRUM_FRAMES = 2200;
const METRICS_CHUNK_SIZE = 262144; const METRICS_CHUNK_SIZE = 262144;
const AAC_SAMPLE_RATES = [ const AAC_SAMPLE_RATES = [
96000, 88200, 64000, 48000, 44100, 32000, 24000, 96000, 88200, 64000, 48000, 44100, 32000, 24000,
22050, 16000, 12000, 11025, 8000, 7350, 22050, 16000, 12000, 11025, 8000, 7350,
] as const; ] as const;
const MP4_CONTAINER_TYPES = new Set([ const MP4_CONTAINER_TYPES = new Set([
"moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "moov", "trak", "mdia", "minf", "stbl", "edts", "dinf",
"udta", "ilst", "meta", "stsd", "wave", "udta", "ilst", "meta", "stsd", "wave",
]); ]);
type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC"; type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
interface ParsedAudioMetadata { interface ParsedAudioMetadata {
fileType: SupportedAudioFileType; fileType: SupportedAudioFileType;
sampleRate: number; sampleRate: number;
@@ -36,104 +30,77 @@ interface ParsedAudioMetadata {
totalFrames?: number; totalFrames?: number;
codecVersion?: string; codecVersion?: string;
} }
interface Mp4BoxInfo { interface Mp4BoxInfo {
offset: number; offset: number;
size: number; size: number;
headerSize: number; headerSize: number;
type: string; type: string;
} }
export interface FrontendAnalysisPayload { export interface FrontendAnalysisPayload {
result: AnalysisResult; result: AnalysisResult;
samples: Float32Array; samples: Float32Array;
} }
export interface AudioArrayBufferInput { export interface AudioArrayBufferInput {
fileName: string; fileName: string;
fileSize: number; fileSize: number;
arrayBuffer: ArrayBuffer; arrayBuffer: ArrayBuffer;
} }
export type AnalysisPhase = "read" | "parse" | "decode" | "metrics" | "spectrum" | "finalize"; export type AnalysisPhase = "read" | "parse" | "decode" | "metrics" | "spectrum" | "finalize";
export interface AnalysisProgress { export interface AnalysisProgress {
phase: AnalysisPhase; phase: AnalysisPhase;
percent: number; percent: number;
message: string; message: string;
} }
export type AnalysisProgressCallback = (progress: AnalysisProgress) => void; export type AnalysisProgressCallback = (progress: AnalysisProgress) => void;
export type AnalysisCancelCheck = () => boolean; export type AnalysisCancelCheck = () => boolean;
function reportProgress(callback: AnalysisProgressCallback | undefined, phase: AnalysisPhase, percent: number, message: string): void {
function reportProgress( if (!callback)
callback: AnalysisProgressCallback | undefined, return;
phase: AnalysisPhase,
percent: number,
message: string,
): void {
if (!callback) return;
callback({ callback({
phase, phase,
percent: Math.max(0, Math.min(100, percent)), percent: Math.max(0, Math.min(100, percent)),
message, message,
}); });
} }
function throwIfCancelled(cancelCheck?: AnalysisCancelCheck): void { function throwIfCancelled(cancelCheck?: AnalysisCancelCheck): void {
if (cancelCheck?.()) { if (cancelCheck?.()) {
throw new Error("Analysis cancelled"); throw new Error("Analysis cancelled");
} }
} }
function nowMs(): number { function nowMs(): number {
return typeof performance !== "undefined" ? performance.now() : Date.now(); return typeof performance !== "undefined" ? performance.now() : Date.now();
} }
function nextTick(): Promise<void> { function nextTick(): Promise<void> {
if (typeof requestAnimationFrame === "function") { if (typeof requestAnimationFrame === "function") {
return new Promise((resolve) => requestAnimationFrame(() => resolve())); return new Promise((resolve) => requestAnimationFrame(() => resolve()));
} }
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 0));
} }
function readFourCC(view: DataView, offset: number): string { function readFourCC(view: DataView, offset: number): string {
return String.fromCharCode( return String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3));
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3),
);
} }
function fileExtension(fileName: string): string { function fileExtension(fileName: string): string {
const normalized = fileName.toLowerCase(); const normalized = fileName.toLowerCase();
const dotIndex = normalized.lastIndexOf("."); const dotIndex = normalized.lastIndexOf(".");
return dotIndex >= 0 ? normalized.slice(dotIndex) : ""; return dotIndex >= 0 ? normalized.slice(dotIndex) : "";
} }
function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudioFileType { function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudioFileType {
const view = new DataView(buffer); const view = new DataView(buffer);
if (view.byteLength >= 4 && view.getUint32(0, false) === 0x664c6143) { if (view.byteLength >= 4 && view.getUint32(0, false) === 0x664c6143) {
return "FLAC"; return "FLAC";
} }
if (view.byteLength >= 3 && if (view.byteLength >= 3 &&
view.getUint8(0) === 0x49 && view.getUint8(0) === 0x49 &&
view.getUint8(1) === 0x44 && view.getUint8(1) === 0x44 &&
view.getUint8(2) === 0x33) { view.getUint8(2) === 0x33) {
return "MP3"; return "MP3";
} }
if (view.byteLength >= 8 && readFourCC(view, 4) === "ftyp") { if (view.byteLength >= 8 && readFourCC(view, 4) === "ftyp") {
return "M4A"; return "M4A";
} }
if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xf6) === 0xf0) { if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xf6) === 0xf0) {
return "AAC"; return "AAC";
} }
for (let offset = 0; offset < Math.min(4096, view.byteLength - 4); offset++) { for (let offset = 0; offset < Math.min(4096, view.byteLength - 4); offset++) {
const header = view.getUint32(offset, false); const header = view.getUint32(offset, false);
if ((header >>> 21) === 0x7ff) { if ((header >>> 21) === 0x7ff) {
@@ -145,7 +112,6 @@ function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudio
} }
} }
} }
switch (fileExtension(fileName)) { switch (fileExtension(fileName)) {
case ".flac": return "FLAC"; case ".flac": return "FLAC";
case ".mp3": return "MP3"; case ".mp3": return "MP3";
@@ -155,36 +121,31 @@ function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudio
default: throw new Error(`Unsupported audio format: ${fileName || "unknown"}`); default: throw new Error(`Unsupported audio format: ${fileName || "unknown"}`);
} }
} }
function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) { if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) {
throw new Error("Invalid FLAC file"); throw new Error("Invalid FLAC file");
} }
let offset = 4; let offset = 4;
while (offset + 4 <= data.length) { while (offset + 4 <= data.length) {
const blockHeader = data[offset]; const blockHeader = data[offset];
const blockType = blockHeader & 0x7f; const blockType = blockHeader & 0x7f;
const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
offset += 4; offset += 4;
if (offset + blockLength > data.length)
if (offset + blockLength > data.length) break; break;
if (blockType === 0 && blockLength >= 18) { if (blockType === 0 && blockLength >= 18) {
const streamInfo = data.subarray(offset, offset + blockLength); const streamInfo = data.subarray(offset, offset + blockLength);
const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4); const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4);
const channels = ((streamInfo[12] >> 1) & 0x07) + 1; const channels = ((streamInfo[12] >> 1) & 0x07) + 1;
const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1; const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1;
const totalSamplesBig = const totalSamplesBig = (BigInt(streamInfo[13] & 0x0f) << 32n) |
(BigInt(streamInfo[13] & 0x0f) << 32n) |
(BigInt(streamInfo[14]) << 24n) | (BigInt(streamInfo[14]) << 24n) |
(BigInt(streamInfo[15]) << 16n) | (BigInt(streamInfo[15]) << 16n) |
(BigInt(streamInfo[16]) << 8n) | (BigInt(streamInfo[16]) << 8n) |
BigInt(streamInfo[17]); BigInt(streamInfo[17]);
const totalSamples = Number(totalSamplesBig); const totalSamples = Number(totalSamplesBig);
const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0; const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0;
return { return {
fileType: "FLAC", fileType: "FLAC",
sampleRate, sampleRate,
@@ -194,13 +155,10 @@ function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
duration, duration,
}; };
} }
offset += blockLength; offset += blockLength;
} }
throw new Error("FLAC STREAMINFO metadata not found"); throw new Error("FLAC STREAMINFO metadata not found");
} }
function skipId3v2Tag(view: DataView): number { function skipId3v2Tag(view: DataView): number {
if (view.byteLength < 10 || if (view.byteLength < 10 ||
view.getUint8(0) !== 0x49 || view.getUint8(0) !== 0x49 ||
@@ -208,20 +166,16 @@ function skipId3v2Tag(view: DataView): number {
view.getUint8(2) !== 0x33) { view.getUint8(2) !== 0x33) {
return 0; return 0;
} }
const size = ((view.getUint8(6) & 0x7f) << 21) |
const size =
((view.getUint8(6) & 0x7f) << 21) |
((view.getUint8(7) & 0x7f) << 14) | ((view.getUint8(7) & 0x7f) << 14) |
((view.getUint8(8) & 0x7f) << 7) | ((view.getUint8(8) & 0x7f) << 7) |
(view.getUint8(9) & 0x7f); (view.getUint8(9) & 0x7f);
let offset = 10 + size; let offset = 10 + size;
if ((view.getUint8(5) & 0x10) !== 0) { if ((view.getUint8(5) & 0x10) !== 0) {
offset += 10; offset += 10;
} }
return offset < view.byteLength ? offset : 0; return offset < view.byteLength ? offset : 0;
} }
function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): number { function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): number {
const tables: Record<number, Record<number, number[]>> = { const tables: Record<number, Record<number, number[]>> = {
1: { 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], 3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
}, },
}; };
const normalizedVersion = version === 2.5 ? 2 : version; const normalizedVersion = version === 2.5 ? 2 : version;
return tables[normalizedVersion]?.[layer]?.[bitrateIndex] ?? 0; return tables[normalizedVersion]?.[layer]?.[bitrateIndex] ?? 0;
} }
function getMp3SamplesPerFrame(version: number, layer: number): number { function getMp3SamplesPerFrame(version: number, layer: number): number {
if (layer === 1) return 384; if (layer === 1)
if (version === 1) return 1152; return 384;
if (version === 1)
return 1152;
return 576; return 576;
} }
interface Mp3FrameInfo { interface Mp3FrameInfo {
version: number; version: number;
versionName: string; versionName: string;
@@ -256,24 +209,26 @@ interface Mp3FrameInfo {
frameSize: number; frameSize: number;
samplesPerFrame: number; samplesPerFrame: number;
} }
function parseMp3FrameHeader(header: number): Mp3FrameInfo | null { 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 versionBits = (header >>> 19) & 0x03;
const layerBits = (header >>> 17) & 0x03; const layerBits = (header >>> 17) & 0x03;
const bitrateIndex = (header >>> 12) & 0x0f; const bitrateIndex = (header >>> 12) & 0x0f;
const sampleRateIndex = (header >>> 10) & 0x03; const sampleRateIndex = (header >>> 10) & 0x03;
const padding = (header >>> 9) & 0x01; const padding = (header >>> 9) & 0x01;
const channelMode = (header >>> 6) & 0x03; const channelMode = (header >>> 6) & 0x03;
const versions = [2.5, null, 2, 1] as const; const versions = [2.5, null, 2, 1] as const;
const layers = [null, 3, 2, 1] as const; const layers = [null, 3, 2, 1] as const;
const version = versions[versionBits]; const version = versions[versionBits];
const layer = layers[layerBits]; const layer = layers[layerBits];
if (version === null || layer === null || sampleRateIndex === 3) return null; if (version === null || layer === null || sampleRateIndex === 3)
return null;
const sampleRateTables: Record<1 | 2 | 25, [number, number, number]> = { const sampleRateTables: Record<1 | 2 | 25, [
number,
number,
number
]> = {
1: [44100, 48000, 32000], 1: [44100, 48000, 32000],
2: [22050, 24000, 16000], 2: [22050, 24000, 16000],
25: [11025, 12000, 8000], 25: [11025, 12000, 8000],
@@ -282,8 +237,8 @@ function parseMp3FrameHeader(header: number): Mp3FrameInfo | null {
const sampleRate = sampleRateTables[sampleRateKey][sampleRateIndex]; const sampleRate = sampleRateTables[sampleRateKey][sampleRateIndex];
const bitrate = getMp3Bitrate(version, layer, bitrateIndex); const bitrate = getMp3Bitrate(version, layer, bitrateIndex);
const samplesPerFrame = getMp3SamplesPerFrame(version, layer); const samplesPerFrame = getMp3SamplesPerFrame(version, layer);
if (!sampleRate || !bitrate || !samplesPerFrame) return null; if (!sampleRate || !bitrate || !samplesPerFrame)
return null;
return { return {
version, version,
versionName: `MPEG-${version === 1 ? "1" : version === 2 ? "2" : "2.5"}`, versionName: `MPEG-${version === 1 ? "1" : version === 2 ? "2" : "2.5"}`,
@@ -295,21 +250,19 @@ function parseMp3FrameHeader(header: number): Mp3FrameInfo | null {
samplesPerFrame, samplesPerFrame,
}; };
} }
function getMp3SideInfoSize(frameInfo: Mp3FrameInfo): number { function getMp3SideInfoSize(frameInfo: Mp3FrameInfo): number {
if (frameInfo.version === 1) { if (frameInfo.version === 1) {
return frameInfo.channels === 1 ? 17 : 32; return frameInfo.channels === 1 ? 17 : 32;
} }
return frameInfo.channels === 1 ? 9 : 17; return frameInfo.channels === 1 ? 9 : 17;
} }
function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { 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); const flags = view.getUint32(offset + 4, false);
let pos = offset + 8; let pos = offset + 8;
let totalFrames = 0; let totalFrames = 0;
let totalBytes = 0; let totalBytes = 0;
if ((flags & 0x01) !== 0 && pos + 4 <= view.byteLength) { if ((flags & 0x01) !== 0 && pos + 4 <= view.byteLength) {
totalFrames = view.getUint32(pos, false); totalFrames = view.getUint32(pos, false);
pos += 4; pos += 4;
@@ -317,10 +270,8 @@ function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameI
if ((flags & 0x02) !== 0 && pos + 4 <= view.byteLength) { if ((flags & 0x02) !== 0 && pos + 4 <= view.byteLength) {
totalBytes = view.getUint32(pos, false); totalBytes = view.getUint32(pos, false);
} }
const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0;
const avgBitrate = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate; const avgBitrate = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate;
return { return {
codecMode: "VBR (Xing)", codecMode: "VBR (Xing)",
totalFrames, totalFrames,
@@ -328,9 +279,9 @@ function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameI
bitrateKbps: avgBitrate, bitrateKbps: avgBitrate,
}; };
} }
function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) { 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 totalBytes = view.getUint32(offset + 10, false);
const totalFrames = view.getUint32(offset + 14, false); const totalFrames = view.getUint32(offset + 14, false);
const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0; const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0;
@@ -342,43 +293,27 @@ function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameI
bitrateKbps, bitrateKbps,
}; };
} }
function parseMp3VbrInfo(view: DataView, frameOffset: number, frameInfo: Mp3FrameInfo) { function parseMp3VbrInfo(view: DataView, frameOffset: number, frameInfo: Mp3FrameInfo) {
const sideInfoSize = getMp3SideInfoSize(frameInfo); const sideInfoSize = getMp3SideInfoSize(frameInfo);
const xingOffset = frameOffset + 4 + sideInfoSize; const xingOffset = frameOffset + 4 + sideInfoSize;
if (xingOffset + 4 <= view.byteLength) { if (xingOffset + 4 <= view.byteLength) {
const xingTag = String.fromCharCode( const xingTag = String.fromCharCode(view.getUint8(xingOffset), view.getUint8(xingOffset + 1), view.getUint8(xingOffset + 2), view.getUint8(xingOffset + 3));
view.getUint8(xingOffset),
view.getUint8(xingOffset + 1),
view.getUint8(xingOffset + 2),
view.getUint8(xingOffset + 3),
);
if (xingTag === "Xing" || xingTag === "Info") { if (xingTag === "Xing" || xingTag === "Info") {
return parseMp3XingHeader(view, xingOffset, frameInfo); return parseMp3XingHeader(view, xingOffset, frameInfo);
} }
} }
const vbriOffset = frameOffset + 36; const vbriOffset = frameOffset + 36;
if (vbriOffset + 4 <= view.byteLength) { if (vbriOffset + 4 <= view.byteLength) {
const vbriTag = String.fromCharCode( const vbriTag = String.fromCharCode(view.getUint8(vbriOffset), view.getUint8(vbriOffset + 1), view.getUint8(vbriOffset + 2), view.getUint8(vbriOffset + 3));
view.getUint8(vbriOffset),
view.getUint8(vbriOffset + 1),
view.getUint8(vbriOffset + 2),
view.getUint8(vbriOffset + 3),
);
if (vbriTag === "VBRI") { if (vbriTag === "VBRI") {
return parseMp3VbriHeader(view, vbriOffset, frameInfo); return parseMp3VbriHeader(view, vbriOffset, frameInfo);
} }
} }
return null; return null;
} }
function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata { function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata {
const view = new DataView(buffer); const view = new DataView(buffer);
const startOffset = skipId3v2Tag(view); const startOffset = skipId3v2Tag(view);
for (let offset = startOffset; offset <= view.byteLength - 4; offset++) { for (let offset = startOffset; offset <= view.byteLength - 4; offset++) {
const header = view.getUint32(offset, false); const header = view.getUint32(offset, false);
const frameInfo = parseMp3FrameHeader(header); const frameInfo = parseMp3FrameHeader(header);
@@ -389,7 +324,6 @@ function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata {
const totalFrames = vbrInfo?.totalFrames ?? Math.floor(estimatedAudioDataSize / estimatedFrameSize); const totalFrames = vbrInfo?.totalFrames ?? Math.floor(estimatedAudioDataSize / estimatedFrameSize);
const duration = vbrInfo?.duration ?? ((totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate); const duration = vbrInfo?.duration ?? ((totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate);
const bitrateKbps = vbrInfo?.bitrateKbps ?? frameInfo.bitrate; const bitrateKbps = vbrInfo?.bitrateKbps ?? frameInfo.bitrate;
return { return {
fileType: "MP3", fileType: "MP3",
sampleRate: frameInfo.sampleRate, sampleRate: frameInfo.sampleRate,
@@ -404,21 +338,18 @@ function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata {
}; };
} }
} }
throw new Error("No valid MP3 frame found"); throw new Error("No valid MP3 frame found");
} }
function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
for (let offset = 0; offset <= data.length - 7; offset++) { 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 sampleRateIndex = (data[offset + 2] >> 2) & 0x0f;
const sampleRate = AAC_SAMPLE_RATES[sampleRateIndex]; const sampleRate = AAC_SAMPLE_RATES[sampleRateIndex];
const channels = ((data[offset + 2] & 0x01) << 2) | ((data[offset + 3] >> 6) & 0x03); const channels = ((data[offset + 2] & 0x01) << 2) | ((data[offset + 3] >> 6) & 0x03);
if (!sampleRate) continue; if (!sampleRate)
continue;
return { return {
fileType: "AAC", fileType: "AAC",
sampleRate, sampleRate,
@@ -428,48 +359,43 @@ function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
duration: 0, duration: 0,
}; };
} }
throw new Error("No valid AAC ADTS header found"); throw new Error("No valid AAC ADTS header found");
} }
function readMp4Box(view: DataView, offset: number, limit: number): Mp4BoxInfo | null { 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); let size = view.getUint32(offset, false);
const type = readFourCC(view, offset + 4); const type = readFourCC(view, offset + 4);
let headerSize = 8; let headerSize = 8;
if (size === 1) { if (size === 1) {
if (offset + 16 > limit) return null; if (offset + 16 > limit)
return null;
const high = view.getUint32(offset + 8, false); const high = view.getUint32(offset + 8, false);
const low = view.getUint32(offset + 12, false); const low = view.getUint32(offset + 12, false);
size = high * 4294967296 + low; size = high * 4294967296 + low;
headerSize = 16; headerSize = 16;
} else if (size === 0) { }
else if (size === 0) {
size = limit - offset; size = limit - offset;
} }
if (size < headerSize || offset + size > limit)
if (size < headerSize || offset + size > limit) return null; return null;
return { offset, size, headerSize, type }; return { offset, size, headerSize, type };
} }
function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata { function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
const view = new DataView(buffer); const view = new DataView(buffer);
let sampleRate = 0; let sampleRate = 0;
let channels = 0; let channels = 0;
let bitsPerSample = 0; let bitsPerSample = 0;
let duration = 0; let duration = 0;
const scanBoxes = (start: number, end: number): void => { const scanBoxes = (start: number, end: number): void => {
let offset = start; let offset = start;
while (offset + 8 <= end) { while (offset + 8 <= end) {
const box = readMp4Box(view, offset, end); const box = readMp4Box(view, offset, end);
if (!box) break; if (!box)
break;
const boxEnd = box.offset + box.size; const boxEnd = box.offset + box.size;
const contentStart = box.offset + box.headerSize; const contentStart = box.offset + box.headerSize;
if (box.type === "mdhd" && contentStart + 24 <= boxEnd) { if (box.type === "mdhd" && contentStart + 24 <= boxEnd) {
const version = view.getUint8(contentStart); const version = view.getUint8(contentStart);
if (version === 0 && contentStart + 24 <= boxEnd) { if (version === 0 && contentStart + 24 <= boxEnd) {
@@ -479,7 +405,8 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
sampleRate = timeScale; sampleRate = timeScale;
duration = durationValue / 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 timeScale = view.getUint32(contentStart + 20, false);
const durationHigh = view.getUint32(contentStart + 24, false); const durationHigh = view.getUint32(contentStart + 24, false);
const durationLow = view.getUint32(contentStart + 28, false); const durationLow = view.getUint32(contentStart + 28, false);
@@ -489,7 +416,8 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
duration = durationValue / timeScale; 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; channels = view.getUint16(box.offset + 24, false) || channels;
bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample; bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample;
if (!sampleRate) { if (!sampleRate) {
@@ -499,24 +427,25 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
} }
} }
} }
if (MP4_CONTAINER_TYPES.has(box.type)) { if (MP4_CONTAINER_TYPES.has(box.type)) {
let childStart = contentStart; let childStart = contentStart;
if (box.type === "meta") childStart = Math.min(boxEnd, contentStart + 4); if (box.type === "meta")
else if (box.type === "stsd") childStart = Math.min(boxEnd, contentStart + 8); childStart = Math.min(boxEnd, contentStart + 4);
if (childStart < boxEnd) scanBoxes(childStart, boxEnd); else if (box.type === "stsd")
childStart = Math.min(boxEnd, contentStart + 8);
if (childStart < boxEnd)
scanBoxes(childStart, boxEnd);
} }
offset = boxEnd; offset = boxEnd;
} }
}; };
scanBoxes(0, view.byteLength); scanBoxes(0, view.byteLength);
if (sampleRate <= 0)
if (sampleRate <= 0) sampleRate = 44100; sampleRate = 44100;
if (channels <= 0) channels = 2; if (channels <= 0)
if (bitsPerSample <= 0) bitsPerSample = 16; channels = 2;
if (bitsPerSample <= 0)
bitsPerSample = 16;
return { return {
fileType: "M4A", fileType: "M4A",
sampleRate, sampleRate,
@@ -526,10 +455,8 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
duration, duration,
}; };
} }
function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata { function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
const fileType = detectAudioFileType(input.arrayBuffer, input.fileName); const fileType = detectAudioFileType(input.arrayBuffer, input.fileName);
switch (fileType) { switch (fileType) {
case "FLAC": return parseFlacMetadata(input.arrayBuffer); case "FLAC": return parseFlacMetadata(input.arrayBuffer);
case "MP3": return parseMp3Metadata(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"}`); default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`);
} }
} }
function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array { function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
const coeffs = new Float32Array(size); const coeffs = new Float32Array(size);
if (size <= 1) { if (size <= 1) {
coeffs.fill(1); coeffs.fill(1);
return coeffs; return coeffs;
} }
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
switch (windowFunction) { switch (windowFunction) {
case "hamming": case "hamming":
@@ -554,8 +479,8 @@ function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["w
case "blackman": case "blackman":
coeffs[i] = coeffs[i] =
0.42 - 0.42 -
0.5 * Math.cos((2 * 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)); 0.08 * Math.cos((4 * Math.PI * i) / (size - 1));
break; break;
case "rectangular": case "rectangular":
coeffs[i] = 1; coeffs[i] = 1;
@@ -566,14 +491,12 @@ function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["w
break; break;
} }
} }
return coeffs; return coeffs;
} }
function buildBitReversal(size: number): Uint32Array { function buildBitReversal(size: number): Uint32Array {
let bits = 0; let bits = 0;
while ((1 << bits) < size) bits++; while ((1 << bits) < size)
bits++;
const out = new Uint32Array(size); const out = new Uint32Array(size);
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
let x = i; let x = i;
@@ -586,44 +509,36 @@ function buildBitReversal(size: number): Uint32Array {
} }
return out; return out;
} }
function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32Array): void { function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32Array): void {
const size = real.length; const size = real.length;
for (let i = 1; i < size; i++) { for (let i = 1; i < size; i++) {
const j = bitReversal[i]; const j = bitReversal[i];
if (i < j) { if (i < j) {
const tr = real[i]; const tr = real[i];
real[i] = real[j]; real[i] = real[j];
real[j] = tr; real[j] = tr;
const ti = imag[i]; const ti = imag[i];
imag[i] = imag[j]; imag[i] = imag[j];
imag[j] = ti; imag[j] = ti;
} }
} }
for (let len = 2; len <= size; len <<= 1) { for (let len = 2; len <= size; len <<= 1) {
const wLen = (-2 * Math.PI) / len; const wLen = (-2 * Math.PI) / len;
const wLenReal = Math.cos(wLen); const wLenReal = Math.cos(wLen);
const wLenImag = Math.sin(wLen); const wLenImag = Math.sin(wLen);
for (let i = 0; i < size; i += len) { for (let i = 0; i < size; i += len) {
let wReal = 1; let wReal = 1;
let wImag = 0; let wImag = 0;
const half = len >> 1; const half = len >> 1;
for (let j = 0; j < half; j++) { for (let j = 0; j < half; j++) {
const uReal = real[i + j]; const uReal = real[i + j];
const uImag = imag[i + j]; const uImag = imag[i + j];
const vReal = real[i + j + half] * wReal - imag[i + j + half] * wImag; const vReal = real[i + j + half] * wReal - imag[i + j + half] * wImag;
const vImag = real[i + j + half] * wImag + imag[i + j + half] * wReal; const vImag = real[i + j + half] * wImag + imag[i + j + half] * wReal;
real[i + j] = uReal + vReal; real[i + j] = uReal + vReal;
imag[i + j] = uImag + vImag; imag[i + j] = uImag + vImag;
real[i + j + half] = uReal - vReal; real[i + j + half] = uReal - vReal;
imag[i + j + half] = uImag - vImag; imag[i + j + half] = uImag - vImag;
const tempReal = wReal * wLenReal - wImag * wLenImag; const tempReal = wReal * wLenReal - wImag * wLenImag;
wImag = wReal * wLenImag + wImag * wLenReal; wImag = wReal * wLenImag + wImag * wLenReal;
wReal = tempReal; 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<SpectrumData> {
export async function analyzeSpectrumFromSamples(
samples: Float32Array,
sampleRate: number,
params: SpectrumParams,
onProgress?: AnalysisProgressCallback,
shouldCancel?: AnalysisCancelCheck,
): Promise<SpectrumData> {
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
const fftSize = params.fftSize; const fftSize = params.fftSize;
const hopSize = Math.max(1, Math.floor(fftSize / 4)); 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 freqBins = Math.floor(fftSize / 2) + 1;
const duration = sampleRate > 0 ? samples.length / sampleRate : 0; const duration = sampleRate > 0 ? samples.length / sampleRate : 0;
const maxFreq = sampleRate / 2; const maxFreq = sampleRate / 2;
const windowCoeffs = buildWindowCoefficients(fftSize, params.windowFunction); const windowCoeffs = buildWindowCoefficients(fftSize, params.windowFunction);
const bitReversal = buildBitReversal(fftSize); const bitReversal = buildBitReversal(fftSize);
const real = new Float32Array(fftSize); const real = new Float32Array(fftSize);
const imag = new Float32Array(fftSize); const imag = new Float32Array(fftSize);
const invFFTSizeSquared = 1 / (fftSize * fftSize); const invFFTSizeSquared = 1 / (fftSize * fftSize);
reportProgress(onProgress, "spectrum", 0, "Preparing FFT..."); reportProgress(onProgress, "spectrum", 0, "Preparing FFT...");
const windowIndices: number[] = []; const windowIndices: number[] = [];
for (let windowIndex = 0; windowIndex < numWindows; windowIndex += frameStride) { for (let windowIndex = 0; windowIndex < numWindows; windowIndex += frameStride) {
@@ -663,19 +569,16 @@ export async function analyzeSpectrumFromSamples(
if (windowIndices[windowIndices.length - 1] !== numWindows - 1) { if (windowIndices[windowIndices.length - 1] !== numWindows - 1) {
windowIndices.push(numWindows - 1); windowIndices.push(numWindows - 1);
} }
const totalSlices = windowIndices.length; const totalSlices = windowIndices.length;
const timeSlices: TimeSlice[] = new Array(totalSlices); const timeSlices: TimeSlice[] = new Array(totalSlices);
let lastReportedPercent = -1; let lastReportedPercent = -1;
let lastYieldAt = nowMs(); let lastYieldAt = nowMs();
for (let i = 0; i < totalSlices; i++) { for (let i = 0; i < totalSlices; i++) {
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
const windowIndex = windowIndices[i]; const windowIndex = windowIndices[i];
const start = windowIndex * hopSize; const start = windowIndex * hopSize;
const remaining = samples.length - start; const remaining = samples.length - start;
const copyLen = Math.max(0, Math.min(fftSize, remaining)); const copyLen = Math.max(0, Math.min(fftSize, remaining));
for (let j = 0; j < copyLen; j++) { for (let j = 0; j < copyLen; j++) {
real[j] = samples[start + j] * windowCoeffs[j]; real[j] = samples[start + j] * windowCoeffs[j];
imag[j] = 0; imag[j] = 0;
@@ -684,26 +587,21 @@ export async function analyzeSpectrumFromSamples(
real[j] = 0; real[j] = 0;
imag[j] = 0; imag[j] = 0;
} }
fftInPlace(real, imag, bitReversal); fftInPlace(real, imag, bitReversal);
const magnitudes = new Float32Array(freqBins); const magnitudes = new Float32Array(freqBins);
for (let j = 0; j < freqBins; j++) { for (let j = 0; j < freqBins; j++) {
const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared; const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared;
magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120; magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120;
} }
timeSlices[i] = { timeSlices[i] = {
time: sampleRate > 0 ? start / sampleRate : 0, time: sampleRate > 0 ? start / sampleRate : 0,
magnitudes, magnitudes,
}; };
const currentPercent = Math.floor(((i + 1) / totalSlices) * 100); const currentPercent = Math.floor(((i + 1) / totalSlices) * 100);
if (currentPercent > lastReportedPercent) { if (currentPercent > lastReportedPercent) {
lastReportedPercent = currentPercent; lastReportedPercent = currentPercent;
reportProgress(onProgress, "spectrum", currentPercent, "Analyzing spectrum..."); reportProgress(onProgress, "spectrum", currentPercent, "Analyzing spectrum...");
} }
if ((i + 1) % 8 === 0) { if ((i + 1) % 8 === 0) {
const now = nowMs(); const now = nowMs();
if (now - lastYieldAt >= 16) { if (now - lastYieldAt >= 16) {
@@ -713,7 +611,6 @@ export async function analyzeSpectrumFromSamples(
} }
} }
} }
reportProgress(onProgress, "spectrum", 100, "Spectrum analysis complete"); reportProgress(onProgress, "spectrum", 100, "Spectrum analysis complete");
return { return {
time_slices: timeSlices, time_slices: timeSlices,
@@ -723,63 +620,44 @@ export async function analyzeSpectrumFromSamples(
max_freq: maxFreq, max_freq: maxFreq,
}; };
} }
function createAnalysisAudioContext(sampleRate: number): AudioContext { function createAnalysisAudioContext(sampleRate: number): AudioContext {
if (sampleRate > 0) { if (sampleRate > 0) {
try { try {
return new AudioContext({ sampleRate }); return new AudioContext({ sampleRate });
} catch { }
catch {
return new AudioContext(); return new AudioContext();
} }
} }
return new AudioContext(); return new AudioContext();
} }
export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<FrontendAnalysisPayload> {
export async function analyzeAudioFile(
file: File,
params: SpectrumParams = DEFAULT_PARAMS,
onProgress?: AnalysisProgressCallback,
shouldCancel?: AnalysisCancelCheck,
): Promise<FrontendAnalysisPayload> {
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "read", 2, "Reading file..."); reportProgress(onProgress, "read", 2, "Reading file...");
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "read", 10, "File loaded"); reportProgress(onProgress, "read", 10, "File loaded");
return analyzeAudioArrayBuffer( return analyzeAudioArrayBuffer({
{ fileName: file.name,
fileName: file.name, fileSize: file.size,
fileSize: file.size, arrayBuffer,
arrayBuffer, }, params, (progress) => {
}, const mappedPercent = 10 + (progress.percent * 0.9);
params, reportProgress(onProgress, progress.phase, mappedPercent, progress.message);
(progress) => { }, shouldCancel);
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<FrontendAnalysisPayload> {
export async function analyzeAudioArrayBuffer(
input: AudioArrayBufferInput,
params: SpectrumParams = DEFAULT_PARAMS,
onProgress?: AnalysisProgressCallback,
shouldCancel?: AnalysisCancelCheck,
): Promise<FrontendAnalysisPayload> {
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "parse", 5, "Parsing audio metadata..."); reportProgress(onProgress, "parse", 5, "Parsing audio metadata...");
const metadata = parseAudioMetadata(input); const metadata = parseAudioMetadata(input);
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "decode", 15, "Decoding audio stream..."); reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
const audioContext = createAnalysisAudioContext(metadata.sampleRate); const audioContext = createAnalysisAudioContext(metadata.sampleRate);
try { try {
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0)); const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
reportProgress(onProgress, "decode", 35, "Audio decoded"); reportProgress(onProgress, "decode", 35, "Audio decoded");
const samples = audioBuffer.getChannelData(0); const samples = audioBuffer.getChannelData(0);
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS..."); reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
let peak = 0; let peak = 0;
let sumSquares = 0; let sumSquares = 0;
@@ -788,13 +666,12 @@ export async function analyzeAudioArrayBuffer(
throwIfCancelled(shouldCancel); throwIfCancelled(shouldCancel);
const sample = samples[i]; const sample = samples[i];
const absSample = Math.abs(sample); const absSample = Math.abs(sample);
if (absSample > peak) peak = absSample; if (absSample > peak)
peak = absSample;
sumSquares += sample * sample; sumSquares += sample * sample;
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) { if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) {
const metricsProgress = 40 + (((i + 1) / samples.length) * 10); const metricsProgress = 40 + (((i + 1) / samples.length) * 10);
reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS..."); reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS...");
const now = nowMs(); const now = nowMs();
if (now - lastMetricsYieldAt >= 16) { if (now - lastMetricsYieldAt >= 16) {
await nextTick(); await nextTick();
@@ -803,7 +680,6 @@ export async function analyzeAudioArrayBuffer(
} }
} }
} }
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120; const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0; const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120; const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
@@ -812,21 +688,12 @@ export async function analyzeAudioArrayBuffer(
const totalSamples = metadata.totalSamples > 0 const totalSamples = metadata.totalSamples > 0
? metadata.totalSamples ? metadata.totalSamples
: Math.floor(duration * metadata.sampleRate); : Math.floor(duration * metadata.sampleRate);
reportProgress(onProgress, "metrics", 50, "Signal metrics complete"); reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
const spectrum = await analyzeSpectrumFromSamples( const spectrum = await analyzeSpectrumFromSamples(samples, metadata.sampleRate, params, (progress) => {
samples, const mappedPercent = 50 + (progress.percent * 0.45);
metadata.sampleRate, reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
params, }, shouldCancel);
(progress) => {
const mappedPercent = 50 + (progress.percent * 0.45);
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
},
shouldCancel,
);
reportProgress(onProgress, "finalize", 97, "Finalizing result..."); reportProgress(onProgress, "finalize", 97, "Finalizing result...");
const payload: FrontendAnalysisPayload = { const payload: FrontendAnalysisPayload = {
result: { result: {
file_path: input.fileName, file_path: input.fileName,
@@ -849,13 +716,12 @@ export async function analyzeAudioArrayBuffer(
}, },
samples, samples,
}; };
reportProgress(onProgress, "finalize", 100, "Analysis complete"); reportProgress(onProgress, "finalize", 100, "Analysis complete");
return payload; return payload;
} finally { }
finally {
await audioContext.close(); await audioContext.close();
} }
} }
export const analyzeFlacFile = analyzeAudioFile; export const analyzeFlacFile = analyzeAudioFile;
export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer; export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;