Fallback Tidal with search and ISRC matching
This commit is contained in:
@@ -7,7 +7,9 @@ import {
|
||||
Radio,
|
||||
TrendingUp,
|
||||
FileAudio,
|
||||
Clock
|
||||
Clock,
|
||||
Gauge,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
@@ -75,6 +77,21 @@ export function AudioAnalysis({
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
// Calculate Nyquist frequency (half of sample rate)
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
|
||||
// Calculate approximate data size (uncompressed PCM)
|
||||
// Formula: sample_rate * channels * (bits_per_sample / 8) * duration
|
||||
const dataSizeBytes = result.sample_rate * result.channels * (result.bits_per_sample / 8) * result.duration;
|
||||
const dataSizeMB = dataSizeBytes / (1024 * 1024);
|
||||
|
||||
const formatDataSize = (mb: number) => {
|
||||
if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -124,6 +141,22 @@ export function AudioAnalysis({
|
||||
</div>
|
||||
<p className="font-semibold">{formatDuration(result.duration)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Gauge className="h-3 w-3" />
|
||||
Nyquist Frequency
|
||||
</div>
|
||||
<p className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
Data Size
|
||||
</div>
|
||||
<p className="font-semibold">{formatDataSize(dataSizeMB)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Range Analysis */}
|
||||
|
||||
@@ -27,15 +27,15 @@ export function SpectrumVisualization({
|
||||
const height = canvas.height;
|
||||
|
||||
// Calculate margins for labels
|
||||
const marginLeft = 80;
|
||||
const marginRight = 80;
|
||||
const marginTop = 20;
|
||||
const marginBottom = 50;
|
||||
const marginLeft = 70; // More space for Frequency label
|
||||
const marginRight = 70; // Space for color bar
|
||||
const marginTop = 30; // More space at top
|
||||
const marginBottom = 65; // More space at bottom for Time label
|
||||
|
||||
const plotWidth = width - marginLeft - marginRight;
|
||||
const plotHeight = height - marginTop - marginBottom;
|
||||
|
||||
// Black background like Spek
|
||||
// Black background
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
@@ -51,9 +51,11 @@ export function SpectrumVisualization({
|
||||
plotHeight,
|
||||
spectrumData
|
||||
);
|
||||
|
||||
drawGrid(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq);
|
||||
}
|
||||
|
||||
// Draw axes, labels, and color bar
|
||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||
|
||||
const drawRealSpectrum = (
|
||||
@@ -70,40 +72,45 @@ export function SpectrumVisualization({
|
||||
const freqBins = timeSlices[0].magnitudes.length;
|
||||
const nyquistFreq = spectrum.max_freq;
|
||||
|
||||
// Find min/max dB values
|
||||
let minDB = 0;
|
||||
let maxDB = -120;
|
||||
let maxDB = -200;
|
||||
|
||||
timeSlices.forEach((slice) => {
|
||||
slice.magnitudes.forEach((db) => {
|
||||
if (db > maxDB) maxDB = db;
|
||||
if (db < minDB) minDB = db;
|
||||
if (db < minDB && db > -200) minDB = db;
|
||||
});
|
||||
});
|
||||
|
||||
// Clamp range for better visualization
|
||||
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
|
||||
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;
|
||||
const sliceWidth = Math.max(1, width / timeSlices.length);
|
||||
|
||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||
const db = slice.magnitudes[f];
|
||||
|
||||
// Linear frequency scale like Spek
|
||||
// Linear frequency scale
|
||||
const freq = (f / freqBins) * nyquistFreq;
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
|
||||
// Calculate next frequency bin position
|
||||
// Calculate bin 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 = (db - minDB) / dbRange;
|
||||
// Normalize intensity
|
||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
@@ -112,161 +119,168 @@ export function SpectrumVisualization({
|
||||
}
|
||||
};
|
||||
|
||||
// Vibrant color scheme like Spek - NGEJERENG!
|
||||
const getSpekColor = (intensity: number): string => {
|
||||
// Enhanced color scheme - better than Spek
|
||||
if (intensity < 0.10) {
|
||||
// Deep black to dark blue
|
||||
const t = intensity / 0.10;
|
||||
return `rgb(0, 0, ${Math.floor(t * 100)})`;
|
||||
} else if (intensity < 0.25) {
|
||||
// Dark blue to bright blue
|
||||
const t = (intensity - 0.10) / 0.15;
|
||||
return `rgb(0, ${Math.floor(t * 50)}, ${Math.floor(100 + t * 155)})`;
|
||||
if (intensity < 0.08) {
|
||||
// Black to deep blue
|
||||
const t = intensity / 0.08;
|
||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||
} else if (intensity < 0.18) {
|
||||
// Deep blue to bright blue
|
||||
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) {
|
||||
// Blue to magenta/purple
|
||||
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) {
|
||||
// Blue to cyan
|
||||
const t = (intensity - 0.25) / 0.15;
|
||||
return `rgb(0, ${Math.floor(50 + t * 205)}, 255)`;
|
||||
} else if (intensity < 0.55) {
|
||||
// Cyan to green
|
||||
const t = (intensity - 0.40) / 0.15;
|
||||
return `rgb(0, 255, ${Math.floor(255 - t * 200)})`;
|
||||
} else if (intensity < 0.70) {
|
||||
// Green to yellow
|
||||
const t = (intensity - 0.55) / 0.15;
|
||||
return `rgb(${Math.floor(t * 255)}, 255, ${Math.floor(55 - t * 55)})`;
|
||||
} else if (intensity < 0.85) {
|
||||
// Yellow to orange
|
||||
const t = (intensity - 0.70) / 0.15;
|
||||
return `rgb(255, ${Math.floor(255 - t * 100)}, 0)`;
|
||||
// Magenta to bright red
|
||||
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) {
|
||||
// Red to orange-red
|
||||
const t = (intensity - 0.40) / 0.12;
|
||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||
} else if (intensity < 0.65) {
|
||||
// Orange-red to bright orange
|
||||
const t = (intensity - 0.52) / 0.13;
|
||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||
} else if (intensity < 0.78) {
|
||||
// Orange to yellow-orange
|
||||
const t = (intensity - 0.65) / 0.13;
|
||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||
} else if (intensity < 0.90) {
|
||||
// Yellow-orange to bright yellow
|
||||
const t = (intensity - 0.78) / 0.12;
|
||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||
} else {
|
||||
// Orange to red
|
||||
const t = (intensity - 0.85) / 0.15;
|
||||
return `rgb(255, ${Math.floor(155 - t * 155)}, ${Math.floor(t * 30)})`;
|
||||
// Yellow to white (hottest)
|
||||
const t = (intensity - 0.90) / 0.10;
|
||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||
}
|
||||
};
|
||||
|
||||
const drawGrid = (
|
||||
const drawAxesAndLabels = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
nyquistFreq: number
|
||||
nyquistFreq: number,
|
||||
duration: number,
|
||||
sampleRate: number
|
||||
) => {
|
||||
// Enhanced grid lines
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
|
||||
ctx.lineWidth = 1;
|
||||
// Frequency labels on Y-axis
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// Dynamic frequency grid lines based on Nyquist frequency
|
||||
const generateFreqLines = (maxFreq: number): number[] => {
|
||||
if (maxFreq <= 24000) {
|
||||
// Standard 44.1/48 kHz (Nyquist ~22/24 kHz)
|
||||
return [1000, 2000, 5000, 10000, 15000, 20000];
|
||||
} else if (maxFreq <= 48000) {
|
||||
// 88.2/96 kHz (Nyquist ~44/48 kHz)
|
||||
return [5000, 10000, 20000, 30000, 40000];
|
||||
} else if (maxFreq <= 96000) {
|
||||
// 176.4/192 kHz (Nyquist ~88/96 kHz)
|
||||
return [10000, 20000, 40000, 60000, 80000];
|
||||
} else {
|
||||
// 352.8/384 kHz and higher (Nyquist ~176/192+ kHz)
|
||||
return [20000, 40000, 80000, 120000, 160000];
|
||||
}
|
||||
};
|
||||
|
||||
const freqLines = generateFreqLines(nyquistFreq);
|
||||
// Generate frequency labels based on Nyquist
|
||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||
|
||||
freqLines.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yPos);
|
||||
ctx.lineTo(x + width, yPos);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// Vertical time grid lines
|
||||
for (let i = 1; i < 10; i++) {
|
||||
const xPos = x + (i / 10) * width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xPos, y);
|
||||
ctx.lineTo(xPos, y + height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = "rgba(220, 220, 220, 0.9)";
|
||||
ctx.font = "11px Arial";
|
||||
|
||||
// Frequency labels - dynamic formatting
|
||||
freqLines.forEach(freq => {
|
||||
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.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(label, x - 6, yPos);
|
||||
ctx.fillText(label, x - 8, yPos);
|
||||
}
|
||||
});
|
||||
|
||||
// Time labels
|
||||
// "0" at bottom
|
||||
ctx.fillText("0", x - 8, y + height);
|
||||
|
||||
// Time labels on X-axis
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const timePos = x + (i / 10) * width;
|
||||
const timeValue = (i / 10) * duration;
|
||||
if (i % 2 === 0) {
|
||||
ctx.fillText(timeValue.toFixed(1), timePos, y + height + 5);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Axis titles
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "bold 13px Arial";
|
||||
ctx.shadowColor = "rgba(0, 0, 0, 0.8)";
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.font = "13px Arial";
|
||||
|
||||
// Y-axis title: "Frequency (Hz)"
|
||||
ctx.save();
|
||||
ctx.translate(8, y + height / 2);
|
||||
ctx.translate(12, y + height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Frequency (kHz)", 0, 0);
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// X-axis title: "Time (seconds)"
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Time (s)", x + width / 2, y + height + 26);
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
||||
|
||||
const boxGradient = ctx.createLinearGradient(x + width - 200, y + 5, x + width - 200, y + 68);
|
||||
boxGradient.addColorStop(0, "rgba(0, 0, 0, 0.85)");
|
||||
boxGradient.addColorStop(1, "rgba(0, 0, 0, 0.7)");
|
||||
ctx.fillStyle = boxGradient;
|
||||
ctx.fillRect(x + width - 200, y + 5, 190, 63);
|
||||
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x + width - 200, y + 5, 190, 63);
|
||||
// Sample rate info in top right
|
||||
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 => {
|
||||
// Always use 30s intervals like the reference image
|
||||
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
|
||||
) => {
|
||||
// Draw gradient color bar
|
||||
for (let i = 0; i < height; i++) {
|
||||
const intensity = 1 - (i / height); // Top is high, bottom is low
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y + i, width, 1);
|
||||
}
|
||||
|
||||
// Border around color bar
|
||||
ctx.strokeStyle = "#666666";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "600 11px Arial";
|
||||
ctx.font = "11px Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
|
||||
ctx.shadowBlur = 2;
|
||||
ctx.fillText(`Sample Rate: ${(sampleRate / 1000).toFixed(1)} kHz`, x + width - 190, y + 20);
|
||||
ctx.fillText(`Bit Depth: ${bitsPerSample}-bit`, x + width - 190, y + 36);
|
||||
ctx.fillText(`Nyquist: ${(nyquistFreq / 1000).toFixed(1)} kHz`, x + width - 190, y + 52);
|
||||
ctx.shadowBlur = 0;
|
||||
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={1600}
|
||||
height={800}
|
||||
width={1200}
|
||||
height={600}
|
||||
className="w-full h-auto"
|
||||
style={{ imageRendering: "auto" }}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,8 @@ export function useDownload() {
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean,
|
||||
position?: number,
|
||||
spotifyId?: string
|
||||
spotifyId?: string,
|
||||
durationMs?: number
|
||||
) => {
|
||||
let service = settings.downloader;
|
||||
|
||||
@@ -72,6 +73,9 @@ export function useDownload() {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert duration from ms to seconds for backend
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
|
||||
// Try Tidal first
|
||||
if (streamingURLs?.tidal_url) {
|
||||
try {
|
||||
@@ -90,6 +94,7 @@ export function useDownload() {
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.tidal_url,
|
||||
duration: durationSeconds,
|
||||
});
|
||||
|
||||
if (tidalResponse.success) {
|
||||
@@ -167,6 +172,9 @@ export function useDownload() {
|
||||
service = "qobuz";
|
||||
}
|
||||
|
||||
// Convert duration from ms to seconds for backend (if not already done above)
|
||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
|
||||
return await downloadTrack({
|
||||
isrc,
|
||||
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
||||
@@ -180,6 +188,7 @@ export function useDownload() {
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
duration: durationSecondsForFallback,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -190,7 +199,8 @@ export function useDownload() {
|
||||
albumName?: string,
|
||||
spotifyId?: string,
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean
|
||||
isArtistDiscography?: boolean,
|
||||
durationMs?: number
|
||||
) => {
|
||||
if (!isrc) {
|
||||
toast.error("No ISRC found for this track");
|
||||
@@ -212,7 +222,8 @@ export function useDownload() {
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
undefined, // Don't pass position for single track
|
||||
spotifyId
|
||||
spotifyId,
|
||||
durationMs
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
@@ -290,7 +301,8 @@ export function useDownload() {
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
i + 1, // Sequential position based on selection order
|
||||
track?.spotify_id
|
||||
track?.spotify_id,
|
||||
track?.duration_ms
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
@@ -394,7 +406,8 @@ export function useDownload() {
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
i + 1,
|
||||
track.spotify_id
|
||||
track.spotify_id,
|
||||
track.duration_ms
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface DownloadRequest {
|
||||
use_album_track_number?: boolean;
|
||||
spotify_id?: string;
|
||||
service_url?: string;
|
||||
duration?: number; // Track duration in seconds for better matching
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
|
||||
Reference in New Issue
Block a user