Add FLAC audio quality analysis and spectrum visualization (#110)
Introduces backend support for analyzing FLAC audio files, including technical metrics and frequency spectrum extraction. Adds frontend components and hooks for file selection, analysis, and visualization, integrating a new Audio Quality Analyzer dialog into the UI. Updates types and dependencies to support audio analysis features.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Activity,
|
||||
Waves,
|
||||
Radio,
|
||||
TrendingUp,
|
||||
FileAudio,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
interface AudioAnalysisProps {
|
||||
result: AnalysisResult | null;
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
}
|
||||
|
||||
export function AudioAnalysis({
|
||||
result,
|
||||
analyzing,
|
||||
onAnalyze,
|
||||
showAnalyzeButton = true
|
||||
}: AudioAnalysisProps) {
|
||||
if (analyzing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner />
|
||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result && showAnalyzeButton) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground/50" />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verify the true lossless quality of downloaded files
|
||||
</p>
|
||||
</div>
|
||||
{onAnalyze && (
|
||||
<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4" />
|
||||
Analyze Audio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Audio Quality Analysis
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Technical analysis of audio file properties
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-6 space-y-6">
|
||||
|
||||
{/* Technical Specifications */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Radio className="h-3 w-3" />
|
||||
Sample Rate
|
||||
</div>
|
||||
<p className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileAudio className="h-3 w-3" />
|
||||
Bit Depth
|
||||
</div>
|
||||
<p className="font-semibold">{result.bit_depth}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Waves className="h-3 w-3" />
|
||||
Channels
|
||||
</div>
|
||||
<p className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Duration
|
||||
</div>
|
||||
<p className="font-semibold">{formatDuration(result.duration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Range Analysis */}
|
||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Dynamic Range Analysis
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Dynamic Range</p>
|
||||
<p className="font-semibold">{formatNumber(result.dynamic_range)} dB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Peak Level</p>
|
||||
<p className="font-semibold">{formatNumber(result.peak_amplitude)} dB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">RMS Level</p>
|
||||
<p className="font-semibold">{formatNumber(result.rms_level)} dB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Info Footer */}
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total Samples: {result.total_samples.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Activity, Upload, X } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
|
||||
export function AudioAnalysisDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
setSelectedFilePath(filePath);
|
||||
await analyzeFile(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select file",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
clearResult();
|
||||
setSelectedFilePath("");
|
||||
}, 200);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Activity className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto flex flex-col p-6 [&>button]:hidden custom-scrollbar" aria-describedby={undefined}>
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogTitle className="text-sm font-medium">Audio Quality Analyzer</DialogTitle>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Selection */}
|
||||
{!result && !analyzing && (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed rounded-lg">
|
||||
<Activity className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Analyze FLAC Audio Quality</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
|
||||
Upload a FLAC file to verify true lossless quality, view detailed technical specifications, and see the frequency spectrum
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5" />
|
||||
Select FLAC File
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Results */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* File Info */}
|
||||
<div className="p-3 bg-muted/30 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Analyzing file:</p>
|
||||
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
|
||||
</div>
|
||||
|
||||
{/* Spectrum Visualization */}
|
||||
<SpectrumVisualization
|
||||
sampleRate={result.sample_rate}
|
||||
bitsPerSample={result.bits_per_sample}
|
||||
duration={result.duration}
|
||||
spectrumData={result.spectrum}
|
||||
/>
|
||||
|
||||
{/* Detailed Analysis */}
|
||||
<AudioAnalysis
|
||||
result={result}
|
||||
analyzing={analyzing}
|
||||
showAnalyzeButton={false}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button onClick={handleSelectFile} variant="outline">
|
||||
<Upload className="h-4 w-4" />
|
||||
Analyze Another File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{analyzing && !result && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings } from "@/components/Settings";
|
||||
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -72,6 +73,7 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
||||
<p>Report bug or request feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AudioAnalysisDialog />
|
||||
<Settings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
bitsPerSample: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
}
|
||||
|
||||
export function SpectrumVisualization({
|
||||
sampleRate,
|
||||
bitsPerSample,
|
||||
duration,
|
||||
spectrumData,
|
||||
}: SpectrumVisualizationProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Calculate margins for labels
|
||||
const marginLeft = 80;
|
||||
const marginRight = 80;
|
||||
const marginTop = 20;
|
||||
const marginBottom = 50;
|
||||
|
||||
const plotWidth = width - marginLeft - marginRight;
|
||||
const plotHeight = height - marginTop - marginBottom;
|
||||
|
||||
// Black background like Spek
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Calculate Nyquist frequency
|
||||
const nyquistFreq = sampleRate / 2;
|
||||
|
||||
if (spectrumData) {
|
||||
drawRealSpectrum(
|
||||
ctx,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
plotWidth,
|
||||
plotHeight,
|
||||
spectrumData
|
||||
);
|
||||
|
||||
drawGrid(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq);
|
||||
}
|
||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||
|
||||
const drawRealSpectrum = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
spectrum: SpectrumData
|
||||
) => {
|
||||
const timeSlices = spectrum.time_slices;
|
||||
if (timeSlices.length === 0) return;
|
||||
|
||||
const freqBins = timeSlices[0].magnitudes.length;
|
||||
const nyquistFreq = spectrum.max_freq;
|
||||
|
||||
let minDB = 0;
|
||||
let maxDB = -120;
|
||||
|
||||
timeSlices.forEach((slice) => {
|
||||
slice.magnitudes.forEach((db) => {
|
||||
if (db > maxDB) maxDB = db;
|
||||
if (db < minDB) minDB = db;
|
||||
});
|
||||
});
|
||||
|
||||
const dbRange = maxDB - minDB;
|
||||
|
||||
for (let t = 0; t < timeSlices.length; t++) {
|
||||
const slice = timeSlices[t];
|
||||
const xPos = x + (t / timeSlices.length) * width;
|
||||
const sliceWidth = Math.max(1, width / timeSlices.length);
|
||||
|
||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||
const db = slice.magnitudes[f];
|
||||
|
||||
// Linear frequency scale like Spek
|
||||
const freq = (f / freqBins) * nyquistFreq;
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
|
||||
// Calculate next frequency bin position
|
||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||
const nextYPos = y + height - (nextFreqRatio * height);
|
||||
|
||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||
|
||||
const intensity = (db - minDB) / dbRange;
|
||||
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSpekColor = (intensity: number): string => {
|
||||
// Enhanced color scheme - better than Spek
|
||||
if (intensity < 0.10) {
|
||||
// Deep black to dark blue
|
||||
const t = intensity / 0.10;
|
||||
return `rgb(0, 0, ${Math.floor(t * 100)})`;
|
||||
} else if (intensity < 0.25) {
|
||||
// Dark blue to bright blue
|
||||
const t = (intensity - 0.10) / 0.15;
|
||||
return `rgb(0, ${Math.floor(t * 50)}, ${Math.floor(100 + t * 155)})`;
|
||||
} else if (intensity < 0.40) {
|
||||
// Blue to cyan
|
||||
const t = (intensity - 0.25) / 0.15;
|
||||
return `rgb(0, ${Math.floor(50 + t * 205)}, 255)`;
|
||||
} else if (intensity < 0.55) {
|
||||
// Cyan to green
|
||||
const t = (intensity - 0.40) / 0.15;
|
||||
return `rgb(0, 255, ${Math.floor(255 - t * 200)})`;
|
||||
} else if (intensity < 0.70) {
|
||||
// Green to yellow
|
||||
const t = (intensity - 0.55) / 0.15;
|
||||
return `rgb(${Math.floor(t * 255)}, 255, ${Math.floor(55 - t * 55)})`;
|
||||
} else if (intensity < 0.85) {
|
||||
// Yellow to orange
|
||||
const t = (intensity - 0.70) / 0.15;
|
||||
return `rgb(255, ${Math.floor(255 - t * 100)}, 0)`;
|
||||
} else {
|
||||
// Orange to red
|
||||
const t = (intensity - 0.85) / 0.15;
|
||||
return `rgb(255, ${Math.floor(155 - t * 155)}, ${Math.floor(t * 30)})`;
|
||||
}
|
||||
};
|
||||
|
||||
const drawGrid = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
nyquistFreq: number
|
||||
) => {
|
||||
// Enhanced grid lines
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Dynamic frequency grid lines based on Nyquist frequency
|
||||
const generateFreqLines = (maxFreq: number): number[] => {
|
||||
if (maxFreq <= 24000) {
|
||||
// Standard 44.1/48 kHz (Nyquist ~22/24 kHz)
|
||||
return [1000, 2000, 5000, 10000, 15000, 20000];
|
||||
} else if (maxFreq <= 48000) {
|
||||
// 88.2/96 kHz (Nyquist ~44/48 kHz)
|
||||
return [5000, 10000, 20000, 30000, 40000];
|
||||
} else if (maxFreq <= 96000) {
|
||||
// 176.4/192 kHz (Nyquist ~88/96 kHz)
|
||||
return [10000, 20000, 40000, 60000, 80000];
|
||||
} else {
|
||||
// 352.8/384 kHz and higher (Nyquist ~176/192+ kHz)
|
||||
return [20000, 40000, 80000, 120000, 160000];
|
||||
}
|
||||
};
|
||||
|
||||
const freqLines = generateFreqLines(nyquistFreq);
|
||||
|
||||
freqLines.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yPos);
|
||||
ctx.lineTo(x + width, yPos);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// Vertical time grid lines
|
||||
for (let i = 1; i < 10; i++) {
|
||||
const xPos = x + (i / 10) * width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xPos, y);
|
||||
ctx.lineTo(xPos, y + height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = "rgba(220, 220, 220, 0.9)";
|
||||
ctx.font = "11px Arial";
|
||||
|
||||
// Frequency labels - dynamic formatting
|
||||
freqLines.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(label, x - 6, yPos);
|
||||
}
|
||||
});
|
||||
|
||||
// Time labels
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const timePos = x + (i / 10) * width;
|
||||
const timeValue = (i / 10) * duration;
|
||||
if (i % 2 === 0) {
|
||||
ctx.fillText(timeValue.toFixed(1), timePos, y + height + 5);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "bold 13px Arial";
|
||||
ctx.shadowColor = "rgba(0, 0, 0, 0.8)";
|
||||
ctx.shadowBlur = 4;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(8, y + height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Frequency (kHz)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Time (s)", x + width / 2, y + height + 26);
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
const boxGradient = ctx.createLinearGradient(x + width - 200, y + 5, x + width - 200, y + 68);
|
||||
boxGradient.addColorStop(0, "rgba(0, 0, 0, 0.85)");
|
||||
boxGradient.addColorStop(1, "rgba(0, 0, 0, 0.7)");
|
||||
ctx.fillStyle = boxGradient;
|
||||
ctx.fillRect(x + width - 200, y + 5, 190, 63);
|
||||
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x + width - 200, y + 5, 190, 63);
|
||||
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "600 11px Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
|
||||
ctx.shadowBlur = 2;
|
||||
ctx.fillText(`Sample Rate: ${(sampleRate / 1000).toFixed(1)} kHz`, x + width - 190, y + 20);
|
||||
ctx.fillText(`Bit Depth: ${bitsPerSample}-bit`, x + width - 190, y + 36);
|
||||
ctx.fillText(`Nyquist: ${(nyquistFreq / 1000).toFixed(1)} kHz`, x + width - 190, y + 52);
|
||||
ctx.shadowBlur = 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={1600}
|
||||
height={800}
|
||||
className="w-full h-auto"
|
||||
style={{ imageRendering: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user