.remake audio quality analyzer

This commit is contained in:
afkarxyz
2026-03-25 18:52:27 +07:00
parent 8919b9a77a
commit c342c3f9ee
11 changed files with 1169 additions and 977 deletions
+9 -65
View File
@@ -791,47 +791,6 @@ func (a *App) ClearFetchHistoryByType(itemType string) error {
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC") return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
} }
func (a *App) AnalyzeTrack(filePath string) (string, error) {
if filePath == "" {
return "", fmt.Errorf("file path is required")
}
result, err := backend.AnalyzeTrack(filePath)
if err != nil {
return "", fmt.Errorf("failed to analyze track: %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) 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) { func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) {
if audioFilePath == "" || base64Data == "" { if audioFilePath == "" || base64Data == "" {
return "", fmt.Errorf("file path and image data are required") return "", fmt.Errorf("file path and image data are required")
@@ -856,30 +815,6 @@ func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string
return outPath, nil return outPath, nil
} }
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
if len(filePaths) == 0 {
return "", fmt.Errorf("at least one file path is required")
}
results := make([]*backend.AnalysisResult, 0, len(filePaths))
for _, filePath := range filePaths {
result, err := backend.AnalyzeTrack(filePath)
if err != nil {
continue
}
results = append(results, result)
}
jsonData, err := json.Marshal(results)
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
type LyricsDownloadRequest struct { type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
@@ -1272,6 +1207,15 @@ func (a *App) ReadTextFile(filePath string) (string, error) {
return string(content), nil return string(content), nil
} }
func (a *App) ReadFileAsBase64(filePath string) (string, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(content), nil
}
func (a *App) RenameFileTo(oldPath, newName string) error { func (a *App) RenameFileTo(oldPath, newName string) error {
dir := filepath.Dir(oldPath) dir := filepath.Dir(oldPath)
ext := filepath.Ext(oldPath) ext := filepath.Ext(oldPath)
+9 -172
View File
@@ -2,15 +2,11 @@ package backend
import ( import (
"fmt" "fmt"
"math"
"os" "os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/go-flac/go-flac"
mewflac "github.com/mewkiz/flac"
) )
type AnalysisResult struct { type AnalysisResult struct {
@@ -26,146 +22,6 @@ type AnalysisResult struct {
DynamicRange float64 `json:"dynamic_range"` DynamicRange float64 `json:"dynamic_range"`
PeakAmplitude float64 `json:"peak_amplitude"` PeakAmplitude float64 `json:"peak_amplitude"`
RMSLevel float64 `json:"rms_level"` RMSLevel float64 `json:"rms_level"`
Spectrum *SpectrumData `json:"spectrum,omitempty"`
}
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
result.Channels = ((data[12] >> 1) & 0x07) + 1
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
}
}
}
spectrum, err := AnalyzeSpectrum(filepath)
if err != nil {
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else {
result.Spectrum = spectrum
calculateRealAudioMetrics(result, filepath)
}
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
samples, err := decodeFLACForMetrics(filepath)
if err != nil {
return
}
var peak float64
var sumSquares float64
for _, sample := range samples {
absVal := sample
if absVal < 0 {
absVal = -absVal
}
if absVal > peak {
peak = absVal
}
sumSquares += sample * sample
}
peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB
rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB
result.DynamicRange = peakDB - rmsDB
}
func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath)
if err != nil {
return nil, err
}
defer stream.Close()
maxSamples := 10000000
samples := make([]float64, 0, maxSamples)
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
var channelSamples []int32
if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples
}
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
for _, sample := range channelSamples {
if len(samples) >= maxSamples {
return samples, nil
}
normalized := float64(sample) / maxVal
samples = append(samples, normalized)
}
if len(samples) >= maxSamples {
break
}
}
return samples, nil
}
func GetFileSize(filepath string) (int64, error) {
info, err := os.Stat(filepath)
if err != nil {
return 0, err
}
return info.Size(), nil
} }
func GetTrackMetadata(filepath string) (*AnalysisResult, error) { func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
@@ -194,20 +50,23 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
"-v", "error", "-v", "error",
"-select_streams", "a:0", "-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate", "-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
"-of", "default=noprint_wrappers=1:nokey=1", "-of", "default=noprint_wrappers=0",
filePath, filePath,
} }
cmd := exec.Command(ffprobePath, args...) cmd := exec.Command(ffprobePath, args...)
setHideWindow(cmd) setHideWindow(cmd)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output)) return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
} }
lines := strings.Split(strings.TrimSpace(string(output)), "\n") infoMap := make(map[string]string)
if len(lines) < 4 { lines := strings.Split(string(output), "\n")
return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output)) for _, line := range lines {
if strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
} }
res := &AnalysisResult{ res := &AnalysisResult{
@@ -218,28 +77,6 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
res.FileSize = info.Size() res.FileSize = info.Size()
} }
infoMap := make(map[string]string)
args = []string{
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
"-of", "default=noprint_wrappers=0",
filePath,
}
cmd = exec.Command(ffprobePath, args...)
setHideWindow(cmd)
output, err = cmd.CombinedOutput()
if err == nil {
lines = strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
if val, ok := infoMap["sample_rate"]; ok { if val, ok := infoMap["sample_rate"]; ok {
s, _ := strconv.Atoi(val) s, _ := strconv.Atoi(val)
res.SampleRate = uint32(s) res.SampleRate = uint32(s)
-222
View File
@@ -1,222 +0,0 @@
package backend
import (
"fmt"
"math"
"math/cmplx"
"github.com/mewkiz/flac"
)
type SpectrumData struct {
TimeSlices []TimeSlice `json:"time_slices"`
SampleRate int `json:"sample_rate"`
FreqBins int `json:"freq_bins"`
Duration float64 `json:"duration"`
MaxFreq float64 `json:"max_freq"`
}
type TimeSlice struct {
Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"`
}
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)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
}
defer stream.Close()
info := stream.Info
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
samples, err := readSamples(stream, channels)
if err != nil {
return nil, fmt.Errorf("failed to read samples: %w", err)
}
if len(samples) == 0 {
return nil, fmt.Errorf("no audio samples found")
}
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) {
var allSamples []float64
maxSamples := 10 * 1024 * 1024
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64
for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i])
}
sample /= float64(channels)
allSamples = append(allSamples, sample)
if len(allSamples) >= maxSamples {
return allSamples, nil
}
}
}
return allSamples, nil
}
func calculateSpectrumWithParams(samples []float64, sampleRate, fftSize int, windowFunc string) *SpectrumData {
numTimeSlices := 300
duration := float64(len(samples)) / float64(sampleRate)
samplesPerSlice := len(samples) / numTimeSlices
if samplesPerSlice < fftSize {
samplesPerSlice = fftSize
numTimeSlices = len(samples) / fftSize
}
timeSlices := make([]TimeSlice, 0, numTimeSlices)
freqBins := fftSize / 2
maxFreq := float64(sampleRate) / 2.0
for i := 0; i < numTimeSlices; i++ {
startIdx := i * samplesPerSlice
if startIdx+fftSize > len(samples) {
break
}
window := samples[startIdx : startIdx+fftSize]
windowedSamples := applyWindow(window, windowFunc)
spectrum := fft(windowedSamples)
magnitudes := make([]float64, freqBins)
for j := 0; j < freqBins; j++ {
magnitude := cmplx.Abs(spectrum[j])
if magnitude < 1e-10 {
magnitude = 1e-10
}
magnitudes[j] = 20 * math.Log10(magnitude)
}
timeSlice := TimeSlice{
Time: float64(startIdx) / float64(sampleRate),
Magnitudes: magnitudes,
}
timeSlices = append(timeSlices, timeSlice)
}
return &SpectrumData{
TimeSlices: timeSlices,
SampleRate: sampleRate,
FreqBins: freqBins,
Duration: duration,
MaxFreq: maxFreq,
}
}
func applyWindow(samples []float64, windowType string) []float64 {
n := len(samples)
windowed := make([]float64, n)
for i := 0; i < n; i++ {
var w float64
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
}
func applyHannWindow(samples []float64) []float64 {
return applyWindow(samples, "hann")
}
func fft(samples []float64) []complex128 {
n := len(samples)
x := make([]complex128, n)
for i := 0; i < n; i++ {
x[i] = complex(samples[i], 0)
}
return fftRecursive(x)
}
func fftRecursive(x []complex128) []complex128 {
n := len(x)
if n <= 1 {
return x
}
even := make([]complex128, n/2)
odd := make([]complex128, n/2)
for i := 0; i < n/2; i++ {
even[i] = x[2*i]
odd[i] = x[2*i+1]
}
evenFFT := fftRecursive(even)
oddFFT := fftRecursive(odd)
result := make([]complex128, n)
for k := 0; k < n/2; k++ {
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
result[k] = evenFFT[k] + t
result[k+n/2] = evenFFT[k] - t
}
return result
}
+1 -1
View File
@@ -140,7 +140,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
{result.spectrum && (() => { {result.spectrum && (() => {
const frames = result.spectrum.time_slices.length; 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; const freqRes = result.sample_rate / fftSize;
return ( return (
+144 -62
View File
@@ -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 { Button } from "@/components/ui/button";
import { Upload, ArrowLeft, Trash2, Download } 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, 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 { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
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) { function isFlacPath(filePath: string): boolean {
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading, reAnalyzeSpectrum } = return filePath.toLowerCase().endsWith(".flac");
useAudioAnalysis(); }
const [isDragging, setIsDragging] = useState(false);
const spectrumRef = useRef<{ getCanvasDataURL: () => string | null; }>(null);
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => { function isFlacFile(file: File): boolean {
if (!selectedFilePath || !spectrumRef.current) 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; 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(); const dataUrl = spectrumRef.current.getCanvasDataURL();
if (!dataUrl) { if (!dataUrl) {
@@ -31,68 +122,51 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
setIsExporting(true); setIsExporting(true);
try { try {
if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl); const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
toast.success("Exported Successfully", { toast.success("Exported Successfully", {
description: `Saved to: ${outPath}`, description: `Saved to: ${outPath}`,
}); });
return;
} }
catch (err) {
toast.error("Export Failed", { const base = selectedFilePath
description: err instanceof Error ? err.message : "Failed to save image", ? 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: "Spectrogram image downloaded",
}); });
} } catch (err) {
finally { toast.error("Export Failed", {
description: err instanceof Error ? err.message : "Failed to export image",
});
} finally {
setIsExporting(false); setIsExporting(false);
} }
}; }, [selectedFilePath]);
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]);
const handleAnalyzeAnother = () => { const handleAnalyzeAnother = () => {
clearResult(); clearResult();
}; };
const fileName = selectedFilePath const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
? selectedFilePath.split(/[/\\]/).pop()
: undefined;
return ( return (
<div className="space-y-6"> <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 justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{onBack && ( {onBack && (
@@ -123,13 +197,21 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
{!result && !analyzing && ( {!result && !analyzing && (
<div <div
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging 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"}`} : "border-muted-foreground/30"
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} }`}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} onDragOver={(e) => {
onDrop={(e) => { e.preventDefault(); setIsDragging(false); }} e.preventDefault();
style={{ "--wails-drop-target": "drop" } as React.CSSProperties} 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"> <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" />
+450 -297
View File
@@ -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 type { SpectrumData } from "@/types/api";
import { Label } from "@/components/ui/label"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -23,326 +29,457 @@ interface SpectrumVisualizationProps {
isAnalyzingSpectrum?: boolean; 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 MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
const t = Math.max(0, Math.min(1, intensity)); const CANVAS_W = 1100;
switch (scheme) { const CANVAS_H = 600;
case "spek": const MAX_RENDER_HEIGHT = 1080;
return spekColor(t);
case "viridis": function clamp01(value: number): number {
return viridisColor(t); return Math.max(0, Math.min(1, value));
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] { function spekColorMap(t: number): [number, number, number] {
const t = Math.max(0, Math.min(1, intensity)); const colors: Array<[number, number, number]> = [
const css = getColor(t, scheme); [0, 0, 0],
const m = css.match(/\d+/g)!; [0, 0, 25],
return [parseInt(m[0]), parseInt(m[1]), parseInt(m[2])]; [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 { function viridisColorMap(t: number): [number, number, number] {
if (t < 0.08) { const colors: Array<[number, number, number]> = [
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], [68, 1, 84],
[72, 36, 117], [70, 20, 100],
[72, 40, 120],
[67, 62, 133],
[62, 74, 137], [62, 74, 137],
[55, 89, 140],
[49, 104, 142], [49, 104, 142],
[43, 117, 142],
[38, 130, 142], [38, 130, 142],
[35, 144, 140],
[31, 158, 137], [31, 158, 137],
[42, 171, 129],
[53, 183, 121], [53, 183, 121],
[110, 206, 88], [81, 194, 105],
[181, 222, 43], [109, 205, 89],
[144, 214, 67],
[180, 222, 44],
[216, 227, 41],
[253, 231, 37], [253, 231, 37],
]; ];
const i = t * (stops.length - 1);
const lo = Math.floor(i); const scaled = t * (colors.length - 1);
const hi = Math.min(lo + 1, stops.length - 1); const idx = Math.floor(scaled);
const f = i - lo; const fraction = scaled - idx;
const [r, g, b] = stops[lo].map((v, k) => Math.round(v + (stops[hi][k] - v) * f)) as [number, number, number];
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})`; return `rgb(${r},${g},${b})`;
} }
function hotColor(t: number): string { function addAxisLabels(
if (t < 0.33) { ctx: CanvasRenderingContext2D,
return `rgb(${Math.round(t / 0.33 * 255)},0,0)`; 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`;
} }
return `rgb(255,255,${Math.round((t - 0.67) / 0.33 * 255)})`; ctx.fillText(label, x, y);
}
}
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 { function drawColorBar(
if (t < 0.33) { ctx: CanvasRenderingContext2D,
return `rgb(0,0,${Math.round(128 + t / 0.33 * 127)})`; 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)`; ctx.fillStyle = gradient;
} ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
return `rgb(${Math.round((t - 0.67) / 0.33 * 255)},255,255)`;
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"; async function renderSpectrogram(
const MARGIN = { top: 50, right: 100, bottom: 50, left: 80 };
const CANVAS_W = 1200;
const CANVAS_H = 600;
function renderSpectrogram(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
spectrum: SpectrumData, spectrum: SpectrumData,
sampleRate: number, sampleRate: number,
duration: number, duration: number,
freqScale: FreqScale, freqScale: FreqScale,
colorScheme: ColorScheme, colorScheme: ColorScheme,
fileName?: string, fileName: string | undefined,
shouldCancel: () => boolean,
) { ) {
const { top, right, bottom, left } = MARGIN; const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right;
const pw = CANVAS_W - left - right; const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom;
const ph = CANVAS_H - top - bottom;
ctx.fillStyle = "#000"; ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
const slices = spectrum.time_slices; const spectrogramData = spectrum.time_slices;
if (!slices || slices.length === 0) const numTimeFrames = spectrogramData.length;
const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0;
if (numTimeFrames === 0 || numFreqBins === 0) {
return; return;
}
const numT = slices.length; let minMag = Number.POSITIVE_INFINITY;
const numF = slices[0].magnitudes.length; let maxMag = Number.NEGATIVE_INFINITY;
const maxFreq = spectrum.max_freq; const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1;
let minDB = Infinity; for (let i = 0; i < numTimeFrames; i += sampleStep) {
let maxDB = -Infinity; const frame = spectrogramData[i].magnitudes;
for (const s of slices) { for (const mag of frame) {
for (const v of s.magnitudes) { if (Number.isFinite(mag)) {
if (v > maxDB) if (mag < minMag)
maxDB = v; minMag = mag;
if (v < minDB && v > -200) if (mag > maxMag)
minDB = v; maxMag = mag;
}
} }
} }
minDB = Math.max(minDB, maxDB - 90);
const dbRange = maxDB - minDB;
const img = ctx.createImageData(pw, ph); if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) {
const data = img.data; minMag = -120;
maxMag = 0;
}
for (let x = 0; x < pw; x++) { const magRange = maxMag - minMag;
const tProgress = x / (pw - 1); const safeMagRange = magRange > 0 ? magRange : 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++) { const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT);
let fProgress = (ph - 1 - y) / (ph - 1); const highResData = highResImageData.data;
const CHUNK_SIZE = 50;
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") { if (freqScale === "log2") {
const minF = 20; const minFreq = 20;
const octaves = Math.log2(maxFreq / minF); const maxFreq = sampleRate / 2;
const freq = minF * Math.pow(2, fProgress * octaves); const octaves = Math.log2(maxFreq / minFreq);
fProgress = freq / maxFreq; const octave = freqProgress * octaves;
const freq = minFreq * Math.pow(2, octave);
freqProgress = freq / maxFreq;
} }
const fExact = fProgress * (numF - 1); const exactFreqPos = freqProgress * (numFreqBins - 1);
const f0 = Math.floor(fExact); const freqIdx = Math.floor(exactFreqPos);
const f1 = Math.min(f0 + 1, numF - 1); const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1);
const ff = fExact - f0; const freqFrac = exactFreqPos - freqIdx;
const m00 = frame0[f0] ?? minDB; let magnitude: number;
const m01 = frame0[f1] ?? minDB; if (timeFrac === 0 && freqFrac === 0) {
const m10 = frame1[f0] ?? minDB; magnitude = frame1[freqIdx] ?? 0;
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 { } else {
yPos = top + ph - (freq / maxFreq) * ph; const mag11 = frame1[freqIdx] ?? 0;
} const mag12 = frame1[freqIdx2] ?? 0;
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; const mag21 = frame2[freqIdx] ?? 0;
ctx.fillText(label, left - 8, yPos); const mag22 = frame2[freqIdx2] ?? 0;
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
ctx.lineWidth = 1; const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac;
ctx.beginPath(); const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac;
ctx.moveTo(left - 4, yPos); magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac;
ctx.lineTo(left + pw, yPos);
ctx.stroke();
} }
ctx.textAlign = "center"; const normalizedMag = clamp01((magnitude - minMag) / safeMagRange);
ctx.textBaseline = "top"; const [r, g, b] = getColorValues(normalizedMag, colorScheme);
const timeStep = smartTimeStep(duration); const pixelIdx = (y * plotWidth + x) * 4;
for (let t = 0; t <= duration; t += timeStep) { highResData[pixelIdx] = r;
const xPos = left + (t / duration) * pw; highResData[pixelIdx + 1] = g;
const label = timeStep >= 60 highResData[pixelIdx + 2] = b;
? `${Math.floor(t / 60)}m${t % 60 ? (t % 60) + "s" : ""}` highResData[pixelIdx + 3] = 255;
: `${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"; if (xStart + CHUNK_SIZE < plotWidth) {
ctx.font = "13px 'Segoe UI', Arial"; await new Promise((resolve) => setTimeout(resolve, 1));
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 (shouldCancel()) {
if (duration <= 30) return;
return 5; }
if (duration <= 60)
return 10; const finalImageData = ctx.createImageData(plotWidth, plotHeight);
if (duration <= 120) const finalData = finalImageData.data;
return 15;
if (duration <= 300) for (let y = 0; y < plotHeight; y++) {
return 30; for (let x = 0; x < plotWidth; x++) {
if (duration <= 600) const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT);
return 60; const highResIdx = (highResY * plotWidth + x) * 4;
return 120; const finalIdx = (y * plotWidth + x) * 4;
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.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; }[] = [ 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: "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: "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>(({ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({
@@ -354,41 +491,37 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
isAnalyzingSpectrum, isAnalyzingSpectrum,
}, ref) => { }, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const preferencesRef = useRef(loadAudioAnalysisPreferences());
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getCanvasDataURL: () => { getCanvasDataURL: () => {
if (!canvasRef.current) if (!canvasRef.current)
return null; return null;
return canvasRef.current.toDataURL("image/png"); return canvasRef.current.toDataURL("image/png");
} },
})); }));
const [freqScale, setFreqScale] = useState<FreqScale>("linear"); const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale);
const [colorScheme, setColorScheme] = useState<ColorScheme>("spek"); const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme);
const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize));
const [fftSize, setFftSize] = useState<string>(() => { const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction);
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) { if (spectrumData?.freq_bins) {
setFftSize(String(spectrumData.freq_bins * 2)); setFftSize(String((spectrumData.freq_bins - 1) * 2));
} }
}, [spectrumData]); }, [spectrumData]);
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => { useEffect(() => {
setFftSize(newFftSize); saveAudioAnalysisPreferences({
setWindowFunction(newWindowFunc); colorScheme,
if (onReAnalyze) { freqScale,
onReAnalyze(parseInt(newFftSize), newWindowFunc); fftSize: Number(fftSize),
} windowFunction,
}; });
}, [colorScheme, freqScale, fftSize, windowFunction]);
const draw = useCallback(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) if (!canvas)
return; return;
@@ -396,21 +529,41 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
if (!ctx) if (!ctx)
return; return;
let canceled = false;
const shouldCancel = () => canceled;
if (spectrumData) { if (spectrumData) {
renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName); void renderSpectrogram(
ctx,
spectrumData,
sampleRate,
duration,
freqScale,
colorScheme,
fileName,
shouldCancel,
);
} else { } else {
ctx.fillStyle = "#000"; ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
ctx.fillStyle = "#444"; ctx.fillStyle = "#444444";
ctx.font = "16px Arial"; ctx.font = "16px Arial";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2); ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
} }
return () => {
canceled = true;
};
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]); }, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
useEffect(() => { draw(); }, [draw]); const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
setFftSize(newFftSize);
useEffect(() => { draw(); }, [draw]); setWindowFunction(newWindowFunc as WindowFunction);
if (onReAnalyze) {
onReAnalyze(parseInt(newFftSize, 10), newWindowFunc);
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -422,14 +575,14 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{COLOR_SCHEMES.map((s) => ( {COLOR_SCHEMES.map((scheme) => (
<SelectItem key={s.value} value={s.value}> <SelectItem key={scheme.value} value={scheme.value}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className="h-4 w-4 rounded-sm border opacity-90" 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> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -441,8 +594,8 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-sm font-medium">Freq Scale:</Label> <Label className="whitespace-nowrap text-sm font-medium">Freq Scale:</Label>
<Select value={freqScale} onValueChange={(v) => { if (v) setFreqScale(v as FreqScale); }} disabled={isAnalyzingSpectrum}> <Select value={freqScale} onValueChange={(v) => setFreqScale(v as FreqScale)} disabled={isAnalyzingSpectrum}>
<SelectTrigger className="h-8 w-[90px] text-sm"> <SelectTrigger className="h-8 w-[95px] text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -470,7 +623,7 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-sm font-medium">Window:</Label> <Label className="whitespace-nowrap text-sm font-medium">Window:</Label>
<Select value={windowFunction} onValueChange={(v) => handleReAnalyze(fftSize, v)} disabled={isAnalyzingSpectrum}> <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
+164 -126
View File
@@ -1,162 +1,199 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useRef } from "react";
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";
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache"; import { analyzeFlacArrayBuffer, analyzeFlacFile, analyzeSpectrumFromSamples } from "@/lib/flac-analysis";
const STORAGE_KEY = "spotiflac_audio_analysis_state"; 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() { export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(() => { const [result, setResult] = useState<AnalysisResult | null>(() => sessionResult);
try { const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath);
const saved = sessionStorage.getItem(STORAGE_KEY); const [error, setError] = useState<string | null>(() => sessionError);
if (saved) { const [spectrumLoading, setSpectrumLoading] = useState(false);
const parsed = JSON.parse(saved); const samplesRef = useRef<Float32Array | null>(sessionSamples);
if (parsed.filePath && parsed.result) {
return { const setResultWithSession = useCallback((next: AnalysisResult | null) => {
...parsed.result, sessionResult = next;
spectrum: undefined, setResult(next);
}; }, []);
}
} const setSelectedFilePathWithSession = useCallback((next: string) => {
} sessionSelectedFilePath = next;
catch (err) { setSelectedFilePath(next);
console.error("Failed to load saved analysis state:", err); }, []);
}
return null; const setErrorWithSession = useCallback((next: string | null) => {
}); sessionError = next;
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => { setError(next);
try { }, []);
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) { const analyzeFile = useCallback(async (file: File) => {
const parsed = JSON.parse(saved); if (!file) {
return parsed.filePath || ""; setErrorWithSession("No file provided");
}
}
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");
return null; return null;
} }
setAnalyzing(true); setAnalyzing(true);
setError(null); setErrorWithSession(null);
setResult(null); setResultWithSession(null);
setSelectedFilePath(filePath); setSelectedFilePathWithSession(file.name);
try { try {
logger.info(`Analyzing audio file: ${filePath}`); logger.info(`Analyzing audio file (frontend): ${file.name}`);
const startTime = Date.now(); const start = Date.now();
const response = await AnalyzeTrack(filePath); const prefs = loadAudioAnalysisPreferences();
const analysisResult: AnalysisResult = JSON.parse(response); const payload = await analyzeFlacFile(file, {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); 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`); logger.success(`Audio analysis completed in ${elapsed}s`);
if (analysisResult.spectrum) { return payload.result;
setSpectrumCache(filePath, analysisResult.spectrum); } catch (err) {
}
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) {
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`); logger.error(`Analysis error: ${errorMessage}`);
setError(errorMessage); setErrorWithSession(errorMessage);
toast.error("Audio Analysis Failed", { toast.error("Audio Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
return null; return null;
} } finally {
finally {
setAnalyzing(false); setAnalyzing(false);
} }
}, []); }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
const clearResult = useCallback(() => {
setResult(null); const analyzeFilePath = useCallback(async (filePath: string) => {
setError(null); if (!filePath) {
setSelectedFilePath(""); setErrorWithSession("No file path provided");
return null;
}
setAnalyzing(true);
setErrorWithSession(null);
setResultWithSession(null);
setSelectedFilePathWithSession(filePath);
try { try {
sessionStorage.removeItem(STORAGE_KEY); logger.info(`Analyzing audio file (frontend from path): ${filePath}`);
} const start = Date.now();
catch (err) { const prefs = loadAudioAnalysisPreferences();
}
clearSpectrumCache(); const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as
}, []); | ((path: string) => Promise<string>)
useEffect(() => { | undefined;
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) { if (!readFileAsBase64) {
return; throw new Error("ReadFileAsBase64 backend method is unavailable");
}
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);
} }
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;
requestAnimationFrame(() => { } finally {
requestAnimationFrame(loadSpectrum); setAnalyzing(false);
});
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
} }
}; }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
}, [result, selectedFilePath, spectrumLoading]);
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
if (!selectedFilePath || !result) if (!result || !samplesRef.current) return;
return;
setSpectrumLoading(true); setSpectrumLoading(true);
try { try {
const response = await AnalyzeSpectrumWithParams(selectedFilePath, fftSize, windowFunction); const spectrum = analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
const spectrumData = JSON.parse(response); fftSize,
setResult(prev => prev ? { ...prev, spectrum: spectrumData } : null); windowFunction: toWindowFunction(windowFunction),
setSpectrumCache(selectedFilePath, spectrumData); });
} setResult((prev) => {
catch (err) { const next = prev ? { ...prev, spectrum } : prev;
sessionResult = next;
return next;
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
logger.error(`Spectrum re-analysis error: ${errorMessage}`); logger.error(`Spectrum re-analysis error: ${errorMessage}`);
toast.error("Spectrum Analysis Failed", { toast.error("Spectrum Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
} } finally {
finally {
setSpectrumLoading(false); setSpectrumLoading(false);
} }
}, [selectedFilePath, result]); }, [result]);
const clearResult = useCallback(() => {
setResultWithSession(null);
setErrorWithSession(null);
setSelectedFilePathWithSession("");
setSpectrumLoading(false);
samplesRef.current = null;
sessionSamples = null;
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
return { return {
analyzing, analyzing,
@@ -165,6 +202,7 @@ export function useAudioAnalysis() {
selectedFilePath, selectedFilePath,
spectrumLoading, spectrumLoading,
analyzeFile, analyzeFile,
analyzeFilePath,
reAnalyzeSpectrum, reAnalyzeSpectrum,
clearResult, 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.
}
}
+299
View File
@@ -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();
}
}
-4
View File
@@ -7,7 +7,6 @@ require (
github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0 github.com/go-flac/go-flac v1.0.0
github.com/mewkiz/flac v1.0.13
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/ulikunitz/xz v0.5.15 github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
@@ -22,7 +21,6 @@ require (
github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/labstack/echo/v4 v4.13.4 // indirect github.com/labstack/echo/v4 v4.13.4 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
@@ -32,8 +30,6 @@ require (
github.com/leaanthony/u v1.1.1 // indirect github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
-10
View File
@@ -21,10 +21,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
@@ -48,12 +44,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=