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
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user