- 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
153 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|