aimusic-attribution / client /src /components /AudioPlayerWithMatches.tsx
emresar's picture
Upload folder using huggingface_hub
6678fa1 verified
import { useRef, useState, useEffect, useMemo } from "react";
import { Play, Pause, Volume2, VolumeX } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface ChunkMatch {
score: number;
title: string;
artist: string;
stemType?: string;
matchedStartTime?: number;
matchedEndTime?: number;
matchType?: "exact" | "similar";
}
interface ChunkAttribution {
chunkIndex: number;
startTime: number;
endTime: number;
matches: ChunkMatch[];
}
interface AudioPlayerWithMatchesProps {
src: string;
title: string;
stemType?: string;
attributions: Array<{
id: number;
score: number;
metadata: {
chunkIndex?: number;
startTime?: number;
endTime?: number;
matchedTitle?: string;
matchedArtist?: string;
matchedStemType?: string;
matchedStartTime?: number;
matchedEndTime?: number;
matchType?: "exact" | "similar";
} | null;
}>;
}
export function AudioPlayerWithMatches({
src,
title,
stemType,
attributions
}: AudioPlayerWithMatchesProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const waveformRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isMuted, setIsMuted] = useState(false);
const [hoverTime, setHoverTime] = useState<number | null>(null);
const [hoverX, setHoverX] = useState<number>(0);
// Group attributions by chunk
const chunkAttributions = useMemo(() => {
const chunks: Map<number, ChunkAttribution> = new Map();
for (const attr of attributions) {
const meta = attr.metadata;
if (!meta || meta.chunkIndex === undefined) continue;
const chunkIndex = meta.chunkIndex;
const startTime = meta.startTime || 0;
// For first 10 seconds, only show exact matches (skip style matches)
const isFirstChunk = startTime < 1;
if (isFirstChunk && meta.matchType !== "exact") {
continue; // Skip style matches in first chunk
}
if (!chunks.has(chunkIndex)) {
chunks.set(chunkIndex, {
chunkIndex,
startTime,
endTime: meta.endTime || 0,
matches: [],
});
}
const chunk = chunks.get(chunkIndex)!;
chunk.matches.push({
score: attr.score,
title: meta.matchedTitle || "Unknown",
artist: meta.matchedArtist || "Unknown",
stemType: meta.matchedStemType,
matchedStartTime: meta.matchedStartTime,
matchedEndTime: meta.matchedEndTime,
matchType: meta.matchType,
});
}
// Sort matches by score within each chunk
for (const chunk of chunks.values()) {
chunk.matches.sort((a, b) => b.score - a.score);
}
return Array.from(chunks.values()).sort((a, b) => a.startTime - b.startTime);
}, [attributions]);
// Get matches for current hover position
const hoveredChunk = useMemo(() => {
if (hoverTime === null) return null;
return chunkAttributions.find(
c => hoverTime >= c.startTime && hoverTime < c.endTime
);
}, [hoverTime, chunkAttributions]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
const handleDurationChange = () => setDuration(audio.duration);
const handleEnded = () => setIsPlaying(false);
audio.addEventListener("timeupdate", handleTimeUpdate);
audio.addEventListener("durationchange", handleDurationChange);
audio.addEventListener("ended", handleEnded);
return () => {
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.removeEventListener("durationchange", handleDurationChange);
audio.removeEventListener("ended", handleEnded);
};
}, []);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
setIsPlaying(!isPlaying);
};
const toggleMute = () => {
const audio = audioRef.current;
if (!audio) return;
audio.muted = !isMuted;
setIsMuted(!isMuted);
};
const handleSeek = (value: number[]) => {
const audio = audioRef.current;
if (!audio || !value[0]) return;
audio.currentTime = value[0];
setCurrentTime(value[0]);
};
const handleWaveformHover = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const time = percentage * duration;
setHoverTime(time);
setHoverX(x);
};
const handleWaveformLeave = () => {
setHoverTime(null);
};
const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const time = percentage * duration;
handleSeek([time]);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
// Get color based on match type: green for exact, orange for style
const getChunkColor = (chunk: ChunkAttribution) => {
const topMatch = chunk.matches[0];
if (!topMatch) return "bg-gray-500/20";
// Check if any match is exact
const hasExact = chunk.matches.some(m => m.matchType === "exact");
if (hasExact) {
// Green shades for exact matches
if (topMatch.score > 0.9) return "bg-green-500/70";
if (topMatch.score > 0.8) return "bg-green-500/50";
return "bg-green-500/40";
} else {
// Orange shades for style matches
if (topMatch.score > 0.9) return "bg-orange-500/60";
if (topMatch.score > 0.8) return "bg-orange-500/50";
return "bg-orange-500/40";
}
};
return (
<Card className="relative">
<CardContent className="p-4 overflow-visible">
<div className="flex items-center gap-4 mb-3">
<Button
variant="outline"
size="icon"
onClick={togglePlay}
className="shrink-0"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate">{title}</h4>
{stemType && (
<Badge variant="secondary" className="text-xs">
{stemType}
</Badge>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={toggleMute}
className="shrink-0"
>
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
</div>
{/* Waveform */}
<div className="relative">
<div
ref={waveformRef}
className="relative h-16 bg-secondary/30 rounded-lg cursor-pointer overflow-hidden"
onMouseMove={handleWaveformHover}
onMouseLeave={handleWaveformLeave}
onClick={handleWaveformClick}
>
{/* Chunk regions with match intensity coloring */}
{duration > 0 && chunkAttributions.map((chunk) => {
const left = (chunk.startTime / duration) * 100;
const width = ((chunk.endTime - chunk.startTime) / duration) * 100;
return (
<div
key={chunk.chunkIndex}
className={`absolute top-0 h-full ${getChunkColor(chunk)} border-r border-background/20`}
style={{ left: `${left}%`, width: `${width}%` }}
/>
);
})}
{/* Playhead */}
{duration > 0 && (
<div
className="absolute top-0 h-full w-0.5 bg-primary z-10"
style={{ left: `${(currentTime / duration) * 100}%` }}
/>
)}
{/* Hover indicator */}
{hoverTime !== null && duration > 0 && (
<div
className="absolute top-0 h-full w-0.5 bg-foreground/50 z-10"
style={{ left: `${(hoverTime / duration) * 100}%` }}
/>
)}
{/* Waveform placeholder bars */}
<div className="absolute inset-0 flex items-center justify-around px-1">
{Array.from({ length: 60 }).map((_, i) => (
<div
key={i}
className="w-1 bg-foreground/20 rounded-full"
style={{
height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 20}%`,
}}
/>
))}
</div>
</div>
</div>
{/* Time display */}
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
{/* Matches panel - shown on hover */}
<div className={`mt-3 rounded-lg border bg-muted/50 transition-all duration-200 overflow-hidden ${hoveredChunk ? 'max-h-40 p-3' : 'max-h-0 p-0 border-transparent'}`}>
{hoveredChunk && (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium">
Matches for {formatTime(hoveredChunk.startTime)} - {formatTime(hoveredChunk.endTime)}
</span>
<Badge variant="outline" className="text-[10px]">
{hoveredChunk.matches.length} matches
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{hoveredChunk.matches.slice(0, 5).map((match, i) => (
<div key={i} className="flex items-center gap-1.5 bg-background rounded-md px-2 py-1 border">
<Badge
variant={match.score > 0.7 ? "destructive" : "secondary"}
className="text-[10px] px-1"
>
{(match.score * 100).toFixed(0)}%
</Badge>
<span className="text-xs truncate max-w-[120px]">{match.title}</span>
</div>
))}
{hoveredChunk.matches.length === 0 && (
<span className="text-xs text-muted-foreground">No matching tracks found</span>
)}
</div>
</>
)}
</div>
{/* Hidden audio element */}
<audio ref={audioRef} src={src} preload="metadata" />
</CardContent>
</Card>
);
}