Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,6 @@ Remaining features and improvements for SampleSpace.

## UI Features

### Waveform Visualization

Add wavesurfer.js waveform rendering to the sample browser and detail view.

- Add `wavesurfer.js` dependency to frontend
- Create `components/waveform-viz.tsx` — renders interactive waveform for a given audio URL
- Integrate into sample cards (mini waveform) and detail view (full waveform with seek/zoom)
- Use the existing `GET /api/samples/{sample_id}/audio` endpoint as the source

### Sample Detail View

Dedicated view showing full metadata, similar samples, and audio visualization for a single sample.
Expand Down
104 changes: 52 additions & 52 deletions frontend/components/sample-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import { useQuery } from "@tanstack/react-query";
import { Pause, Play } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { listSamplesOptions } from "@/api/generated/@tanstack/react-query.gen";
import type { SampleSchema } from "@/api/generated/types.gen";
import { Button } from "@/components/ui/button";
import { WaveformViz } from "@/components/waveform-viz";

const BACKEND_URL =
process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8002";
Expand All @@ -28,51 +29,65 @@ function SampleCard({
sample,
isPlaying,
onTogglePlay,
onPlaybackEnd,
}: {
sample: SampleSchema;
isPlaying: boolean;
onTogglePlay: (sample: SampleSchema) => void;
onPlaybackEnd: () => void;
}) {
return (
<div className="flex items-center gap-2 rounded-lg border bg-card p-2 text-sm">
<button
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
onClick={() => onTogglePlay(sample)}
type="button"
>
{isPlaying ? <Pause size={12} /> : <Play size={12} />}
</button>
<div className="rounded-lg border bg-card p-2 text-sm">
<div className="flex items-center gap-2">
<button
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
onClick={() => onTogglePlay(sample)}
type="button"
>
{isPlaying ? <Pause size={12} /> : <Play size={12} />}
</button>

<div className="min-w-0 flex-1">
<div className="truncate font-medium text-xs" title={sample.filename}>
{sample.filename}
</div>
<div className="flex flex-wrap gap-1 mt-0.5">
{sample.sample_type && (
<span className="rounded bg-secondary px-1 py-0.5 text-[10px] text-muted-foreground">
{sample.sample_type}
</span>
)}
{sample.key && (
<span className="rounded bg-secondary px-1 py-0.5 text-[10px] text-muted-foreground">
{sample.key}
</span>
)}
{sample.bpm != null && sample.bpm > 0 && (
<span className="rounded bg-secondary px-1 py-0.5 text-[10px] text-muted-foreground">
{sample.bpm} BPM
</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-xs" title={sample.filename}>
{sample.filename}
</div>
<div className="flex flex-wrap gap-1 mt-0.5">
{sample.sample_type && (
<span className="rounded bg-secondary px-1 py-0.5 text-[10px] text-muted-foreground">
{sample.sample_type}
</span>
)}
{sample.key && (
<span className="rounded bg-secondary px-1 py-0.5 text-[10px] text-muted-foreground">
{sample.key}
</span>
)}
{sample.bpm != null && sample.bpm > 0 && (
<span className="rounded bg-secondary px-1 py-0.5 text-[10px] text-muted-foreground">
{sample.bpm} BPM
</span>
)}
</div>
</div>
</div>

{isPlaying && (
<div className="mt-2">
<WaveformViz
audioUrl={`${BACKEND_URL}/api/samples/${sample.id}/audio`}
height={40}
autoplay
onFinish={onPlaybackEnd}
/>
</div>
)}
</div>
);
}

export function SampleBrowser() {
const [activeType, setActiveType] = useState<string | null>(null);
const [playingId, setPlayingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);

const { data, isLoading } = useQuery(
listSamplesOptions({
Expand All @@ -85,29 +100,13 @@ export function SampleBrowser() {
? samples.filter((s) => s.sample_type === activeType)
: samples;

const handleTogglePlay = useCallback(
(sample: SampleSchema) => {
if (playingId === sample.id) {
// Stop current
audioRef.current?.pause();
setPlayingId(null);
return;
}
const handleTogglePlay = useCallback((sample: SampleSchema) => {
setPlayingId((prev) => (prev === sample.id ? null : sample.id));
}, []);

// Stop previous and play new
if (audioRef.current) {
audioRef.current.pause();
}

const audio = new Audio(`${BACKEND_URL}/api/samples/${sample.id}/audio`);
audio.onended = () => setPlayingId(null);
audio.onerror = () => setPlayingId(null);
audio.play();
audioRef.current = audio;
setPlayingId(sample.id);
},
[playingId],
);
const handlePlaybackEnd = useCallback(() => {
setPlayingId(null);
}, []);

return (
<div className="flex h-full flex-col">
Expand Down Expand Up @@ -159,6 +158,7 @@ export function SampleBrowser() {
<SampleCard
isPlaying={playingId === sample.id}
key={sample.id}
onPlaybackEnd={handlePlaybackEnd}
onTogglePlay={handleTogglePlay}
sample={sample}
/>
Expand Down
80 changes: 80 additions & 0 deletions frontend/components/waveform-viz.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import WavesurferPlayer from "@wavesurfer/react";
import { useCallback, useState } from "react";
import type WaveSurfer from "wavesurfer.js";

function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}

function getThemeColors() {
const style = getComputedStyle(document.documentElement);
const primary = style.getPropertyValue("--primary").trim();
const muted = style.getPropertyValue("--muted-foreground").trim();
return {
progressColor: primary || "#3b82f6",
waveColor: muted || "#a1a1aa",
};
}

interface WaveformVizProps {
audioUrl: string;
height?: number;
autoplay?: boolean;
onFinish?: () => void;
}

export function WaveformViz({
audioUrl,
height = 40,
autoplay = false,
onFinish,
}: WaveformVizProps) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [colors] = useState(() =>
typeof document !== "undefined"
? getThemeColors()
: { progressColor: "#3b82f6", waveColor: "#a1a1aa" },
);

const handleReady = useCallback(
(ws: WaveSurfer) => {
setDuration(ws.getDuration());
if (autoplay) {
ws.play();
}
},
[autoplay],
);

const handleTimeupdate = useCallback((ws: WaveSurfer) => {
setCurrentTime(ws.getCurrentTime());
}, []);

return (
<div className="w-full">
<WavesurferPlayer
url={audioUrl}
height={height}
waveColor={colors.waveColor}
progressColor={colors.progressColor}
barWidth={2}
barGap={1}
barRadius={2}
cursorWidth={0}
interact={true}
onReady={handleReady}
onTimeupdate={handleTimeupdate}
onFinish={onFinish}
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.91.2",
"@wavesurfer/react": "^1.0.12",
"ai": "^6.0.116",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
Expand All @@ -35,7 +36,8 @@
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.3"
"use-stick-to-bottom": "^1.1.3",
"wavesurfer.js": "^7.12.4"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
Expand Down
22 changes: 22 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading