Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

153 lines
5.7 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Activity, FileAudio, Gauge, Trash2, Waves } from "lucide-react";
import { Button } from "../components/Button";
import { FileDropzone } from "../components/FileDropzone";
import { analyzeAudioFile, type AudioAnalysis } from "../helpers/audioAnalyzer";
import styles from "./analyze.module.css";
import { MobileNav } from "../components/MobileNav";
type AnalysisItem = {
id: string;
file: File;
status: "analyzing" | "done" | "error";
analysis?: AudioAnalysis;
error?: string;
};
function formatHz(value: number) {
return value >= 1000 ? `${(value / 1000).toFixed(1)} kHz` : `${Math.round(value)} Hz`;
}
function formatDuration(value: number) {
const seconds = Math.max(0, Math.round(value));
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
function Spectrogram({ data }: { data: number[][] }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas || !data.length || !data[0]?.length) return;
const width = data.length;
const height = data[0].length;
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return;
const image = context.createImageData(width, height);
for (let x = 0; x < width; x += 1) {
for (let y = 0; y < height; y += 1) {
const value = data[x][height - 1 - y] || 0;
const index = (y * width + x) * 4;
image.data[index] = Math.round(20 + 30 * value);
image.data[index + 1] = Math.round(30 + 190 * value);
image.data[index + 2] = Math.round(45 + 205 * value);
image.data[index + 3] = 255;
}
}
context.putImageData(image, 0, 0);
}, [data]);
return <canvas ref={ref} className={styles.spectrogram} aria-label="Audio spectrogram" />;
}
export default function AnalyzePage() {
const [items, setItems] = useState<AnalysisItem[]>([]);
const [working, setWorking] = useState(false);
async function addFiles(files: File[]) {
const added = files.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
file,
status: "analyzing" as const,
}));
setItems((current) => [
...current,
...added.filter((entry) => !current.some((item) => item.id === entry.id)),
]);
setWorking(true);
for (const entry of added) {
try {
const analysis = await analyzeAudioFile(entry.file);
setItems((current) =>
current.map((item) =>
item.id === entry.id ? { ...item, status: "done", analysis } : item,
),
);
} catch (reason) {
setItems((current) =>
current.map((item) =>
item.id === entry.id
? {
...item,
status: "error",
error: reason instanceof Error ? reason.message : "Analysis failed.",
}
: item,
),
);
}
}
setWorking(false);
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<span>Audio Analyzer</span>
</header>
<section className={styles.heading}>
<div>
<span>LOCAL AUDIO TOOLS</span>
<h1>Audio Analyzer</h1>
<p>Inspect frequency content and loudness statistics entirely on this device.</p>
</div>
{items.length ? (
<Button variant="outline" onClick={() => setItems([])} disabled={working}>
<Trash2 size={15} /> Clear all
</Button>
) : null}
</section>
<section className={styles.dropPanel}>
<FileDropzone
accept=".mp3,.wav,.m4a,.aac,.ogg,.flac,audio/*"
maxFiles={30}
maxSize={500 * 1024 * 1024}
onFilesSelected={addFiles}
icon={<Waves size={38} />}
title="Drop audio files to analyze"
subtitle="Spectrogram, peak, RMS, crest factor and estimated frequency cutoff"
/>
</section>
<section className={styles.results}>
{items.map((item) => (
<article className={styles.card} key={item.id}>
<div className={styles.fileHeader}>
<FileAudio size={18} />
<div><strong>{item.file.name}</strong><span>{(item.file.size / 1024 / 1024).toFixed(1)} MB</span></div>
<span className={styles.status}>{item.status === "analyzing" ? "Analyzing…" : item.status === "error" ? "Error" : item.analysis?.qualityLabel}</span>
</div>
{item.error ? <div className={styles.error}>{item.error}</div> : null}
{item.analysis ? (
<div className={styles.analysis}>
<Spectrogram data={item.analysis.spectrogram} />
<div className={styles.metrics}>
<div><Activity size={15} /><span>Peak</span><strong>{item.analysis.peakDbfs.toFixed(1)} dBFS</strong></div>
<div><Activity size={15} /><span>RMS</span><strong>{item.analysis.rmsDbfs.toFixed(1)} dBFS</strong></div>
<div><Gauge size={15} /><span>Crest</span><strong>{item.analysis.crestFactorDb.toFixed(1)} dB</strong></div>
<div><Waves size={15} /><span>Cutoff</span><strong>{formatHz(item.analysis.estimatedCutoffHz)}</strong></div>
<div><Gauge size={15} /><span>Sample rate</span><strong>{formatHz(item.analysis.sampleRate)}</strong></div>
<div><FileAudio size={15} /><span>Duration</span><strong>{formatDuration(item.analysis.durationSeconds)}</strong></div>
</div>
</div>
) : null}
</article>
))}
</section>
<MobileNav />
</main>
);
}