Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions

View File

@@ -0,0 +1,531 @@
"use client";
import { useAudioState, AudioFeatures } from "@/lib/audio-state-context";
import { cn } from "@/utils/cn";
import { useMemo, useState, useEffect, useRef, memo } from "react";
import { X, Maximize2, Minimize2 } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import {
ResponsiveContainer,
RadarChart,
PolarGrid,
PolarAngleAxis,
Radar,
} from "recharts";
// Extended feature interface for all enhanced vibe data
interface ExtendedFeatures extends AudioFeatures {
danceabilityMl?: number | null;
}
// Feature configurations for radar chart
const RADAR_FEATURES = [
{ key: "energy", label: "Energy", min: 0, max: 1 },
{ key: "valence", label: "Mood", min: 0, max: 1 },
{ key: "arousal", label: "Arousal", min: 0, max: 1 },
{ key: "danceability", label: "Dance", min: 0, max: 1 },
{ key: "bpm", label: "Tempo", min: 60, max: 200 },
{ key: "moodHappy", label: "Happy", min: 0, max: 1 },
{ key: "moodSad", label: "Sad", min: 0, max: 1 },
{ key: "moodRelaxed", label: "Relaxed", min: 0, max: 1 },
{ key: "moodAggressive", label: "Aggressive", min: 0, max: 1 },
{ key: "moodParty", label: "Party", min: 0, max: 1 },
{ key: "moodAcoustic", label: "Acoustic", min: 0, max: 1 },
{ key: "moodElectronic", label: "Electronic", min: 0, max: 1 },
];
const ML_MOODS = [
{ key: "moodHappy", label: "Happy", color: "#ecb200" },
{ key: "moodSad", label: "Sad", color: "#5c8dd6" },
{ key: "moodRelaxed", label: "Relaxed", color: "#1db954" },
{ key: "moodAggressive", label: "Aggressive", color: "#e35656" },
{ key: "moodParty", label: "Party", color: "#e056a0" },
{ key: "moodAcoustic", label: "Acoustic", color: "#d4a656" },
{ key: "moodElectronic", label: "Electronic", color: "#a056e0" },
];
interface EnhancedVibeOverlayProps {
className?: string;
currentTrackFeatures?: ExtendedFeatures | null;
variant?: "floating" | "inline";
onClose?: () => void;
}
// Helper to normalize values
function normalizeValue(
value: number | null | undefined,
min: number,
max: number
): number {
if (value == null) return 0;
return Math.max(0, Math.min(1, (value - min) / (max - min)));
}
// Helper to get feature value safely
function getFeatureValue(
features: ExtendedFeatures | null | undefined,
key: string
): number | null {
if (!features) return null;
const value = (features as Record<string, unknown>)[key];
if (typeof value === "number") return value;
return null;
}
// Audio waveform visualization component - slower, smoother animation
const AudioWaveform = memo(function AudioWaveform({
energy,
bpm,
}: {
energy: number;
bpm: number;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const timeRef = useRef(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const resize = () => {
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
ctx.scale(2, 2);
};
resize();
const animate = () => {
const width = canvas.offsetWidth;
const height = canvas.offsetHeight;
ctx.clearRect(0, 0, width, height);
// Calculate wave parameters based on audio features - SLOWER
const baseAmplitude = height * 0.35 * Math.max(0.3, energy);
const frequency = (bpm / 120) * 0.015; // Reduced frequency
const speed = (bpm / 120) * 0.015; // Slower speed (was 0.05)
// Draw multiple layered waves with #ecb200 accent
for (let layer = 0; layer < 3; layer++) {
const layerOffset = layer * 0.4;
const layerAmplitude = baseAmplitude * (1 - layer * 0.2);
const alpha = 0.5 - layer * 0.12;
ctx.beginPath();
ctx.moveTo(0, height / 2);
for (let x = 0; x <= width; x += 2) {
const y =
height / 2 +
Math.sin(
(x * frequency + timeRef.current * speed + layerOffset) *
Math.PI
) *
layerAmplitude +
Math.sin(
(x * frequency * 1.5 + timeRef.current * speed * 0.8) *
Math.PI
) *
(layerAmplitude * 0.25);
ctx.lineTo(x, y);
}
ctx.strokeStyle = "#ecb200";
ctx.globalAlpha = alpha;
ctx.lineWidth = 2 - layer * 0.4;
ctx.stroke();
}
// Draw glow effect
ctx.globalAlpha = 0.15;
ctx.filter = "blur(10px)";
ctx.beginPath();
ctx.moveTo(0, height / 2);
for (let x = 0; x <= width; x += 2) {
const y =
height / 2 +
Math.sin((x * frequency + timeRef.current * speed) * Math.PI) *
baseAmplitude;
ctx.lineTo(x, y);
}
ctx.strokeStyle = "#ecb200";
ctx.lineWidth = 8;
ctx.stroke();
ctx.filter = "none";
ctx.globalAlpha = 1;
timeRef.current += 1;
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [energy, bpm]);
return (
<canvas
ref={canvasRef}
className="w-full h-14"
style={{ opacity: 0.7 }}
/>
);
});
export function EnhancedVibeOverlay({
className,
currentTrackFeatures,
variant = "floating",
onClose,
}: EnhancedVibeOverlayProps) {
const { vibeMode, vibeSourceFeatures } = useAudioState();
const [isExpanded, setIsExpanded] = useState(true);
const [viewMode, setViewMode] = useState<"full" | "minimal">("full");
// Use vibeSourceFeatures as the source
const sourceFeatures = vibeSourceFeatures as ExtendedFeatures | null;
// Current features should be from the actual current track, NOT fallback to source
const currentFeatures = currentTrackFeatures as ExtendedFeatures | null;
// For display, use currentFeatures if available, otherwise show source (for first track)
const displayFeatures = currentFeatures || sourceFeatures;
// Prepare radar chart data
const radarData = useMemo(() => {
return RADAR_FEATURES.map((feature) => {
const sourceVal = getFeatureValue(sourceFeatures, feature.key);
// For current, use displayFeatures (falls back to source if current is null)
const currentVal = getFeatureValue(displayFeatures, feature.key);
return {
feature: feature.label,
source: normalizeValue(sourceVal, feature.min, feature.max) * 100,
current: normalizeValue(currentVal, feature.min, feature.max) * 100,
fullMark: 100,
};
});
}, [sourceFeatures, displayFeatures]);
// Calculate overall match score using cosine similarity (same as backend)
const matchScore = useMemo(() => {
if (!sourceFeatures || !currentFeatures) return null;
// Build feature vectors for cosine similarity
const sourceVector: number[] = [];
const currentVector: number[] = [];
RADAR_FEATURES.forEach((feature) => {
const sourceVal = getFeatureValue(sourceFeatures, feature.key);
const currentVal = getFeatureValue(currentFeatures, feature.key);
// Use 0.5 as default for missing values (neutral)
const sourceNorm = normalizeValue(sourceVal ?? (feature.min + feature.max) / 2, feature.min, feature.max);
const currentNorm = normalizeValue(currentVal ?? (feature.min + feature.max) / 2, feature.min, feature.max);
// Weight ML mood features higher (1.3x) like backend does
const weight = feature.key.startsWith("mood") ? 1.3 : 1.0;
sourceVector.push(sourceNorm * weight);
currentVector.push(currentNorm * weight);
});
// Calculate cosine similarity
let dotProduct = 0;
let magSource = 0;
let magCurrent = 0;
for (let i = 0; i < sourceVector.length; i++) {
dotProduct += sourceVector[i] * currentVector[i];
magSource += sourceVector[i] * sourceVector[i];
magCurrent += currentVector[i] * currentVector[i];
}
const magnitude = Math.sqrt(magSource) * Math.sqrt(magCurrent);
if (magnitude === 0) return null;
const similarity = dotProduct / magnitude;
return Math.round(similarity * 100);
}, [sourceFeatures, currentFeatures]);
// Get audio features for waveform - use current if available, fallback to source
const energy = getFeatureValue(currentFeatures, "energy") ?? getFeatureValue(sourceFeatures, "energy") ?? 0.5;
const bpm = getFeatureValue(currentFeatures, "bpm") ?? getFeatureValue(sourceFeatures, "bpm") ?? 120;
// Don't render if not in vibe mode
if (!vibeMode) return null;
const isFloating = variant === "floating";
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={cn(
// Spotify-inspired dark theme
"bg-[#121212] text-white relative overflow-hidden",
isFloating
? "fixed bottom-24 right-4 z-50 rounded-xl shadow-2xl w-[380px] border border-[#282828]"
: "rounded-xl w-full border border-[#282828]",
className
)}
>
{/* Header */}
<div
className={cn(
"flex items-center justify-between px-4 py-3 border-b border-[#282828] cursor-pointer",
isFloating && "hover:bg-[#1a1a1a] transition-colors"
)}
onClick={() => isFloating && setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-3">
<motion.div
animate={{
boxShadow: [
"0 0 8px #ecb200",
"0 0 16px #ecb200",
"0 0 8px #ecb200",
],
}}
transition={{ duration: 2, repeat: Infinity }}
className="w-2.5 h-2.5 rounded-full bg-[#ecb200]"
/>
<span className="text-sm font-semibold tracking-wide">
Vibe Analysis
</span>
{matchScore !== null && (
<span
className={cn(
"text-xs font-bold px-2.5 py-1 rounded-full",
matchScore >= 80
? "bg-[#1db954]/20 text-[#1db954]"
: matchScore >= 60
? "bg-[#ecb200]/20 text-[#ecb200]"
: "bg-[#e35656]/20 text-[#e35656]"
)}
>
{matchScore}% Match
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setViewMode(viewMode === "full" ? "minimal" : "full");
}}
className="p-2 rounded-full hover:bg-[#282828] transition-colors"
title={viewMode === "full" ? "Minimal view" : "Full view"}
>
{viewMode === "full" ? (
<Minimize2 className="w-4 h-4 text-[#b3b3b3]" />
) : (
<Maximize2 className="w-4 h-4 text-[#b3b3b3]" />
)}
</button>
{isFloating && onClose && (
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="p-2 hover:bg-[#282828] rounded-full transition-colors"
>
<X className="w-4 h-4 text-[#b3b3b3]" />
</button>
)}
</div>
</div>
{/* Content */}
<AnimatePresence mode="wait">
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* Waveform visualization */}
<div className="px-4 pt-3 pb-1">
<AudioWaveform energy={energy} bpm={bpm} />
</div>
{viewMode === "full" ? (
<div className="p-4 space-y-4">
{/* Radar Chart */}
<div className="h-[260px] w-full bg-[#181818] rounded-lg p-2">
<ResponsiveContainer width="100%" height="100%">
<RadarChart
data={radarData}
margin={{ top: 20, right: 30, bottom: 20, left: 30 }}
>
<PolarGrid
stroke="#282828"
strokeDasharray="3 3"
/>
<PolarAngleAxis
dataKey="feature"
tick={{
fill: "#b3b3b3",
fontSize: 10,
fontWeight: 500,
}}
tickLine={false}
/>
{/* Source track (dashed yellow) */}
<Radar
name="Source"
dataKey="source"
stroke="#ecb200"
fill="#ecb200"
fillOpacity={0.1}
strokeWidth={2}
strokeDasharray="5 5"
/>
{/* Current track (solid white) */}
<Radar
name="Current"
dataKey="current"
stroke="#ffffff"
fill="#ffffff"
fillOpacity={0.15}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{/* Feature cards */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-[#181818] rounded-lg p-3">
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-1">
Analysis Mode
</div>
<div className="text-sm font-semibold capitalize text-white">
{sourceFeatures?.analysisMode || "Standard"}
</div>
</div>
<div className="bg-[#181818] rounded-lg p-3">
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-1">
Tempo
</div>
<div className="text-sm font-semibold text-white">
{bpm ? `${Math.round(bpm)} BPM` : "N/A"}
</div>
</div>
</div>
{/* ML Mood Spectrum - shows current track's moods */}
<div className="bg-[#181818] rounded-lg p-4">
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-3">
Mood Spectrum {!currentFeatures && "(Source Track)"}
</div>
<div className="space-y-3">
{ML_MOODS.map((mood) => {
const value = getFeatureValue(
displayFeatures,
mood.key
);
// Show the bar even if value is 0, only hide if null/undefined
const percentage = value != null ? Math.round(value * 100) : 0;
const hasValue = value != null;
return (
<div key={mood.key} className="flex items-center gap-3">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: mood.color }}
/>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs text-[#b3b3b3]">
{mood.label}
</span>
<span className="text-xs font-medium tabular-nums text-white">
{hasValue ? `${percentage}%` : "—"}
</span>
</div>
<div className="w-full bg-[#282828] rounded-full h-1 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{
width: hasValue ? `${Math.max(percentage, 2)}%` : "0%",
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
className="h-full rounded-full"
style={{ backgroundColor: mood.color }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
/* Minimal view - just radar */
<div className="p-4">
<div className="h-[180px] w-full bg-[#181818] rounded-lg p-2">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={radarData}>
<PolarGrid stroke="#282828" />
<PolarAngleAxis
dataKey="feature"
tick={{ fill: "#b3b3b3", fontSize: 9 }}
tickLine={false}
/>
<Radar
name="Source"
dataKey="source"
stroke="#ecb200"
fill="#ecb200"
fillOpacity={0.1}
strokeWidth={2}
strokeDasharray="5 5"
/>
<Radar
name="Current"
dataKey="current"
stroke="#ffffff"
fill="#ffffff"
fillOpacity={0.15}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Legend */}
<div className="flex items-center justify-center gap-6 py-3 border-t border-[#282828]">
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#ecb200]" style={{ borderStyle: "dashed" }} />
<span className="text-[10px] text-[#b3b3b3]">Source</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-2 rounded-sm bg-white/30" />
<span className="text-[10px] text-[#b3b3b3]">Current</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}