From 556b694a99a7deb45652638e05d2bcbe4adfc203 Mon Sep 17 00:00:00 2001 From: Luke Mainwaring Date: Fri, 20 Mar 2026 13:33:29 -0400 Subject: [PATCH 1/3] waveform viz --- frontend/components/sample-browser.tsx | 104 ++++++++++++------------- frontend/components/waveform-viz.tsx | 84 ++++++++++++++++++++ frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 22 ++++++ 4 files changed, 161 insertions(+), 53 deletions(-) create mode 100644 frontend/components/waveform-viz.tsx diff --git a/frontend/components/sample-browser.tsx b/frontend/components/sample-browser.tsx index 3d49df1..c901a86 100644 --- a/frontend/components/sample-browser.tsx +++ b/frontend/components/sample-browser.tsx @@ -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"; @@ -28,43 +29,58 @@ function SampleCard({ sample, isPlaying, onTogglePlay, + onPlaybackEnd, }: { sample: SampleSchema; isPlaying: boolean; onTogglePlay: (sample: SampleSchema) => void; + onPlaybackEnd: () => void; }) { return ( -
- +
+
+ -
-
- {sample.filename} -
-
- {sample.sample_type && ( - - {sample.sample_type} - - )} - {sample.key && ( - - {sample.key} - - )} - {sample.bpm != null && sample.bpm > 0 && ( - - {sample.bpm} BPM - - )} +
+
+ {sample.filename} +
+
+ {sample.sample_type && ( + + {sample.sample_type} + + )} + {sample.key && ( + + {sample.key} + + )} + {sample.bpm != null && sample.bpm > 0 && ( + + {sample.bpm} BPM + + )} +
+ + {isPlaying && ( +
+ +
+ )}
); } @@ -72,7 +88,6 @@ function SampleCard({ export function SampleBrowser() { const [activeType, setActiveType] = useState(null); const [playingId, setPlayingId] = useState(null); - const audioRef = useRef(null); const { data, isLoading } = useQuery( listSamplesOptions({ @@ -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 (
@@ -159,6 +158,7 @@ export function SampleBrowser() { diff --git a/frontend/components/waveform-viz.tsx b/frontend/components/waveform-viz.tsx new file mode 100644 index 0000000..bded35e --- /dev/null +++ b/frontend/components/waveform-viz.tsx @@ -0,0 +1,84 @@ +"use client"; + +import WavesurferPlayer from "@wavesurfer/react"; +import { useCallback, useEffect, useRef, 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 colorsRef = useRef(getThemeColors()); + + useEffect(() => { + colorsRef.current = getThemeColors(); + }, []); + + const handleReady = useCallback( + (ws: WaveSurfer) => { + setDuration(ws.getDuration()); + if (autoplay) { + ws.play(); + } + }, + [autoplay], + ); + + const handleTimeupdate = useCallback((ws: WaveSurfer) => { + setCurrentTime(ws.getCurrentTime()); + }, []); + + const handleFinish = useCallback(() => { + onFinish?.(); + }, [onFinish]); + + return ( +
+ +
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ ); +} diff --git a/frontend/package.json b/frontend/package.json index b3e76fe..7038324 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e4927bc..1cc9dab 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@tanstack/react-query': specifier: ^5.91.2 version: 5.91.2(react@19.2.4) + '@wavesurfer/react': + specifier: ^1.0.12 + version: 1.0.12(react@19.2.4)(wavesurfer.js@7.12.4) ai: specifier: ^6.0.116 version: 6.0.116(zod@4.3.6) @@ -83,6 +86,9 @@ importers: use-stick-to-bottom: specifier: ^1.1.3 version: 1.1.3(react@19.2.4) + wavesurfer.js: + specifier: ^7.12.4 + version: 7.12.4 devDependencies: '@biomejs/biome': specifier: 2.4.8 @@ -1090,6 +1096,12 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@wavesurfer/react@1.0.12': + resolution: {integrity: sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + wavesurfer.js: '>=7.7.14' + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -2357,6 +2369,9 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + wavesurfer.js@7.12.4: + resolution: {integrity: sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -3285,6 +3300,11 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@wavesurfer/react@1.0.12(react@19.2.4)(wavesurfer.js@7.12.4)': + dependencies: + react: 19.2.4 + wavesurfer.js: 7.12.4 + acorn@8.16.0: {} ai@6.0.116(zod@4.3.6): @@ -4837,6 +4857,8 @@ snapshots: vscode-uri@3.1.0: {} + wavesurfer.js@7.12.4: {} + web-namespaces@2.0.1: {} which@2.0.2: From a7b196e29282c50b2b2a793d9d791dd2038777ae Mon Sep 17 00:00:00 2001 From: Luke Mainwaring Date: Fri, 20 Mar 2026 13:37:02 -0400 Subject: [PATCH 2/3] pr feedback --- frontend/components/waveform-viz.tsx | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/frontend/components/waveform-viz.tsx b/frontend/components/waveform-viz.tsx index bded35e..a7ff2b5 100644 --- a/frontend/components/waveform-viz.tsx +++ b/frontend/components/waveform-viz.tsx @@ -1,7 +1,7 @@ "use client"; import WavesurferPlayer from "@wavesurfer/react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import type WaveSurfer from "wavesurfer.js"; function formatTime(seconds: number): string { @@ -35,11 +35,11 @@ export function WaveformViz({ }: WaveformVizProps) { const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - const colorsRef = useRef(getThemeColors()); - - useEffect(() => { - colorsRef.current = getThemeColors(); - }, []); + const [colors] = useState(() => + typeof document !== "undefined" + ? getThemeColors() + : { progressColor: "#3b82f6", waveColor: "#a1a1aa" }, + ); const handleReady = useCallback( (ws: WaveSurfer) => { @@ -55,17 +55,13 @@ export function WaveformViz({ setCurrentTime(ws.getCurrentTime()); }, []); - const handleFinish = useCallback(() => { - onFinish?.(); - }, [onFinish]); - return (
{formatTime(currentTime)} From 68b6ca945bf62a96defb18fdf78fdd8be8f227d7 Mon Sep 17 00:00:00 2001 From: Luke Mainwaring Date: Fri, 20 Mar 2026 13:38:42 -0400 Subject: [PATCH 3/3] remove from roadmap --- docs/ROADMAP.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8c980e0..48415e5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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.