From ba2cc30d93ebe8056591eda744c2003b50c4784c Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 3 Feb 2026 21:17:18 +1300 Subject: [PATCH] feat(41-02): create AudioInputNode component with waveform visualization - File upload via drag-and-drop or click - Waveform visualization using useAudioVisualization hook - Play/pause controls with scrub bar - Canvas-based rendering with ResizeObserver for responsive sizing - Audio handle (purple/violet) on right side - Min dimensions: 250px width, 150px height - Remove button to clear audio file --- src/components/nodes/AudioInputNode.tsx | 373 ++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 src/components/nodes/AudioInputNode.tsx diff --git a/src/components/nodes/AudioInputNode.tsx b/src/components/nodes/AudioInputNode.tsx new file mode 100644 index 00000000..b9453ea7 --- /dev/null +++ b/src/components/nodes/AudioInputNode.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { useCallback, useRef, useState, useEffect } from "react"; +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { BaseNode } from "./BaseNode"; +import { useCommentNavigation } from "@/hooks/useCommentNavigation"; +import { useWorkflowStore } from "@/store/workflowStore"; +import { AudioInputNodeData } from "@/types"; +import { useAudioVisualization } from "@/hooks/useAudioVisualization"; + +type AudioInputNodeType = Node; + +export function AudioInputNode({ id, data, selected }: NodeProps) { + const nodeData = data; + const commentNavigation = useCommentNavigation(id); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const fileInputRef = useRef(null); + const audioRef = useRef(null); + const canvasRef = useRef(null); + const waveformContainerRef = useRef(null); + const animationFrameRef = useRef(); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [audioBlob, setAudioBlob] = useState(null); + + // Use the audio visualization hook + const { waveformData, isLoading } = useAudioVisualization(audioBlob); + + // Convert base64 data URL to Blob for the hook + useEffect(() => { + if (nodeData.audioFile) { + fetch(nodeData.audioFile) + .then((r) => r.blob()) + .then(setAudioBlob) + .catch(() => setAudioBlob(null)); + } else { + setAudioBlob(null); + } + }, [nodeData.audioFile]); + + // Setup audio element + useEffect(() => { + if (nodeData.audioFile && !audioRef.current) { + const audio = new Audio(nodeData.audioFile); + audioRef.current = audio; + + audio.addEventListener("ended", () => { + setIsPlaying(false); + setCurrentTime(0); + }); + + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime); + }); + } + + return () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + }; + }, [nodeData.audioFile]); + + // Draw waveform on canvas + useEffect(() => { + if (!waveformData || !canvasRef.current || !waveformContainerRef.current) return; + + const canvas = canvasRef.current; + const container = waveformContainerRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Use ResizeObserver for responsive sizing + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const width = entry.contentRect.width; + const height = entry.contentRect.height; + + // Set canvas size + canvas.width = width; + canvas.height = height; + + // Draw waveform + ctx.clearRect(0, 0, width, height); + + const peaks = waveformData.peaks; + const barCount = Math.min(peaks.length, width); + const barWidth = width / barCount; + const barGap = 1; + + ctx.fillStyle = "rgb(167, 139, 250)"; // violet-400 + + for (let i = 0; i < barCount; i++) { + const peakIndex = Math.floor((i / barCount) * peaks.length); + const peak = peaks[peakIndex] || 0; + const barHeight = peak * height; + const x = i * barWidth; + const y = (height - barHeight) / 2; + + ctx.fillRect(x, y, barWidth - barGap, barHeight); + } + + // Draw playback position + if (isPlaying && nodeData.duration) { + const progress = currentTime / nodeData.duration; + const x = progress * width; + + ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, [waveformData, isPlaying, currentTime, nodeData.duration]); + + // Animation loop for smooth playback position updates + useEffect(() => { + if (isPlaying && audioRef.current) { + const updatePosition = () => { + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + } + animationFrameRef.current = requestAnimationFrame(updatePosition); + }; + animationFrameRef.current = requestAnimationFrame(updatePosition); + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isPlaying]); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.match(/^audio\//)) { + alert("Unsupported format. Use MP3, WAV, OGG, AAC, or other audio formats."); + return; + } + + if (file.size > 50 * 1024 * 1024) { + alert("Audio file too large. Maximum size is 50MB."); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + + // Extract duration using HTML Audio element + const audio = new Audio(base64); + audio.onloadedmetadata = () => { + updateNodeData(id, { + audioFile: base64, + filename: file.name, + format: file.type, + duration: audio.duration, + }); + }; + }; + reader.readAsDataURL(file); + }, + [id, updateNodeData] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const file = e.dataTransfer.files?.[0]; + if (!file) return; + + const dt = new DataTransfer(); + dt.items.add(file); + if (fileInputRef.current) { + fileInputRef.current.files = dt.files; + fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true })); + } + }, + [] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleRemove = useCallback(() => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setIsPlaying(false); + setCurrentTime(0); + setAudioBlob(null); + updateNodeData(id, { + audioFile: null, + filename: null, + duration: null, + format: null, + }); + }, [id, updateNodeData]); + + const handlePlayPause = useCallback(() => { + if (!audioRef.current) return; + + if (isPlaying) { + audioRef.current.pause(); + setIsPlaying(false); + } else { + audioRef.current.play(); + setIsPlaying(true); + } + }, [isPlaying]); + + const handleSeek = useCallback((e: React.MouseEvent) => { + if (!audioRef.current || !nodeData.duration || !waveformContainerRef.current) return; + + const rect = waveformContainerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const progress = x / rect.width; + const newTime = progress * nodeData.duration; + + audioRef.current.currentTime = newTime; + setCurrentTime(newTime); + }, [nodeData.duration]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + return ( + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + selected={selected} + commentNavigation={commentNavigation ?? undefined} + minWidth={250} + minHeight={150} + > + + + {nodeData.audioFile ? ( +
+ {/* Filename and duration */} +
+ + {nodeData.filename} + + {nodeData.duration && ( + + {formatTime(nodeData.duration)} + + )} +
+ + {/* Waveform visualization */} + {isLoading ? ( +
+ Loading waveform... +
+ ) : waveformData ? ( +
+ +
+ ) : ( +
+ Processing... +
+ )} + + {/* Play/pause controls */} +
+ + + {/* Progress bar / scrubber */} +
+ {nodeData.duration && ( +
+ )} +
+ + {/* Current time */} + + {formatTime(currentTime)} + +
+ + {/* Remove button */} + +
+ ) : ( +
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={handleDragOver} + className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center cursor-pointer hover:border-neutral-500 hover:bg-neutral-700/50 transition-colors" + > + + + + + Drop audio or click + +
+ )} + + + + ); +}