.refine audio quality analyzer
This commit is contained in:
@@ -809,6 +809,53 @@ func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
|||||||
return string(jsonData), nil
|
return string(jsonData), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) AnalyzeSpectrumWithParams(filePath string, fftSize int, windowFunction string) (string, error) {
|
||||||
|
if filePath == "" {
|
||||||
|
return "", fmt.Errorf("file path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := backend.SpectrumParams{
|
||||||
|
FFTSize: fftSize,
|
||||||
|
WindowFunction: windowFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := backend.AnalyzeSpectrumWithParams(filePath, params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to analyze spectrum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) {
|
||||||
|
if audioFilePath == "" || base64Data == "" {
|
||||||
|
return "", fmt.Errorf("file path and image data are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base64Data = strings.TrimPrefix(base64Data, "data:image/png;base64,")
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode base64 image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(audioFilePath)
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||||
|
outPath := filepath.Join(filepath.Dir(audioFilePath), baseName+".png")
|
||||||
|
|
||||||
|
err = os.WriteFile(outPath, data, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save image to disk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
||||||
if len(filePaths) == 0 {
|
if len(filePaths) == 0 {
|
||||||
return "", fmt.Errorf("at least one file path is required")
|
return "", fmt.Errorf("at least one file path is required")
|
||||||
|
|||||||
+50
-9
@@ -21,8 +21,23 @@ type TimeSlice struct {
|
|||||||
Magnitudes []float64 `json:"magnitudes"`
|
Magnitudes []float64 `json:"magnitudes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
type SpectrumParams struct {
|
||||||
|
FFTSize int `json:"fft_size"`
|
||||||
|
WindowFunction string `json:"window_function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultSpectrumParams() SpectrumParams {
|
||||||
|
return SpectrumParams{
|
||||||
|
FFTSize: 4096,
|
||||||
|
WindowFunction: "hann",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||||
|
return AnalyzeSpectrumWithParams(filepath, DefaultSpectrumParams())
|
||||||
|
}
|
||||||
|
|
||||||
|
func AnalyzeSpectrumWithParams(filepath string, params SpectrumParams) (*SpectrumData, error) {
|
||||||
stream, err := flac.ParseFile(filepath)
|
stream, err := flac.ParseFile(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
||||||
@@ -42,7 +57,20 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
|||||||
return nil, fmt.Errorf("no audio samples found")
|
return nil, fmt.Errorf("no audio samples found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return calculateSpectrum(samples, sampleRate), nil
|
fftSize := params.FFTSize
|
||||||
|
validSizes := []int{512, 1024, 2048, 4096, 8192}
|
||||||
|
valid := false
|
||||||
|
for _, s := range validSizes {
|
||||||
|
if fftSize == s {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
fftSize = 4096
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateSpectrumWithParams(samples, sampleRate, fftSize, params.WindowFunction), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||||
@@ -75,8 +103,7 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
|||||||
return allSamples, nil
|
return allSamples, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
func calculateSpectrumWithParams(samples []float64, sampleRate, fftSize int, windowFunc string) *SpectrumData {
|
||||||
fftSize := 8192
|
|
||||||
numTimeSlices := 300
|
numTimeSlices := 300
|
||||||
|
|
||||||
duration := float64(len(samples)) / float64(sampleRate)
|
duration := float64(len(samples)) / float64(sampleRate)
|
||||||
@@ -98,8 +125,7 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window := samples[startIdx : startIdx+fftSize]
|
window := samples[startIdx : startIdx+fftSize]
|
||||||
|
windowedSamples := applyWindow(window, windowFunc)
|
||||||
windowedSamples := applyHannWindow(window)
|
|
||||||
|
|
||||||
spectrum := fft(windowedSamples)
|
spectrum := fft(windowedSamples)
|
||||||
|
|
||||||
@@ -129,18 +155,33 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyHannWindow(samples []float64) []float64 {
|
func applyWindow(samples []float64, windowType string) []float64 {
|
||||||
n := len(samples)
|
n := len(samples)
|
||||||
windowed := make([]float64, n)
|
windowed := make([]float64, n)
|
||||||
|
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
var w float64
|
||||||
windowed[i] = samples[i] * window
|
switch windowType {
|
||||||
|
case "hamming":
|
||||||
|
w = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
|
||||||
|
case "blackman":
|
||||||
|
w = 0.42 - 0.5*math.Cos(2*math.Pi*float64(i)/float64(n-1)) +
|
||||||
|
0.08*math.Cos(4*math.Pi*float64(i)/float64(n-1))
|
||||||
|
case "rectangular":
|
||||||
|
w = 1.0
|
||||||
|
default:
|
||||||
|
w = 0.5 * (1.0 - math.Cos(2*math.Pi*float64(i)/float64(n-1)))
|
||||||
|
}
|
||||||
|
windowed[i] = samples[i] * w
|
||||||
}
|
}
|
||||||
|
|
||||||
return windowed
|
return windowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyHannWindow(samples []float64) []float64 {
|
||||||
|
return applyWindow(samples, "hann")
|
||||||
|
}
|
||||||
|
|
||||||
func fft(samples []float64) []complex128 {
|
func fft(samples []float64) []complex128 {
|
||||||
n := len(samples)
|
n := len(samples)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
|
|
||||||
interface AudioAnalysisProps {
|
interface AudioAnalysisProps {
|
||||||
result: AnalysisResult | null;
|
result: AnalysisResult | null;
|
||||||
analyzing: boolean;
|
analyzing: boolean;
|
||||||
@@ -10,19 +11,24 @@ interface AudioAnalysisProps {
|
|||||||
showAnalyzeButton?: boolean;
|
showAnalyzeButton?: boolean;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
||||||
if (analyzing) {
|
if (analyzing) {
|
||||||
return (<Card>
|
return (
|
||||||
|
<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex items-center justify-center py-8 gap-3">
|
<div className="flex items-center justify-center py-8 gap-3">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>);
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result && showAnalyzeButton) {
|
if (!result && showAnalyzeButton) {
|
||||||
return (<Card>
|
return (
|
||||||
|
<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||||
<Activity className="h-12 w-12 text-primary" />
|
<Activity className="h-12 w-12 text-primary" />
|
||||||
@@ -32,94 +38,133 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
|||||||
Verify the true lossless quality of downloaded files
|
Verify the true lossless quality of downloaded files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onAnalyze && (<Button onClick={onAnalyze}>
|
{onAnalyze && (
|
||||||
|
<Button onClick={onAnalyze}>
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
Analyze Audio
|
Analyze Audio
|
||||||
</Button>)}
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>);
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
const formatDuration = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
const formatNumber = (num: number) => {
|
||||||
return num.toFixed(2);
|
return num.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
const formatFileSize = (bytes: number): string => {
|
||||||
if (bytes === 0)
|
if (bytes === 0) return "0 B";
|
||||||
return "0 B";
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const nyquistFreq = result.sample_rate / 2;
|
const nyquistFreq = result.sample_rate / 2;
|
||||||
return (<Card className="gap-2">
|
|
||||||
<CardHeader>
|
return (
|
||||||
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
|
<Card className="gap-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
{filePath && (
|
||||||
|
<p className="text-sm font-mono break-all text-muted-foreground">{filePath}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-2">
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
|
||||||
<Radio className="h-3 w-3 text-muted-foreground"/>
|
<ul className="text-sm space-y-1">
|
||||||
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Sample Rate:</span>
|
<span className="text-muted-foreground">Sample Rate:</span>
|
||||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
<span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
<li className="flex justify-between">
|
||||||
<FileAudio className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<span className="text-muted-foreground">Bit Depth:</span>
|
<span className="text-muted-foreground">Bit Depth:</span>
|
||||||
<span className="font-semibold">{result.bit_depth}</span>
|
<span className="font-medium font-mono">{result.bit_depth}</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
<li className="flex justify-between">
|
||||||
<Waves className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<span className="text-muted-foreground">Channels:</span>
|
<span className="text-muted-foreground">Channels:</span>
|
||||||
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
<span className="font-medium font-mono">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
<li className="flex justify-between">
|
||||||
<Clock className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<span className="text-muted-foreground">Duration:</span>
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
<span className="font-medium font-mono">{formatDuration(result.duration)}</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
{result.file_size > 0 && (
|
||||||
<Gauge className="h-3 w-3 text-muted-foreground"/>
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Nyquist:</span>
|
|
||||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
|
||||||
</div>
|
|
||||||
{result.file_size > 0 && (<div className="flex items-center gap-1">
|
|
||||||
<HardDrive className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<span className="text-muted-foreground">Size:</span>
|
<span className="text-muted-foreground">Size:</span>
|
||||||
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
|
<span className="font-medium font-mono">{formatFileSize(result.file_size)}</span>
|
||||||
</div>)}
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Signal Analytics</p>
|
||||||
<div className="flex items-center gap-1">
|
<ul className="text-sm space-y-1">
|
||||||
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Nyquist:</span>
|
||||||
|
<span className="font-medium font-mono">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
<span className="font-medium font-mono">{formatNumber(result.dynamic_range)} dB</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Peak Amplitude:</span>
|
||||||
|
<span className="font-medium font-mono">{formatNumber(result.peak_amplitude)} dB</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">RMS Level:</span>
|
||||||
|
<span className="font-medium font-mono">{formatNumber(result.rms_level)} dB</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Total Samples:</span>
|
||||||
|
<span className="font-medium font-mono">{result.total_samples.toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-muted-foreground">Peak:</span>
|
{result.spectrum && (() => {
|
||||||
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
const frames = result.spectrum.time_slices.length;
|
||||||
</div>
|
const fftSize = result.spectrum.freq_bins * 2;
|
||||||
<div className="flex items-center gap-1">
|
const freqRes = result.sample_rate / fftSize;
|
||||||
<span className="text-muted-foreground">RMS:</span>
|
|
||||||
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
return (
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
|
||||||
<span className="text-muted-foreground">Samples:</span>
|
<ul className="text-sm space-y-1">
|
||||||
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Analysis Frames:</span>
|
||||||
|
<span className="font-medium font-mono">{frames.toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">FFT Size:</span>
|
||||||
|
<span className="font-medium font-mono">{fftSize.toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Freq Resolution:</span>
|
||||||
|
<span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>);
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,51 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
|
|
||||||
interface AudioAnalysisPageProps {
|
interface AudioAnalysisPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading, reAnalyzeSpectrum } =
|
||||||
|
useAudioAnalysis();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!selectedFilePath || !spectrumRef.current)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||||
|
if (!dataUrl) {
|
||||||
|
toast.error("Export Failed", { description: "Cannot get canvas data" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||||
|
toast.success("Exported Successfully", {
|
||||||
|
description: `Saved to: ${outPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Export Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to save image",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
const handleSelectFile = async () => {
|
||||||
try {
|
try {
|
||||||
const filePath = await SelectFile();
|
const filePath = await SelectFile();
|
||||||
@@ -26,6 +59,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
if (paths.length === 0)
|
if (paths.length === 0)
|
||||||
@@ -39,6 +73,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
}
|
}
|
||||||
await analyzeFile(filePath);
|
await analyzeFile(filePath);
|
||||||
}, [analyzeFile]);
|
}, [analyzeFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
OnFileDrop((x, y, paths) => {
|
OnFileDrop((x, y, paths) => {
|
||||||
handleFileDrop(x, y, paths);
|
handleFileDrop(x, y, paths);
|
||||||
@@ -47,37 +82,55 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
OnFileDropOff();
|
OnFileDropOff();
|
||||||
};
|
};
|
||||||
}, [handleFileDrop]);
|
}, [handleFileDrop]);
|
||||||
|
|
||||||
const handleAnalyzeAnother = () => {
|
const handleAnalyzeAnother = () => {
|
||||||
clearResult();
|
clearResult();
|
||||||
};
|
};
|
||||||
return (<div className="space-y-6">
|
|
||||||
|
|
||||||
|
const fileName = selectedFilePath
|
||||||
|
? selectedFilePath.split(/[/\\]/).pop()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
{onBack && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>)}
|
</Button>
|
||||||
|
)}
|
||||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||||
</div>
|
</div>
|
||||||
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
{result && (
|
||||||
<Trash2 className="h-4 w-4"/>
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isExporting || spectrumLoading}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
{isExporting ? "Exporting..." : "Export PNG"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
Clear
|
Clear
|
||||||
</Button>)}
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!result && !analyzing && (
|
||||||
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
<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-primary bg-primary/10"
|
||||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
: "border-muted-foreground/30"}`}
|
||||||
e.preventDefault();
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||||
setIsDragging(true);
|
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||||
}} onDragLeave={(e) => {
|
onDrop={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||||
e.preventDefault();
|
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||||
setIsDragging(false);
|
>
|
||||||
}} onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<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" />
|
<Upload className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
@@ -90,24 +143,36 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
<Upload className="h-5 w-5" />
|
<Upload className="h-5 w-5" />
|
||||||
Select FLAC File
|
Select FLAC File
|
||||||
</Button>
|
</Button>
|
||||||
</div>)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analyzing && !result && (
|
||||||
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
<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>
|
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
||||||
</div>)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AudioAnalysis
|
||||||
|
result={result}
|
||||||
|
analyzing={analyzing}
|
||||||
|
showAnalyzeButton={false}
|
||||||
|
filePath={selectedFilePath}
|
||||||
|
/>
|
||||||
|
|
||||||
{result && (<div className="space-y-4">
|
<SpectrumVisualization
|
||||||
|
ref={spectrumRef}
|
||||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
sampleRate={result.sample_rate}
|
||||||
|
duration={result.duration}
|
||||||
|
spectrumData={result.spectrum}
|
||||||
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
fileName={fileName}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
onReAnalyze={reAnalyzeSpectrum}
|
||||||
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
|
isAnalyzingSpectrum={spectrumLoading}
|
||||||
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
|
/>
|
||||||
</div>)}
|
</div>
|
||||||
</div>);
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,193 +1,503 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import type { SpectrumData } from "@/types/api";
|
import type { SpectrumData } from "@/types/api";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { forwardRef, useImperativeHandle } from "react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export interface SpectrumVisualizationHandle {
|
||||||
|
getCanvasDataURL: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SpectrumVisualizationProps {
|
interface SpectrumVisualizationProps {
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
bitsPerSample: number;
|
|
||||||
duration: number;
|
duration: number;
|
||||||
spectrumData?: SpectrumData;
|
spectrumData?: SpectrumData;
|
||||||
|
fileName?: string;
|
||||||
|
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
||||||
|
isAnalyzingSpectrum?: boolean;
|
||||||
}
|
}
|
||||||
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
|
|
||||||
|
type ColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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][] = [
|
||||||
|
[68, 1, 84],
|
||||||
|
[72, 36, 117],
|
||||||
|
[62, 74, 137],
|
||||||
|
[49, 104, 142],
|
||||||
|
[38, 130, 142],
|
||||||
|
[31, 158, 137],
|
||||||
|
[53, 183, 121],
|
||||||
|
[110, 206, 88],
|
||||||
|
[181, 222, 43],
|
||||||
|
[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];
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hotColor(t: number): string {
|
||||||
|
if (t < 0.33) {
|
||||||
|
return `rgb(${Math.round(t / 0.33 * 255)},0,0)`;
|
||||||
|
}
|
||||||
|
if (t < 0.67) {
|
||||||
|
return `rgb(255,${Math.round((t - 0.33) / 0.34 * 255)},0)`;
|
||||||
|
}
|
||||||
|
return `rgb(255,255,${Math.round((t - 0.67) / 0.33 * 255)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coolColor(t: number): string {
|
||||||
|
if (t < 0.33) {
|
||||||
|
return `rgb(0,0,${Math.round(128 + t / 0.33 * 127)})`;
|
||||||
|
}
|
||||||
|
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)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreqScale = "linear" | "log2";
|
||||||
|
|
||||||
|
const MARGIN = { top: 50, right: 100, bottom: 50, left: 80 };
|
||||||
|
const CANVAS_W = 1200;
|
||||||
|
const CANVAS_H = 600;
|
||||||
|
|
||||||
|
function renderSpectrogram(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
spectrum: SpectrumData,
|
||||||
|
sampleRate: number,
|
||||||
|
duration: number,
|
||||||
|
freqScale: FreqScale,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
fileName?: string,
|
||||||
|
) {
|
||||||
|
const { top, right, bottom, left } = MARGIN;
|
||||||
|
const pw = CANVAS_W - left - right;
|
||||||
|
const ph = CANVAS_H - top - bottom;
|
||||||
|
|
||||||
|
ctx.fillStyle = "#000";
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
|
const slices = spectrum.time_slices;
|
||||||
|
if (!slices || slices.length === 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#ccc";
|
||||||
|
ctx.font = "12px 'Segoe UI', Arial";
|
||||||
|
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(24, top + ph / 2);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "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: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" },
|
||||||
|
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000, #fff)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({
|
||||||
|
sampleRate,
|
||||||
|
duration,
|
||||||
|
spectrumData,
|
||||||
|
fileName,
|
||||||
|
onReAnalyze,
|
||||||
|
isAnalyzingSpectrum,
|
||||||
|
}, ref) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (spectrumData && spectrumData.freq_bins) {
|
||||||
|
setFftSize(String(spectrumData.freq_bins * 2));
|
||||||
|
}
|
||||||
|
}, [spectrumData]);
|
||||||
|
|
||||||
|
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
|
||||||
|
setFftSize(newFftSize);
|
||||||
|
setWindowFunction(newWindowFunc);
|
||||||
|
if (onReAnalyze) {
|
||||||
|
onReAnalyze(parseInt(newFftSize), newWindowFunc);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas)
|
if (!canvas)
|
||||||
return;
|
return;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx)
|
if (!ctx)
|
||||||
return;
|
return;
|
||||||
const width = canvas.width;
|
|
||||||
const height = canvas.height;
|
|
||||||
const marginLeft = 70;
|
|
||||||
const marginRight = 70;
|
|
||||||
const marginTop = 30;
|
|
||||||
const marginBottom = 65;
|
|
||||||
const plotWidth = width - marginLeft - marginRight;
|
|
||||||
const plotHeight = height - marginTop - marginBottom;
|
|
||||||
ctx.fillStyle = "#000000";
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
const nyquistFreq = sampleRate / 2;
|
|
||||||
if (spectrumData) {
|
if (spectrumData) {
|
||||||
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
|
renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName);
|
||||||
}
|
} else {
|
||||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
ctx.fillStyle = "#000";
|
||||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
ctx.fillStyle = "#444";
|
||||||
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
|
ctx.font = "16px Arial";
|
||||||
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 = -200;
|
|
||||||
timeSlices.forEach((slice) => {
|
|
||||||
slice.magnitudes.forEach((db) => {
|
|
||||||
if (db > maxDB)
|
|
||||||
maxDB = db;
|
|
||||||
if (db < minDB && db > -200)
|
|
||||||
minDB = db;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
minDB = Math.max(minDB, maxDB - 90);
|
|
||||||
const dbRange = maxDB - minDB;
|
|
||||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
|
||||||
for (let t = 0; t < timeSlices.length; t++) {
|
|
||||||
const slice = timeSlices[t];
|
|
||||||
const xPos = x + (t / timeSlices.length) * width;
|
|
||||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
|
||||||
const db = slice.magnitudes[f];
|
|
||||||
const freq = (f / freqBins) * nyquistFreq;
|
|
||||||
const freqRatio = freq / nyquistFreq;
|
|
||||||
const yPos = y + height - (freqRatio * height);
|
|
||||||
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 = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
|
||||||
const color = getSpekColor(intensity);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getSpekColor = (intensity: number): string => {
|
|
||||||
if (intensity < 0.08) {
|
|
||||||
const t = intensity / 0.08;
|
|
||||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.18) {
|
|
||||||
const t = (intensity - 0.08) / 0.10;
|
|
||||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.28) {
|
|
||||||
const t = (intensity - 0.18) / 0.10;
|
|
||||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.40) {
|
|
||||||
const t = (intensity - 0.28) / 0.12;
|
|
||||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.52) {
|
|
||||||
const t = (intensity - 0.40) / 0.12;
|
|
||||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.65) {
|
|
||||||
const t = (intensity - 0.52) / 0.13;
|
|
||||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.78) {
|
|
||||||
const t = (intensity - 0.65) / 0.13;
|
|
||||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.90) {
|
|
||||||
const t = (intensity - 0.78) / 0.12;
|
|
||||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const t = (intensity - 0.90) / 0.10;
|
|
||||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
|
|
||||||
ctx.fillStyle = "#CCCCCC";
|
|
||||||
ctx.font = "12px Arial";
|
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
|
||||||
freqLabels.forEach(freq => {
|
|
||||||
if (freq <= nyquistFreq) {
|
|
||||||
const freqRatio = freq / nyquistFreq;
|
|
||||||
const yPos = y + height - (freqRatio * height);
|
|
||||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
|
||||||
ctx.fillText(label, x - 8, yPos);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ctx.fillText("0", x - 8, y + height);
|
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "top";
|
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
|
||||||
const timeStep = getTimeStep(duration);
|
|
||||||
for (let t = 0; t <= duration; t += timeStep) {
|
|
||||||
const xPos = x + (t / duration) * width;
|
|
||||||
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
|
||||||
}
|
|
||||||
ctx.fillStyle = "#FFFFFF";
|
|
||||||
ctx.font = "13px Arial";
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(12, y + height / 2);
|
|
||||||
ctx.rotate(-Math.PI / 2);
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
|
||||||
ctx.restore();
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.fillStyle = "#CCCCCC";
|
|
||||||
ctx.font = "12px Arial";
|
|
||||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
|
||||||
};
|
|
||||||
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
|
||||||
if (nyquistFreq <= 24000) {
|
|
||||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
|
||||||
}
|
|
||||||
else if (nyquistFreq <= 48000) {
|
|
||||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
|
||||||
}
|
|
||||||
else if (nyquistFreq <= 96000) {
|
|
||||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getTimeStep = (duration: number): number => {
|
|
||||||
if (duration <= 60)
|
|
||||||
return 15;
|
|
||||||
if (duration <= 120)
|
|
||||||
return 30;
|
|
||||||
if (duration <= 300)
|
|
||||||
return 30;
|
|
||||||
if (duration <= 600)
|
|
||||||
return 60;
|
|
||||||
return 60;
|
|
||||||
};
|
|
||||||
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
|
|
||||||
for (let i = 0; i < height; i++) {
|
|
||||||
const intensity = 1 - (i / height);
|
|
||||||
const color = getSpekColor(intensity);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(x, y + i, width, 1);
|
|
||||||
}
|
|
||||||
ctx.strokeStyle = "#666666";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeRect(x, y, width, height);
|
|
||||||
ctx.fillStyle = "#FFFFFF";
|
|
||||||
ctx.font = "11px Arial";
|
|
||||||
ctx.textAlign = "left";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
ctx.fillText("High", x + width + 5, y + 10);
|
|
||||||
ctx.fillText("Low", x + width + 5, y + height - 10);
|
|
||||||
};
|
|
||||||
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
|
||||||
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
|
||||||
</div>);
|
|
||||||
}
|
}
|
||||||
|
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
|
||||||
|
|
||||||
|
useEffect(() => { draw(); }, [draw]);
|
||||||
|
|
||||||
|
useEffect(() => { draw(); }, [draw]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap text-sm font-medium">Color Scheme:</Label>
|
||||||
|
<Select value={colorScheme} onValueChange={(v) => setColorScheme(v as ColorScheme)} disabled={isAnalyzingSpectrum}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{COLOR_SCHEMES.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-sm border opacity-90"
|
||||||
|
style={{ backgroundImage: s.gradient }}
|
||||||
|
/>
|
||||||
|
<span>{s.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-border hidden sm:block mx-1"></div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="linear">Linear</SelectItem>
|
||||||
|
<SelectItem value="log2">Log2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap text-sm font-medium">FFT Size:</Label>
|
||||||
|
<Select value={fftSize} onValueChange={(v) => handleReAnalyze(v, windowFunction)} disabled={isAnalyzingSpectrum}>
|
||||||
|
<SelectTrigger className="h-8 w-[90px] text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="512">512</SelectItem>
|
||||||
|
<SelectItem value="1024">1024</SelectItem>
|
||||||
|
<SelectItem value="2048">2048</SelectItem>
|
||||||
|
<SelectItem value="4096">4096</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hann">Hann</SelectItem>
|
||||||
|
<SelectItem value="hamming">Hamming</SelectItem>
|
||||||
|
<SelectItem value="blackman">Blackman</SelectItem>
|
||||||
|
<SelectItem value="rectangular">Rectangular</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||||
|
{isAnalyzingSpectrum && (
|
||||||
|
<div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center z-10 backdrop-blur-sm">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
||||||
|
<p className="text-sm text-foreground">Re-analyzing spectrum...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={CANVAS_W}
|
||||||
|
height={CANVAS_H}
|
||||||
|
className="w-full h-auto"
|
||||||
|
style={{ imageRendering: "auto" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
import { AnalyzeTrack, AnalyzeSpectrumWithParams } from "../../wailsjs/go/main/App";
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
@@ -135,6 +135,29 @@ export function useAudioAnalysis() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [result, selectedFilePath, spectrumLoading]);
|
}, [result, selectedFilePath, spectrumLoading]);
|
||||||
|
|
||||||
|
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||||
|
if (!selectedFilePath || !result)
|
||||||
|
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 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 {
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedFilePath, result]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
analyzing,
|
analyzing,
|
||||||
result,
|
result,
|
||||||
@@ -142,6 +165,7 @@ export function useAudioAnalysis() {
|
|||||||
selectedFilePath,
|
selectedFilePath,
|
||||||
spectrumLoading,
|
spectrumLoading,
|
||||||
analyzeFile,
|
analyzeFile,
|
||||||
|
reAnalyzeSpectrum,
|
||||||
clearResult,
|
clearResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user