.remake audio quality analyzer
This commit is contained in:
@@ -140,7 +140,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
||||
|
||||
{result.spectrum && (() => {
|
||||
const frames = result.spectrum.time_slices.length;
|
||||
const fftSize = result.spectrum.freq_bins * 2;
|
||||
const fftSize = (result.spectrum.freq_bins - 1) * 2;
|
||||
const freqRes = result.sample_rate / fftSize;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,27 +1,118 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
interface AudioAnalysisPageProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading, reAnalyzeSpectrum } =
|
||||
useAudioAnalysis();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
function isFlacPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".flac");
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!selectedFilePath || !spectrumRef.current)
|
||||
function isFlacFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith(".flac") ||
|
||||
file.type === "audio/flac" ||
|
||||
file.type === "audio/x-flac"
|
||||
);
|
||||
}
|
||||
|
||||
function isAbsolutePath(filePath: string): boolean {
|
||||
return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath);
|
||||
}
|
||||
|
||||
function fileNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || filePath;
|
||||
}
|
||||
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const {
|
||||
analyzing,
|
||||
result,
|
||||
analyzeFile,
|
||||
analyzeFilePath,
|
||||
clearResult,
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
reAnalyzeSpectrum,
|
||||
} = useAudioAnalysis();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null);
|
||||
|
||||
const analyzeSelectedPath = useCallback(async (filePath: string) => {
|
||||
if (!isFlacPath(filePath)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: "Please select a FLAC file for analysis",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await analyzeFilePath(filePath);
|
||||
}, [analyzeFilePath]);
|
||||
|
||||
const analyzeSelectedFile = useCallback(async (file: File) => {
|
||||
if (!isFlacFile(file)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: "Please select a FLAC file for analysis",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await analyzeFile(file);
|
||||
}, [analyzeFile]);
|
||||
|
||||
const handleSelectFile = useCallback(async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
await analyzeSelectedPath(filePath);
|
||||
} catch {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [analyzeSelectedPath]);
|
||||
|
||||
const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
await analyzeSelectedFile(file);
|
||||
e.target.value = "";
|
||||
}, [analyzeSelectedFile]);
|
||||
|
||||
const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
await analyzeSelectedFile(file);
|
||||
}, [analyzeSelectedFile]);
|
||||
|
||||
useEffect(() => {
|
||||
OnFileDrop((_x, _y, paths) => {
|
||||
setIsDragging(false);
|
||||
const droppedPath = paths?.[0];
|
||||
if (!droppedPath) return;
|
||||
void analyzeSelectedPath(droppedPath);
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [analyzeSelectedPath]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!spectrumRef.current) return;
|
||||
|
||||
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||
if (!dataUrl) {
|
||||
@@ -31,68 +122,51 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||
if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
|
||||
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||
toast.success("Exported Successfully", {
|
||||
description: `Saved to: ${outPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const base = selectedFilePath
|
||||
? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "")
|
||||
: "spectrogram";
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = `${base}_spectrogram.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
toast.success("Exported Successfully", {
|
||||
description: `Saved to: ${outPath}`,
|
||||
description: "Spectrogram image downloaded",
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
toast.error("Export Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to save image",
|
||||
description: err instanceof Error ? err.message : "Failed to export image",
|
||||
});
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
await analyzeFile(filePath);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select file",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||
setIsDragging(false);
|
||||
if (paths.length === 0)
|
||||
return;
|
||||
const filePath = paths[0];
|
||||
if (!filePath.toLowerCase().endsWith(".flac")) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: "Please drop a FLAC file for analysis",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await analyzeFile(filePath);
|
||||
}, [analyzeFile]);
|
||||
|
||||
useEffect(() => {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
}, [selectedFilePath]);
|
||||
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
};
|
||||
|
||||
const fileName = selectedFilePath
|
||||
? selectedFilePath.split(/[/\\]/).pop()
|
||||
: undefined;
|
||||
const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".flac,audio/flac,audio/x-flac"
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (
|
||||
@@ -123,13 +197,21 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
|
||||
{!result && !analyzing && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||
onDrop={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
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">
|
||||
<Upload className="h-8 w-8 text-primary" />
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
loadAudioAnalysisPreferences,
|
||||
saveAudioAnalysisPreferences,
|
||||
type AnalyzerColorScheme,
|
||||
type AnalyzerFreqScale,
|
||||
type AnalyzerWindowFunction,
|
||||
} from "@/lib/audio-analysis-preferences";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,326 +29,457 @@ interface SpectrumVisualizationProps {
|
||||
isAnalyzingSpectrum?: boolean;
|
||||
}
|
||||
|
||||
type ColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale";
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
type FreqScale = AnalyzerFreqScale;
|
||||
type WindowFunction = AnalyzerWindowFunction;
|
||||
|
||||
function getColor(intensity: number, scheme: ColorScheme): string {
|
||||
const t = Math.max(0, Math.min(1, intensity));
|
||||
switch (scheme) {
|
||||
case "spek":
|
||||
return spekColor(t);
|
||||
case "viridis":
|
||||
return viridisColor(t);
|
||||
case "hot":
|
||||
return hotColor(t);
|
||||
case "cool":
|
||||
return coolColor(t);
|
||||
case "grayscale": {
|
||||
const v = Math.round(t * 255);
|
||||
return `rgb(${v},${v},${v})`;
|
||||
}
|
||||
default:
|
||||
return spekColor(t);
|
||||
}
|
||||
const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
|
||||
const CANVAS_W = 1100;
|
||||
const CANVAS_H = 600;
|
||||
const MAX_RENDER_HEIGHT = 1080;
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function getColorRGB(intensity: number, scheme: ColorScheme): [number, number, number] {
|
||||
const t = Math.max(0, Math.min(1, intensity));
|
||||
const css = getColor(t, scheme);
|
||||
const m = css.match(/\d+/g)!;
|
||||
return [parseInt(m[0]), parseInt(m[1]), parseInt(m[2])];
|
||||
function spekColorMap(t: number): [number, number, number] {
|
||||
const colors: Array<[number, number, number]> = [
|
||||
[0, 0, 0],
|
||||
[0, 0, 25],
|
||||
[0, 0, 50],
|
||||
[0, 0, 80],
|
||||
[20, 0, 120],
|
||||
[50, 0, 150],
|
||||
[80, 0, 180],
|
||||
[120, 0, 120],
|
||||
[150, 0, 80],
|
||||
[180, 0, 40],
|
||||
[210, 0, 0],
|
||||
[240, 30, 0],
|
||||
[255, 60, 0],
|
||||
[255, 100, 0],
|
||||
[255, 140, 0],
|
||||
[255, 180, 0],
|
||||
[255, 210, 0],
|
||||
[255, 235, 0],
|
||||
[255, 250, 50],
|
||||
[255, 255, 100],
|
||||
[255, 255, 150],
|
||||
[255, 255, 200],
|
||||
[255, 255, 255],
|
||||
];
|
||||
|
||||
const scaled = t * (colors.length - 1);
|
||||
const idx = Math.floor(scaled);
|
||||
const fraction = scaled - idx;
|
||||
|
||||
if (idx >= colors.length - 1) {
|
||||
return colors[colors.length - 1];
|
||||
}
|
||||
|
||||
const c1 = colors[idx];
|
||||
const c2 = colors[idx + 1];
|
||||
return [
|
||||
Math.round(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||
Math.round(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||
Math.round(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||
];
|
||||
}
|
||||
|
||||
function spekColor(t: number): string {
|
||||
if (t < 0.08) {
|
||||
const v = t / 0.08;
|
||||
return `rgb(0,0,${Math.round(v * 80)})`;
|
||||
}
|
||||
if (t < 0.18) {
|
||||
const v = (t - 0.08) / 0.10;
|
||||
return `rgb(${Math.round(v * 50)},${Math.round(v * 30)},${Math.round(80 + v * 175)})`;
|
||||
}
|
||||
if (t < 0.28) {
|
||||
const v = (t - 0.18) / 0.10;
|
||||
return `rgb(${Math.round(50 + v * 150)},${Math.round(30 - v * 30)},${Math.round(255 - v * 55)})`;
|
||||
}
|
||||
if (t < 0.40) {
|
||||
const v = (t - 0.28) / 0.12;
|
||||
return `rgb(${Math.round(200 + v * 55)},0,${Math.round(200 - v * 200)})`;
|
||||
}
|
||||
if (t < 0.52) {
|
||||
const v = (t - 0.40) / 0.12;
|
||||
return `rgb(255,${Math.round(v * 100)},0)`;
|
||||
}
|
||||
if (t < 0.65) {
|
||||
const v = (t - 0.52) / 0.13;
|
||||
return `rgb(255,${Math.round(100 + v * 80)},0)`;
|
||||
}
|
||||
if (t < 0.78) {
|
||||
const v = (t - 0.65) / 0.13;
|
||||
return `rgb(255,${Math.round(180 + v * 55)},${Math.round(v * 30)})`;
|
||||
}
|
||||
if (t < 0.90) {
|
||||
const v = (t - 0.78) / 0.12;
|
||||
return `rgb(255,${Math.round(235 + v * 20)},${Math.round(30 + v * 100)})`;
|
||||
}
|
||||
const v = (t - 0.90) / 0.10;
|
||||
return `rgb(255,255,${Math.round(130 + v * 125)})`;
|
||||
}
|
||||
|
||||
function viridisColor(t: number): string {
|
||||
const stops: [number, number, number][] = [
|
||||
function viridisColorMap(t: number): [number, number, number] {
|
||||
const colors: Array<[number, number, number]> = [
|
||||
[68, 1, 84],
|
||||
[72, 36, 117],
|
||||
[70, 20, 100],
|
||||
[72, 40, 120],
|
||||
[67, 62, 133],
|
||||
[62, 74, 137],
|
||||
[55, 89, 140],
|
||||
[49, 104, 142],
|
||||
[43, 117, 142],
|
||||
[38, 130, 142],
|
||||
[35, 144, 140],
|
||||
[31, 158, 137],
|
||||
[42, 171, 129],
|
||||
[53, 183, 121],
|
||||
[110, 206, 88],
|
||||
[181, 222, 43],
|
||||
[81, 194, 105],
|
||||
[109, 205, 89],
|
||||
[144, 214, 67],
|
||||
[180, 222, 44],
|
||||
[216, 227, 41],
|
||||
[253, 231, 37],
|
||||
];
|
||||
const i = t * (stops.length - 1);
|
||||
const lo = Math.floor(i);
|
||||
const hi = Math.min(lo + 1, stops.length - 1);
|
||||
const f = i - lo;
|
||||
const [r, g, b] = stops[lo].map((v, k) => Math.round(v + (stops[hi][k] - v) * f)) as [number, number, number];
|
||||
|
||||
const scaled = t * (colors.length - 1);
|
||||
const idx = Math.floor(scaled);
|
||||
const fraction = scaled - idx;
|
||||
|
||||
if (idx >= colors.length - 1) {
|
||||
return colors[colors.length - 1];
|
||||
}
|
||||
|
||||
const c1 = colors[idx];
|
||||
const c2 = colors[idx + 1];
|
||||
return [
|
||||
Math.floor(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||
Math.floor(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||
Math.floor(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||
];
|
||||
}
|
||||
|
||||
function hotColorMap(t: number): [number, number, number] {
|
||||
if (t < 0.33) {
|
||||
return [Math.floor(t * 3 * 255), 0, 0];
|
||||
}
|
||||
if (t < 0.66) {
|
||||
return [255, Math.floor((t - 0.33) * 3 * 255), 0];
|
||||
}
|
||||
return [255, 255, Math.floor((t - 0.66) * 3 * 255)];
|
||||
}
|
||||
|
||||
function coolColorMap(t: number): [number, number, number] {
|
||||
return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255];
|
||||
}
|
||||
|
||||
function getColorValues(norm: number, scheme: ColorScheme): [number, number, number] {
|
||||
const value = clamp01(norm);
|
||||
switch (scheme) {
|
||||
case "spek":
|
||||
return spekColorMap(value);
|
||||
case "viridis":
|
||||
return viridisColorMap(value);
|
||||
case "hot":
|
||||
return hotColorMap(value);
|
||||
case "cool":
|
||||
return coolColorMap(value);
|
||||
case "grayscale":
|
||||
default: {
|
||||
const gray = Math.floor(value * 255);
|
||||
return [gray, gray, gray];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getColorString(norm: number, scheme: ColorScheme): string {
|
||||
const [r, g, b] = getColorValues(norm, scheme);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function hotColor(t: number): string {
|
||||
if (t < 0.33) {
|
||||
return `rgb(${Math.round(t / 0.33 * 255)},0,0)`;
|
||||
function addAxisLabels(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
plotWidth: number,
|
||||
plotHeight: number,
|
||||
sampleRate: number,
|
||||
duration: number,
|
||||
freqScale: FreqScale,
|
||||
fileName?: string,
|
||||
) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "12px Segoe UI";
|
||||
|
||||
ctx.textAlign = "center";
|
||||
const widthFactor = plotWidth / 1000;
|
||||
let timeStep: number;
|
||||
|
||||
if (duration <= 10) {
|
||||
timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5);
|
||||
} else if (duration <= 30) {
|
||||
timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1);
|
||||
} else if (duration <= 120) {
|
||||
timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5);
|
||||
} else if (duration <= 600) {
|
||||
timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20);
|
||||
} else {
|
||||
timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40);
|
||||
}
|
||||
if (t < 0.67) {
|
||||
return `rgb(255,${Math.round((t - 0.33) / 0.34 * 255)},0)`;
|
||||
|
||||
if (duration > 0) {
|
||||
for (let time = 0; time <= duration + 1e-9; time += timeStep) {
|
||||
const timeProgress = time / duration;
|
||||
const x = MARGIN.left + timeProgress * (plotWidth - 1);
|
||||
const y = CANVAS_H - MARGIN.bottom + 20;
|
||||
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, MARGIN.top + plotHeight);
|
||||
ctx.lineTo(x, MARGIN.top + plotHeight + 5);
|
||||
ctx.stroke();
|
||||
|
||||
let label: string;
|
||||
if (timeStep >= 60) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
|
||||
} else {
|
||||
label = `${time}s`;
|
||||
}
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
return `rgb(255,255,${Math.round((t - 0.67) / 0.33 * 255)})`;
|
||||
|
||||
ctx.textAlign = "right";
|
||||
const maxFreq = sampleRate / 2;
|
||||
|
||||
if (freqScale === "log2") {
|
||||
const heightFactor = plotHeight / 500;
|
||||
const minFreq = 20;
|
||||
const frequencies: number[] = [];
|
||||
const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2);
|
||||
|
||||
let octaveCount = 0;
|
||||
for (let freq = minFreq; freq <= maxFreq; freq *= 2) {
|
||||
if (octaveCount % octaveStep === 0) {
|
||||
frequencies.push(freq);
|
||||
}
|
||||
octaveCount++;
|
||||
}
|
||||
|
||||
for (const freq of frequencies) {
|
||||
const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq);
|
||||
const y = MARGIN.top + plotHeight * (1 - freqNormalized);
|
||||
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN.left - 5, y);
|
||||
ctx.lineTo(MARGIN.left, y);
|
||||
ctx.stroke();
|
||||
|
||||
const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`;
|
||||
ctx.fillText(label, MARGIN.left - 10, y + 4);
|
||||
}
|
||||
} else {
|
||||
const heightFactor = plotHeight / 500;
|
||||
let freqStep: number;
|
||||
|
||||
if (maxFreq <= 8000) {
|
||||
freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500);
|
||||
} else if (maxFreq <= 16000) {
|
||||
freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000);
|
||||
} else if (maxFreq <= 24000) {
|
||||
freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000);
|
||||
} else {
|
||||
freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000);
|
||||
}
|
||||
|
||||
for (let freq = 0; freq <= maxFreq; freq += freqStep) {
|
||||
const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4;
|
||||
const x = MARGIN.left - 15;
|
||||
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN.left - 5, y - 4);
|
||||
ctx.lineTo(MARGIN.left, y - 4);
|
||||
ctx.stroke();
|
||||
|
||||
let label: string;
|
||||
if (freq === 0) {
|
||||
label = "0";
|
||||
} else if (freq >= 1000) {
|
||||
label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`;
|
||||
} else {
|
||||
label = `${freq}`;
|
||||
}
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = "14px Segoe UI";
|
||||
ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(25, CANVAS_H / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
ctx.font = "12px Segoe UI";
|
||||
if (fileName) {
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(fileName, MARGIN.left + 15, 25);
|
||||
}
|
||||
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25);
|
||||
}
|
||||
|
||||
function coolColor(t: number): string {
|
||||
if (t < 0.33) {
|
||||
return `rgb(0,0,${Math.round(128 + t / 0.33 * 127)})`;
|
||||
function drawColorBar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
plotHeight: number,
|
||||
colorScheme: ColorScheme,
|
||||
) {
|
||||
const colorBarWidth = 20;
|
||||
const colorBarX = CANVAS_W - MARGIN.right + 30;
|
||||
const colorBarY = MARGIN.top;
|
||||
const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY);
|
||||
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const value = i / 100;
|
||||
gradient.addColorStop(value, getColorString(value, colorScheme));
|
||||
}
|
||||
if (t < 0.67) {
|
||||
return `rgb(0,${Math.round((t - 0.33) / 0.34 * 255)},255)`;
|
||||
}
|
||||
return `rgb(${Math.round((t - 0.67) / 0.33 * 255)},255,255)`;
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "10px Segoe UI";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12);
|
||||
ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5);
|
||||
}
|
||||
|
||||
type FreqScale = "linear" | "log2";
|
||||
|
||||
const MARGIN = { top: 50, right: 100, bottom: 50, left: 80 };
|
||||
const CANVAS_W = 1200;
|
||||
const CANVAS_H = 600;
|
||||
|
||||
function renderSpectrogram(
|
||||
async function renderSpectrogram(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
spectrum: SpectrumData,
|
||||
sampleRate: number,
|
||||
duration: number,
|
||||
freqScale: FreqScale,
|
||||
colorScheme: ColorScheme,
|
||||
fileName?: string,
|
||||
fileName: string | undefined,
|
||||
shouldCancel: () => boolean,
|
||||
) {
|
||||
const { top, right, bottom, left } = MARGIN;
|
||||
const pw = CANVAS_W - left - right;
|
||||
const ph = CANVAS_H - top - bottom;
|
||||
const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right;
|
||||
const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom;
|
||||
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
const slices = spectrum.time_slices;
|
||||
if (!slices || slices.length === 0)
|
||||
const spectrogramData = spectrum.time_slices;
|
||||
const numTimeFrames = spectrogramData.length;
|
||||
const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0;
|
||||
if (numTimeFrames === 0 || numFreqBins === 0) {
|
||||
return;
|
||||
|
||||
const numT = slices.length;
|
||||
const numF = slices[0].magnitudes.length;
|
||||
const maxFreq = spectrum.max_freq;
|
||||
|
||||
let minDB = Infinity;
|
||||
let maxDB = -Infinity;
|
||||
for (const s of slices) {
|
||||
for (const v of s.magnitudes) {
|
||||
if (v > maxDB)
|
||||
maxDB = v;
|
||||
if (v < minDB && v > -200)
|
||||
minDB = v;
|
||||
}
|
||||
}
|
||||
minDB = Math.max(minDB, maxDB - 90);
|
||||
const dbRange = maxDB - minDB;
|
||||
|
||||
const img = ctx.createImageData(pw, ph);
|
||||
const data = img.data;
|
||||
let minMag = Number.POSITIVE_INFINITY;
|
||||
let maxMag = Number.NEGATIVE_INFINITY;
|
||||
const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1;
|
||||
|
||||
for (let x = 0; x < pw; x++) {
|
||||
const tProgress = x / (pw - 1);
|
||||
const tExact = tProgress * (numT - 1);
|
||||
const t0 = Math.floor(tExact);
|
||||
const t1 = Math.min(t0 + 1, numT - 1);
|
||||
const tf = tExact - t0;
|
||||
const frame0 = slices[t0].magnitudes;
|
||||
const frame1 = slices[t1].magnitudes;
|
||||
|
||||
for (let y = 0; y < ph; y++) {
|
||||
let fProgress = (ph - 1 - y) / (ph - 1);
|
||||
|
||||
if (freqScale === "log2") {
|
||||
const minF = 20;
|
||||
const octaves = Math.log2(maxFreq / minF);
|
||||
const freq = minF * Math.pow(2, fProgress * octaves);
|
||||
fProgress = freq / maxFreq;
|
||||
for (let i = 0; i < numTimeFrames; i += sampleStep) {
|
||||
const frame = spectrogramData[i].magnitudes;
|
||||
for (const mag of frame) {
|
||||
if (Number.isFinite(mag)) {
|
||||
if (mag < minMag)
|
||||
minMag = mag;
|
||||
if (mag > maxMag)
|
||||
maxMag = mag;
|
||||
}
|
||||
|
||||
const fExact = fProgress * (numF - 1);
|
||||
const f0 = Math.floor(fExact);
|
||||
const f1 = Math.min(f0 + 1, numF - 1);
|
||||
const ff = fExact - f0;
|
||||
|
||||
const m00 = frame0[f0] ?? minDB;
|
||||
const m01 = frame0[f1] ?? minDB;
|
||||
const m10 = frame1[f0] ?? minDB;
|
||||
const m11 = frame1[f1] ?? minDB;
|
||||
const mag = (m00 * (1 - ff) + m01 * ff) * (1 - tf) + (m10 * (1 - ff) + m11 * ff) * tf;
|
||||
|
||||
const norm = Math.max(0, Math.min(1, (mag - minDB) / dbRange));
|
||||
const [r, g, b] = getColorRGB(norm, colorScheme);
|
||||
const idx = (y * pw + x) * 4;
|
||||
data[idx] = r;
|
||||
data[idx + 1] = g;
|
||||
data[idx + 2] = b;
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(img, left, top);
|
||||
if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) {
|
||||
minMag = -120;
|
||||
maxMag = 0;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#ccc";
|
||||
ctx.font = "12px 'Segoe UI', Arial";
|
||||
const magRange = maxMag - minMag;
|
||||
const safeMagRange = magRange > 0 ? magRange : 1;
|
||||
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT);
|
||||
const highResData = highResImageData.data;
|
||||
const CHUNK_SIZE = 50;
|
||||
|
||||
const freqLabels = buildFreqLabels(maxFreq, freqScale);
|
||||
for (const freq of freqLabels) {
|
||||
if (freq > maxFreq)
|
||||
continue;
|
||||
let yPos: number;
|
||||
if (freqScale === "log2") {
|
||||
const minF = 20;
|
||||
const norm = Math.log2(freq / minF) / Math.log2(maxFreq / minF);
|
||||
yPos = top + ph - norm * ph;
|
||||
} else {
|
||||
yPos = top + ph - (freq / maxFreq) * ph;
|
||||
for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) {
|
||||
if (shouldCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth);
|
||||
for (let x = xStart; x < xEnd; x++) {
|
||||
const timeProgress = x / (plotWidth - 1);
|
||||
const exactTimePos = timeProgress * (numTimeFrames - 1);
|
||||
const timeIdx = Math.floor(exactTimePos);
|
||||
const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1);
|
||||
const timeFrac = exactTimePos - timeIdx;
|
||||
|
||||
const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes;
|
||||
const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1;
|
||||
|
||||
for (let y = 0; y < MAX_RENDER_HEIGHT; y++) {
|
||||
let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1);
|
||||
|
||||
if (freqScale === "log2") {
|
||||
const minFreq = 20;
|
||||
const maxFreq = sampleRate / 2;
|
||||
const octaves = Math.log2(maxFreq / minFreq);
|
||||
const octave = freqProgress * octaves;
|
||||
const freq = minFreq * Math.pow(2, octave);
|
||||
freqProgress = freq / maxFreq;
|
||||
}
|
||||
|
||||
const exactFreqPos = freqProgress * (numFreqBins - 1);
|
||||
const freqIdx = Math.floor(exactFreqPos);
|
||||
const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1);
|
||||
const freqFrac = exactFreqPos - freqIdx;
|
||||
|
||||
let magnitude: number;
|
||||
if (timeFrac === 0 && freqFrac === 0) {
|
||||
magnitude = frame1[freqIdx] ?? 0;
|
||||
} else {
|
||||
const mag11 = frame1[freqIdx] ?? 0;
|
||||
const mag12 = frame1[freqIdx2] ?? 0;
|
||||
const mag21 = frame2[freqIdx] ?? 0;
|
||||
const mag22 = frame2[freqIdx2] ?? 0;
|
||||
|
||||
const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac;
|
||||
const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac;
|
||||
magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac;
|
||||
}
|
||||
|
||||
const normalizedMag = clamp01((magnitude - minMag) / safeMagRange);
|
||||
const [r, g, b] = getColorValues(normalizedMag, colorScheme);
|
||||
const pixelIdx = (y * plotWidth + x) * 4;
|
||||
highResData[pixelIdx] = r;
|
||||
highResData[pixelIdx + 1] = g;
|
||||
highResData[pixelIdx + 2] = b;
|
||||
highResData[pixelIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
if (xStart + CHUNK_SIZE < plotWidth) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||
ctx.fillText(label, left - 8, yPos);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left - 4, yPos);
|
||||
ctx.lineTo(left + pw, yPos);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const timeStep = smartTimeStep(duration);
|
||||
for (let t = 0; t <= duration; t += timeStep) {
|
||||
const xPos = left + (t / duration) * pw;
|
||||
const label = timeStep >= 60
|
||||
? `${Math.floor(t / 60)}m${t % 60 ? (t % 60) + "s" : ""}`
|
||||
: `${t}s`;
|
||||
ctx.fillText(label, xPos, top + ph + 8);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xPos, top);
|
||||
ctx.lineTo(xPos, top + ph + 4);
|
||||
ctx.stroke();
|
||||
if (shouldCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "13px 'Segoe UI', Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.fillText("Time (seconds)", left + pw / 2, CANVAS_H - 12);
|
||||
const finalImageData = ctx.createImageData(plotWidth, plotHeight);
|
||||
const finalData = finalImageData.data;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(24, top + ph / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
for (let y = 0; y < plotHeight; y++) {
|
||||
for (let x = 0; x < plotWidth; x++) {
|
||||
const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT);
|
||||
const highResIdx = (highResY * plotWidth + x) * 4;
|
||||
const finalIdx = (y * plotWidth + x) * 4;
|
||||
|
||||
ctx.font = "12px 'Segoe UI', Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "#fff";
|
||||
if (fileName)
|
||||
ctx.fillText(fileName, left, 26);
|
||||
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, left + pw, 26);
|
||||
|
||||
const cbX = left + pw + 25;
|
||||
const cbW = 14;
|
||||
for (let i = 0; i < ph; i++) {
|
||||
const norm = 1 - i / ph;
|
||||
ctx.fillStyle = getColor(norm, colorScheme);
|
||||
ctx.fillRect(cbX, top + i, cbW, 1);
|
||||
if (highResIdx < highResData.length) {
|
||||
finalData[finalIdx] = highResData[highResIdx];
|
||||
finalData[finalIdx + 1] = highResData[highResIdx + 1];
|
||||
finalData[finalIdx + 2] = highResData[highResIdx + 2];
|
||||
finalData[finalIdx + 3] = highResData[highResIdx + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.5)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(cbX, top, cbW, ph);
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "10px 'Segoe UI', Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("High", cbX + cbW + 6, top + 6);
|
||||
ctx.fillText("Low", cbX + cbW + 6, top + ph - 6);
|
||||
}
|
||||
|
||||
function buildFreqLabels(maxFreq: number, scale: FreqScale): number[] {
|
||||
if (scale === "log2") {
|
||||
const labels: number[] = [];
|
||||
for (let f = 20; f <= maxFreq; f *= 2)
|
||||
labels.push(f);
|
||||
for (let f = 100; f <= maxFreq; f *= 10)
|
||||
labels.push(f);
|
||||
return [...new Set(labels)].sort((a, b) => a - b);
|
||||
}
|
||||
if (maxFreq <= 24000)
|
||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
||||
if (maxFreq <= 48000)
|
||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
||||
if (maxFreq <= 96000)
|
||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
||||
}
|
||||
|
||||
function smartTimeStep(duration: number): number {
|
||||
if (duration <= 30)
|
||||
return 5;
|
||||
if (duration <= 60)
|
||||
return 10;
|
||||
if (duration <= 120)
|
||||
return 15;
|
||||
if (duration <= 300)
|
||||
return 30;
|
||||
if (duration <= 600)
|
||||
return 60;
|
||||
return 120;
|
||||
ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top);
|
||||
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
|
||||
drawColorBar(ctx, plotHeight, colorScheme);
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: { value: ColorScheme; label: string; gradient: string; }[] = [
|
||||
{ value: "spek", label: "Spek", gradient: "linear-gradient(to right, #000050, #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: "hot", label: "Hot", gradient: "linear-gradient(to right, #000, #f00, #ff0, #fff)" },
|
||||
{ value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" },
|
||||
{ value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" },
|
||||
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000, #fff)" },
|
||||
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" },
|
||||
];
|
||||
|
||||
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({
|
||||
@@ -354,41 +491,37 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
isAnalyzingSpectrum,
|
||||
}, ref) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const preferencesRef = useRef(loadAudioAnalysisPreferences());
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCanvasDataURL: () => {
|
||||
if (!canvasRef.current)
|
||||
return null;
|
||||
return canvasRef.current.toDataURL("image/png");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const [freqScale, setFreqScale] = useState<FreqScale>("linear");
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>("spek");
|
||||
|
||||
const [fftSize, setFftSize] = useState<string>(() => {
|
||||
if (spectrumData && spectrumData.freq_bins) {
|
||||
return String(spectrumData.freq_bins * 2);
|
||||
}
|
||||
return "4096";
|
||||
});
|
||||
const [windowFunction, setWindowFunction] = useState<string>("hann");
|
||||
const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale);
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme);
|
||||
const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize));
|
||||
const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction);
|
||||
|
||||
useEffect(() => {
|
||||
if (spectrumData && spectrumData.freq_bins) {
|
||||
setFftSize(String(spectrumData.freq_bins * 2));
|
||||
if (spectrumData?.freq_bins) {
|
||||
setFftSize(String((spectrumData.freq_bins - 1) * 2));
|
||||
}
|
||||
}, [spectrumData]);
|
||||
|
||||
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
|
||||
setFftSize(newFftSize);
|
||||
setWindowFunction(newWindowFunc);
|
||||
if (onReAnalyze) {
|
||||
onReAnalyze(parseInt(newFftSize), newWindowFunc);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
saveAudioAnalysisPreferences({
|
||||
colorScheme,
|
||||
freqScale,
|
||||
fftSize: Number(fftSize),
|
||||
windowFunction,
|
||||
});
|
||||
}, [colorScheme, freqScale, fftSize, windowFunction]);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas)
|
||||
return;
|
||||
@@ -396,21 +529,41 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
let canceled = false;
|
||||
const shouldCancel = () => canceled;
|
||||
|
||||
if (spectrumData) {
|
||||
renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName);
|
||||
void renderSpectrogram(
|
||||
ctx,
|
||||
spectrumData,
|
||||
sampleRate,
|
||||
duration,
|
||||
freqScale,
|
||||
colorScheme,
|
||||
fileName,
|
||||
shouldCancel,
|
||||
);
|
||||
} else {
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
ctx.fillStyle = "#444";
|
||||
ctx.fillStyle = "#444444";
|
||||
ctx.font = "16px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
|
||||
}
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
|
||||
|
||||
useEffect(() => { draw(); }, [draw]);
|
||||
|
||||
useEffect(() => { draw(); }, [draw]);
|
||||
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
|
||||
setFftSize(newFftSize);
|
||||
setWindowFunction(newWindowFunc as WindowFunction);
|
||||
if (onReAnalyze) {
|
||||
onReAnalyze(parseInt(newFftSize, 10), newWindowFunc);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -422,14 +575,14 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_SCHEMES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{COLOR_SCHEMES.map((scheme) => (
|
||||
<SelectItem key={scheme.value} value={scheme.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-4 w-4 rounded-sm border opacity-90"
|
||||
style={{ backgroundImage: s.gradient }}
|
||||
style={{ backgroundImage: scheme.gradient }}
|
||||
/>
|
||||
<span>{s.label}</span>
|
||||
<span>{scheme.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -441,8 +594,8 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Freq Scale:</Label>
|
||||
<Select value={freqScale} onValueChange={(v) => { if (v) setFreqScale(v as FreqScale); }} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[90px] text-sm">
|
||||
<Select value={freqScale} onValueChange={(v) => setFreqScale(v as FreqScale)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[95px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -470,7 +623,7 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Window:</Label>
|
||||
<Select value={windowFunction} onValueChange={(v) => handleReAnalyze(fftSize, v)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[115px] text-sm capitalize">
|
||||
<SelectTrigger className="h-8 w-[120px] text-sm capitalize">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -1,162 +1,199 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { AnalyzeTrack, AnalyzeSpectrumWithParams } from "../../wailsjs/go/main/App";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
|
||||
const STORAGE_KEY = "spotiflac_audio_analysis_state";
|
||||
import { analyzeFlacArrayBuffer, analyzeFlacFile, analyzeSpectrumFromSamples } from "@/lib/flac-analysis";
|
||||
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
|
||||
|
||||
type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||
|
||||
function toWindowFunction(value: string): WindowFunction {
|
||||
switch (value) {
|
||||
case "hamming":
|
||||
case "blackman":
|
||||
case "rectangular":
|
||||
return value;
|
||||
case "hann":
|
||||
default:
|
||||
return "hann";
|
||||
}
|
||||
}
|
||||
|
||||
function fileNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || filePath;
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const clean = base64.includes(",") ? base64.split(",")[1] : base64;
|
||||
const binary = atob(clean);
|
||||
const len = binary.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
let sessionResult: AnalysisResult | null = null;
|
||||
let sessionSelectedFilePath = "";
|
||||
let sessionError: string | null = null;
|
||||
let sessionSamples: Float32Array | null = null;
|
||||
|
||||
export function useAudioAnalysis() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
return {
|
||||
...parsed.result,
|
||||
spectrum: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to load saved analysis state:", err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.filePath || "";
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const analyzeFile = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
setError("No file path provided");
|
||||
const [result, setResult] = useState<AnalysisResult | null>(() => sessionResult);
|
||||
const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath);
|
||||
const [error, setError] = useState<string | null>(() => sessionError);
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(false);
|
||||
const samplesRef = useRef<Float32Array | null>(sessionSamples);
|
||||
|
||||
const setResultWithSession = useCallback((next: AnalysisResult | null) => {
|
||||
sessionResult = next;
|
||||
setResult(next);
|
||||
}, []);
|
||||
|
||||
const setSelectedFilePathWithSession = useCallback((next: string) => {
|
||||
sessionSelectedFilePath = next;
|
||||
setSelectedFilePath(next);
|
||||
}, []);
|
||||
|
||||
const setErrorWithSession = useCallback((next: string | null) => {
|
||||
sessionError = next;
|
||||
setError(next);
|
||||
}, []);
|
||||
|
||||
const analyzeFile = useCallback(async (file: File) => {
|
||||
if (!file) {
|
||||
setErrorWithSession("No file provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
setAnalyzing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setSelectedFilePath(filePath);
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(file.name);
|
||||
|
||||
try {
|
||||
logger.info(`Analyzing audio file: ${filePath}`);
|
||||
const startTime = Date.now();
|
||||
const response = await AnalyzeTrack(filePath);
|
||||
const analysisResult: AnalysisResult = JSON.parse(response);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
logger.info(`Analyzing audio file (frontend): ${file.name}`);
|
||||
const start = Date.now();
|
||||
const prefs = loadAudioAnalysisPreferences();
|
||||
const payload = await analyzeFlacFile(file, {
|
||||
fftSize: prefs.fftSize,
|
||||
windowFunction: prefs.windowFunction,
|
||||
});
|
||||
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
setResultWithSession(payload.result);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
if (analysisResult.spectrum) {
|
||||
setSpectrumCache(filePath, analysisResult.spectrum);
|
||||
}
|
||||
const { spectrum, ...detailResult } = analysisResult;
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
filePath,
|
||||
result: detailResult,
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to save analysis state:", err);
|
||||
}
|
||||
setResult(analysisResult);
|
||||
setSpectrumLoading(false);
|
||||
return analysisResult;
|
||||
}
|
||||
catch (err) {
|
||||
return payload.result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
setError(errorMessage);
|
||||
setErrorWithSession(errorMessage);
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}, []);
|
||||
const clearResult = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setSelectedFilePath("");
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
|
||||
const analyzeFilePath = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
setErrorWithSession("No file path provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
setAnalyzing(true);
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(filePath);
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
clearSpectrumCache();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
let rafId: number;
|
||||
const loadSpectrum = () => {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
||||
if (cachedSpectrum) {
|
||||
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
else {
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(loadSpectrum);
|
||||
});
|
||||
return () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
logger.info(`Analyzing audio file (frontend from path): ${filePath}`);
|
||||
const start = Date.now();
|
||||
const prefs = loadAudioAnalysisPreferences();
|
||||
|
||||
const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as
|
||||
| ((path: string) => Promise<string>)
|
||||
| undefined;
|
||||
if (!readFileAsBase64) {
|
||||
throw new Error("ReadFileAsBase64 backend method is unavailable");
|
||||
}
|
||||
};
|
||||
}, [result, selectedFilePath, spectrumLoading]);
|
||||
|
||||
const base64Data = await readFileAsBase64(filePath);
|
||||
const arrayBuffer = base64ToArrayBuffer(base64Data);
|
||||
const fileName = fileNameFromPath(filePath);
|
||||
const payload = await analyzeFlacArrayBuffer(
|
||||
{
|
||||
fileName,
|
||||
fileSize: arrayBuffer.byteLength,
|
||||
arrayBuffer,
|
||||
},
|
||||
{
|
||||
fftSize: prefs.fftSize,
|
||||
windowFunction: prefs.windowFunction,
|
||||
},
|
||||
);
|
||||
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
setResultWithSession(payload.result);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
return payload.result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
setErrorWithSession(errorMessage);
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
|
||||
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!selectedFilePath || !result)
|
||||
return;
|
||||
if (!result || !samplesRef.current) return;
|
||||
|
||||
setSpectrumLoading(true);
|
||||
try {
|
||||
const response = await AnalyzeSpectrumWithParams(selectedFilePath, fftSize, windowFunction);
|
||||
const spectrumData = JSON.parse(response);
|
||||
setResult(prev => prev ? { ...prev, spectrum: spectrumData } : null);
|
||||
setSpectrumCache(selectedFilePath, spectrumData);
|
||||
}
|
||||
catch (err) {
|
||||
const spectrum = analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
|
||||
fftSize,
|
||||
windowFunction: toWindowFunction(windowFunction),
|
||||
});
|
||||
setResult((prev) => {
|
||||
const next = prev ? { ...prev, spectrum } : prev;
|
||||
sessionResult = next;
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
|
||||
logger.error(`Spectrum re-analysis error: ${errorMessage}`);
|
||||
toast.error("Spectrum Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
}, [selectedFilePath, result]);
|
||||
}, [result]);
|
||||
|
||||
const clearResult = useCallback(() => {
|
||||
setResultWithSession(null);
|
||||
setErrorWithSession(null);
|
||||
setSelectedFilePathWithSession("");
|
||||
setSpectrumLoading(false);
|
||||
samplesRef.current = null;
|
||||
sessionSamples = null;
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
|
||||
return {
|
||||
analyzing,
|
||||
@@ -165,6 +202,7 @@ export function useAudioAnalysis() {
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
analyzeFile,
|
||||
analyzeFilePath,
|
||||
reAnalyzeSpectrum,
|
||||
clearResult,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export type AnalyzerColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale";
|
||||
export type AnalyzerFreqScale = "linear" | "log2";
|
||||
export type AnalyzerWindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||
|
||||
export interface AudioAnalysisPreferences {
|
||||
colorScheme: AnalyzerColorScheme;
|
||||
freqScale: AnalyzerFreqScale;
|
||||
fftSize: number;
|
||||
windowFunction: AnalyzerWindowFunction;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "spotiflac_audio_analysis_preferences";
|
||||
|
||||
const DEFAULT_PREFERENCES: AudioAnalysisPreferences = {
|
||||
colorScheme: "spek",
|
||||
freqScale: "linear",
|
||||
fftSize: 4096,
|
||||
windowFunction: "hann",
|
||||
};
|
||||
|
||||
const FFT_SIZE_SET = new Set([512, 1024, 2048, 4096]);
|
||||
|
||||
function toColorScheme(value: unknown): AnalyzerColorScheme {
|
||||
return value === "viridis" || value === "hot" || value === "cool" || value === "grayscale"
|
||||
? value
|
||||
: "spek";
|
||||
}
|
||||
|
||||
function toFreqScale(value: unknown): AnalyzerFreqScale {
|
||||
return value === "log2" ? "log2" : "linear";
|
||||
}
|
||||
|
||||
function toFFTSize(value: unknown): number {
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
return FFT_SIZE_SET.has(num) ? num : 4096;
|
||||
}
|
||||
|
||||
function toWindowFunction(value: unknown): AnalyzerWindowFunction {
|
||||
return value === "hamming" || value === "blackman" || value === "rectangular"
|
||||
? value
|
||||
: "hann";
|
||||
}
|
||||
|
||||
export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw)
|
||||
return DEFAULT_PREFERENCES;
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<AudioAnalysisPreferences>;
|
||||
return {
|
||||
colorScheme: toColorScheme(parsed.colorScheme),
|
||||
freqScale: toFreqScale(parsed.freqScale),
|
||||
fftSize: toFFTSize(parsed.fftSize),
|
||||
windowFunction: toWindowFunction(parsed.windowFunction),
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return DEFAULT_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferences): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
colorScheme: toColorScheme(preferences.colorScheme),
|
||||
freqScale: toFreqScale(preferences.freqScale),
|
||||
fftSize: toFFTSize(preferences.fftSize),
|
||||
windowFunction: toWindowFunction(preferences.windowFunction),
|
||||
}));
|
||||
}
|
||||
catch {
|
||||
// Ignore persistence errors.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { AnalysisResult, SpectrumData, TimeSlice } from "@/types/api";
|
||||
|
||||
export interface SpectrumParams {
|
||||
fftSize: number;
|
||||
windowFunction: "hann" | "hamming" | "blackman" | "rectangular";
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: SpectrumParams = {
|
||||
fftSize: 4096,
|
||||
windowFunction: "hann",
|
||||
};
|
||||
|
||||
interface FlacStreamInfo {
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
bitsPerSample: number;
|
||||
totalSamples: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FrontendAnalysisPayload {
|
||||
result: AnalysisResult;
|
||||
samples: Float32Array;
|
||||
}
|
||||
|
||||
export interface FlacArrayBufferInput {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
arrayBuffer: ArrayBuffer;
|
||||
}
|
||||
|
||||
function parseFlacStreamInfo(buffer: ArrayBuffer): FlacStreamInfo {
|
||||
const data = new Uint8Array(buffer);
|
||||
if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) {
|
||||
throw new Error("Invalid FLAC file");
|
||||
}
|
||||
|
||||
let offset = 4;
|
||||
while (offset + 4 <= data.length) {
|
||||
const blockHeader = data[offset];
|
||||
const blockType = blockHeader & 0x7f;
|
||||
const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||
offset += 4;
|
||||
|
||||
if (offset + blockLength > data.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockType === 0 && blockLength >= 18) {
|
||||
const streamInfo = data.subarray(offset, offset + blockLength);
|
||||
const sampleRate =
|
||||
(streamInfo[10] << 12) |
|
||||
(streamInfo[11] << 4) |
|
||||
(streamInfo[12] >> 4);
|
||||
const channels = ((streamInfo[12] >> 1) & 0x07) + 1;
|
||||
const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1;
|
||||
const totalSamplesBig =
|
||||
(BigInt(streamInfo[13] & 0x0f) << 32n) |
|
||||
(BigInt(streamInfo[14]) << 24n) |
|
||||
(BigInt(streamInfo[15]) << 16n) |
|
||||
(BigInt(streamInfo[16]) << 8n) |
|
||||
BigInt(streamInfo[17]);
|
||||
const totalSamples = Number(totalSamplesBig);
|
||||
const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0;
|
||||
|
||||
return {
|
||||
sampleRate,
|
||||
channels,
|
||||
bitsPerSample,
|
||||
totalSamples,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
offset += blockLength;
|
||||
}
|
||||
|
||||
throw new Error("FLAC STREAMINFO metadata not found");
|
||||
}
|
||||
|
||||
function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
|
||||
const coeffs = new Float32Array(size);
|
||||
if (size <= 1) {
|
||||
coeffs.fill(1);
|
||||
return coeffs;
|
||||
}
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
switch (windowFunction) {
|
||||
case "hamming":
|
||||
coeffs[i] = 0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (size - 1));
|
||||
break;
|
||||
case "blackman":
|
||||
coeffs[i] =
|
||||
0.42 -
|
||||
0.5 * Math.cos((2 * Math.PI * i) / (size - 1)) +
|
||||
0.08 * Math.cos((4 * Math.PI * i) / (size - 1));
|
||||
break;
|
||||
case "rectangular":
|
||||
coeffs[i] = 1;
|
||||
break;
|
||||
case "hann":
|
||||
default:
|
||||
coeffs[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return coeffs;
|
||||
}
|
||||
|
||||
function buildBitReversal(size: number): Uint32Array {
|
||||
let bits = 0;
|
||||
while ((1 << bits) < size)
|
||||
bits++;
|
||||
|
||||
const out = new Uint32Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
let x = i;
|
||||
let rev = 0;
|
||||
for (let b = 0; b < bits; b++) {
|
||||
rev = (rev << 1) | (x & 1);
|
||||
x >>= 1;
|
||||
}
|
||||
out[i] = rev;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32Array): void {
|
||||
const size = real.length;
|
||||
|
||||
for (let i = 1; i < size; i++) {
|
||||
const j = bitReversal[i];
|
||||
if (i < j) {
|
||||
const tr = real[i];
|
||||
real[i] = real[j];
|
||||
real[j] = tr;
|
||||
|
||||
const ti = imag[i];
|
||||
imag[i] = imag[j];
|
||||
imag[j] = ti;
|
||||
}
|
||||
}
|
||||
|
||||
for (let len = 2; len <= size; len <<= 1) {
|
||||
const wLen = (-2 * Math.PI) / len;
|
||||
const wLenReal = Math.cos(wLen);
|
||||
const wLenImag = Math.sin(wLen);
|
||||
|
||||
for (let i = 0; i < size; i += len) {
|
||||
let wReal = 1;
|
||||
let wImag = 0;
|
||||
const half = len >> 1;
|
||||
|
||||
for (let j = 0; j < half; j++) {
|
||||
const uReal = real[i + j];
|
||||
const uImag = imag[i + j];
|
||||
const vReal = real[i + j + half] * wReal - imag[i + j + half] * wImag;
|
||||
const vImag = real[i + j + half] * wImag + imag[i + j + half] * wReal;
|
||||
|
||||
real[i + j] = uReal + vReal;
|
||||
imag[i + j] = uImag + vImag;
|
||||
real[i + j + half] = uReal - vReal;
|
||||
imag[i + j + half] = uImag - vImag;
|
||||
|
||||
const tempReal = wReal * wLenReal - wImag * wLenImag;
|
||||
wImag = wReal * wLenImag + wImag * wLenReal;
|
||||
wReal = tempReal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function analyzeSpectrumFromSamples(
|
||||
samples: Float32Array,
|
||||
sampleRate: number,
|
||||
params: SpectrumParams,
|
||||
): SpectrumData {
|
||||
const fftSize = params.fftSize;
|
||||
const hopSize = Math.max(1, Math.floor(fftSize / 4));
|
||||
const rawWindows = Math.floor((samples.length - fftSize) / hopSize);
|
||||
const numWindows = Math.max(1, rawWindows);
|
||||
const freqBins = Math.floor(fftSize / 2) + 1;
|
||||
const duration = sampleRate > 0 ? samples.length / sampleRate : 0;
|
||||
const maxFreq = sampleRate / 2;
|
||||
|
||||
const windowCoeffs = buildWindowCoefficients(fftSize, params.windowFunction);
|
||||
const bitReversal = buildBitReversal(fftSize);
|
||||
const real = new Float32Array(fftSize);
|
||||
const imag = new Float32Array(fftSize);
|
||||
const invFFTSizeSquared = 1 / (fftSize * fftSize);
|
||||
|
||||
const timeSlices: TimeSlice[] = new Array(numWindows);
|
||||
for (let i = 0; i < numWindows; i++) {
|
||||
const start = i * hopSize;
|
||||
const remaining = samples.length - start;
|
||||
const copyLen = Math.max(0, Math.min(fftSize, remaining));
|
||||
|
||||
for (let j = 0; j < copyLen; j++) {
|
||||
real[j] = samples[start + j] * windowCoeffs[j];
|
||||
imag[j] = 0;
|
||||
}
|
||||
for (let j = copyLen; j < fftSize; j++) {
|
||||
real[j] = 0;
|
||||
imag[j] = 0;
|
||||
}
|
||||
|
||||
fftInPlace(real, imag, bitReversal);
|
||||
|
||||
const magnitudes = new Array<number>(freqBins);
|
||||
for (let j = 0; j < freqBins; j++) {
|
||||
const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared;
|
||||
magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120;
|
||||
}
|
||||
|
||||
timeSlices[i] = {
|
||||
time: sampleRate > 0 ? start / sampleRate : 0,
|
||||
magnitudes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
time_slices: timeSlices,
|
||||
sample_rate: sampleRate,
|
||||
freq_bins: freqBins,
|
||||
duration,
|
||||
max_freq: maxFreq,
|
||||
};
|
||||
}
|
||||
|
||||
export async function analyzeFlacFile(
|
||||
file: File,
|
||||
params: SpectrumParams = DEFAULT_PARAMS,
|
||||
): Promise<FrontendAnalysisPayload> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
return analyzeFlacArrayBuffer(
|
||||
{
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
arrayBuffer,
|
||||
},
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function analyzeFlacArrayBuffer(
|
||||
input: FlacArrayBufferInput,
|
||||
params: SpectrumParams = DEFAULT_PARAMS,
|
||||
): Promise<FrontendAnalysisPayload> {
|
||||
const streamInfo = parseFlacStreamInfo(input.arrayBuffer);
|
||||
const audioContext = new AudioContext({ sampleRate: streamInfo.sampleRate });
|
||||
|
||||
try {
|
||||
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
|
||||
const samples = audioBuffer.getChannelData(0);
|
||||
|
||||
let peak = 0;
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const sample = samples[i];
|
||||
const absSample = Math.abs(sample);
|
||||
if (absSample > peak)
|
||||
peak = absSample;
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
|
||||
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
|
||||
const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
|
||||
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
||||
const dynamicRange = peakDB - rmsDB;
|
||||
|
||||
const spectrum = analyzeSpectrumFromSamples(samples, streamInfo.sampleRate, params);
|
||||
const durationFromBuffer = audioBuffer.duration;
|
||||
const duration = durationFromBuffer > 0 ? durationFromBuffer : streamInfo.duration;
|
||||
const totalSamples = streamInfo.totalSamples > 0 ? streamInfo.totalSamples : Math.floor(duration * streamInfo.sampleRate);
|
||||
|
||||
return {
|
||||
result: {
|
||||
file_path: input.fileName,
|
||||
file_size: input.fileSize,
|
||||
sample_rate: streamInfo.sampleRate,
|
||||
channels: streamInfo.channels,
|
||||
bits_per_sample: streamInfo.bitsPerSample,
|
||||
total_samples: totalSamples,
|
||||
duration,
|
||||
bit_depth: `${streamInfo.bitsPerSample}-bit`,
|
||||
dynamic_range: dynamicRange,
|
||||
peak_amplitude: peakDB,
|
||||
rms_level: rmsDB,
|
||||
spectrum,
|
||||
},
|
||||
samples,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user