Browse Source

refactor: extract useAudioPlayback hook from audio node components

Deduplicate 6 patterns (drawWaveform, ResizeObserver, redraw effect,
animation loop, handlePlayPause, handleSeek) that were 100% identical
between AudioInputNode and GenerateAudioNode into a shared hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
dff6027b3e
  1. 188
      src/components/nodes/AudioInputNode.tsx
  2. 192
      src/components/nodes/GenerateAudioNode.tsx
  3. 210
      src/hooks/useAudioPlayback.ts

188
src/components/nodes/AudioInputNode.tsx

@ -7,6 +7,7 @@ import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { AudioInputNodeData } from "@/types"; import { AudioInputNodeData } from "@/types";
import { useAudioVisualization } from "@/hooks/useAudioVisualization"; import { useAudioVisualization } from "@/hooks/useAudioVisualization";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
type AudioInputNodeType = Node<AudioInputNodeData, "audioInput">; type AudioInputNodeType = Node<AudioInputNodeData, "audioInput">;
@ -15,18 +16,9 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
const commentNavigation = useCommentNavigation(id); const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const waveformContainerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | undefined>(undefined);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null); const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
// Use the audio visualization hook
const { waveformData, isLoading } = useAudioVisualization(audioBlob);
// Convert base64 data URL to Blob for the hook // Convert base64 data URL to Blob for the hook
useEffect(() => { useEffect(() => {
if (nodeData.audioFile) { if (nodeData.audioFile) {
@ -39,135 +31,21 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
} }
}, [nodeData.audioFile]); }, [nodeData.audioFile]);
// Setup audio element const { waveformData, isLoading } = useAudioVisualization(audioBlob);
useEffect(() => { const {
if (nodeData.audioFile && !audioRef.current) { audioRef,
const audio = new Audio(nodeData.audioFile); canvasRef,
audioRef.current = audio; waveformContainerRef,
isPlaying,
const handleEnded = () => { currentTime,
setIsPlaying(false); handlePlayPause,
setCurrentTime(0); handleSeek,
}; formatTime,
const handleTimeUpdate = () => { } = useAudioPlayback({
setCurrentTime(audio.currentTime); audioSrc: nodeData.audioFile ?? null,
}; waveformData,
audio.addEventListener("ended", handleEnded); isLoadingWaveform: isLoading,
audio.addEventListener("timeupdate", handleTimeUpdate); });
return () => {
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.pause();
audioRef.current = null;
};
}
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, [nodeData.audioFile]);
// Helper to draw waveform bars on canvas with optional progress indicator
const drawWaveform = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[], progress?: number) => {
ctx.clearRect(0, 0, width, height);
const barCount = Math.min(peaks.length, width);
const barWidth = width / barCount;
const barGap = 1;
const progressX = progress !== undefined ? progress * width : -1;
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.fillStyle = x < progressX ? "rgb(139, 92, 246)" : "rgba(139, 92, 246, 0.4)"; // violet-500 played, violet-500/40 unplayed
ctx.fillRect(x, y, barWidth - barGap, barHeight);
}
// Draw playback position line
if (progressX > 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.9)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(progressX, 0);
ctx.lineTo(progressX, height);
ctx.stroke();
}
},
[]
);
// Effect A: ResizeObserver — only recreated when waveformData changes
useEffect(() => {
if (!waveformData || !canvasRef.current || !waveformContainerRef.current) return;
const canvas = canvasRef.current;
const container = waveformContainerRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
const height = entry.contentRect.height;
canvas.width = width;
canvas.height = height;
drawWaveform(ctx, width, height, waveformData.peaks);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [waveformData, drawWaveform]);
// Effect B: Redraw waveform + playback progress (lightweight, no ResizeObserver)
useEffect(() => {
if (!waveformData || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
if (width === 0 || height === 0) return;
const duration = audioRef.current?.duration;
const progress = duration && isFinite(duration) && currentTime > 0 ? currentTime / duration : undefined;
drawWaveform(ctx, width, height, waveformData.peaks, progress);
}, [isPlaying, currentTime, waveformData, drawWaveform]);
// 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( const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@ -232,8 +110,6 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
audioRef.current.pause(); audioRef.current.pause();
audioRef.current = null; audioRef.current = null;
} }
setIsPlaying(false);
setCurrentTime(0);
setAudioBlob(null); setAudioBlob(null);
updateNodeData(id, { updateNodeData(id, {
audioFile: null, audioFile: null,
@ -241,37 +117,7 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
duration: null, duration: null,
format: null, format: null,
}); });
}, [id, updateNodeData]); }, [id, updateNodeData, audioRef]);
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<HTMLDivElement>) => {
if (!audioRef.current || !audioRef.current.duration || !isFinite(audioRef.current.duration) || !waveformContainerRef.current) return;
const rect = waveformContainerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const progress = x / rect.width;
const newTime = progress * audioRef.current.duration;
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
}, []);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
return ( return (
<BaseNode <BaseNode

192
src/components/nodes/GenerateAudioNode.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useCallback, useState, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useState, useEffect, useMemo } from "react";
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { ProviderBadge } from "./ProviderBadge"; import { ProviderBadge } from "./ProviderBadge";
@ -11,6 +11,7 @@ import { GenerateAudioNodeData, ProviderType, SelectedModel, ModelInputDef } fro
import { ProviderModel } from "@/lib/providers/types"; import { ProviderModel } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useAudioVisualization } from "@/hooks/useAudioVisualization"; import { useAudioVisualization } from "@/hooks/useAudioVisualization";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">; type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
@ -21,12 +22,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
const generationsPath = useWorkflowStore((state) => state.generationsPath); const generationsPath = useWorkflowStore((state) => state.generationsPath);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isLoadingCarouselAudio, setIsLoadingCarouselAudio] = useState(false); const [isLoadingCarouselAudio, setIsLoadingCarouselAudio] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const waveformContainerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | undefined>(undefined);
// Get the current selected provider (default to fal) // Get the current selected provider (default to fal)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
@ -46,176 +41,29 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
} }
}, [nodeData.outputAudio]); }, [nodeData.outputAudio]);
// Setup audio element const {
useEffect(() => { audioRef,
if (!nodeData.outputAudio) { canvasRef,
if (audioRef.current) { waveformContainerRef,
audioRef.current.pause(); isPlaying,
audioRef.current = null; currentTime,
} handlePlayPause,
setIsPlaying(false); handleSeek,
setCurrentTime(0); formatTime,
return; } = useAudioPlayback({
} audioSrc: nodeData.outputAudio ?? null,
waveformData,
// Clean up previous audio element isLoadingWaveform,
if (audioRef.current) { });
audioRef.current.pause();
}
const audio = new Audio(nodeData.outputAudio);
audioRef.current = audio;
const handleEnded = () => {
setIsPlaying(false);
setCurrentTime(0);
};
const handleTimeUpdate = () => {
setCurrentTime(audio.currentTime);
};
audio.addEventListener("ended", handleEnded);
audio.addEventListener("timeupdate", handleTimeUpdate);
return () => {
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.pause();
audioRef.current = null;
};
}, [nodeData.outputAudio]);
// Draw waveform with optional progress indicator
const drawWaveform = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[], progress?: number) => {
ctx.clearRect(0, 0, width, height);
const barCount = Math.min(peaks.length, width);
const barWidth = width / barCount;
const barGap = 1;
const progressX = progress !== undefined ? progress * width : -1;
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.fillStyle = x < progressX ? "rgb(139, 92, 246)" : "rgba(139, 92, 246, 0.4)"; // violet-500 played, violet-500/40 unplayed
ctx.fillRect(x, y, barWidth - barGap, barHeight);
}
// Draw playback position line
if (progressX > 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.9)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(progressX, 0);
ctx.lineTo(progressX, height);
ctx.stroke();
}
},
[]
);
// Effect: ResizeObserver for waveform 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;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
const height = entry.contentRect.height;
canvas.width = width;
canvas.height = height;
drawWaveform(ctx, width, height, waveformData.peaks);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [waveformData, drawWaveform]);
// Effect: Redraw waveform with playback progress
useEffect(() => {
if (!waveformData || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
if (width === 0 || height === 0) return;
const duration = audioRef.current?.duration;
const progress = duration && isFinite(duration) && currentTime > 0 ? currentTime / duration : undefined;
drawWaveform(ctx, width, height, waveformData.peaks, progress);
}, [isPlaying, currentTime, waveformData, drawWaveform]);
// 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 handleClearAudio = useCallback(() => { const handleClearAudio = useCallback(() => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.pause(); audioRef.current.pause();
audioRef.current = null; audioRef.current = null;
} }
setIsPlaying(false);
setCurrentTime(0);
setAudioBlob(null); setAudioBlob(null);
updateNodeData(id, { outputAudio: null, status: "idle", error: null, duration: null, format: null }); updateNodeData(id, { outputAudio: null, status: "idle", error: null, duration: null, format: null });
}, [id, updateNodeData]); }, [id, updateNodeData, audioRef]);
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!audioRef.current || !audioRef.current.duration || !isFinite(audioRef.current.duration) || !waveformContainerRef.current) return;
const rect = waveformContainerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const progress = x / rect.width;
const newTime = progress * audioRef.current.duration;
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
}, []);
const handlePlayPause = useCallback(() => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current.play();
setIsPlaying(true);
}
}, [isPlaying]);
const handleParametersChange = useCallback( const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => { (parameters: Record<string, unknown>) => {
@ -343,12 +191,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
return "Generate Audio"; return "Generate Audio";
}, [nodeData.selectedModel?.displayName, nodeData.selectedModel?.modelId]); }, [nodeData.selectedModel?.displayName, nodeData.selectedModel?.modelId]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
// Provider badge as title prefix // Provider badge as title prefix
const titlePrefix = useMemo(() => ( const titlePrefix = useMemo(() => (
<ProviderBadge provider={currentProvider} /> <ProviderBadge provider={currentProvider} />

210
src/hooks/useAudioPlayback.ts

@ -0,0 +1,210 @@
import { useCallback, useRef, useState, useEffect } from "react";
import { WaveformData } from "./useAudioVisualization";
interface UseAudioPlaybackOptions {
audioSrc: string | null;
waveformData: WaveformData | null;
isLoadingWaveform: boolean;
}
interface UseAudioPlaybackResult {
audioRef: React.RefObject<HTMLAudioElement | null>;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
waveformContainerRef: React.RefObject<HTMLDivElement | null>;
isPlaying: boolean;
currentTime: number;
handlePlayPause: () => void;
handleSeek: (e: React.MouseEvent<HTMLDivElement>) => void;
formatTime: (seconds: number) => string;
waveformData: WaveformData | null;
isLoadingWaveform: boolean;
}
export function useAudioPlayback({ audioSrc, waveformData, isLoadingWaveform }: UseAudioPlaybackOptions): UseAudioPlaybackResult {
const audioRef = useRef<HTMLAudioElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const waveformContainerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | undefined>(undefined);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// Setup audio element
useEffect(() => {
if (!audioSrc) {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setIsPlaying(false);
setCurrentTime(0);
return;
}
// Clean up previous audio element
if (audioRef.current) {
audioRef.current.pause();
}
const audio = new Audio(audioSrc);
audioRef.current = audio;
const handleEnded = () => {
setIsPlaying(false);
setCurrentTime(0);
};
const handleTimeUpdate = () => {
setCurrentTime(audio.currentTime);
};
audio.addEventListener("ended", handleEnded);
audio.addEventListener("timeupdate", handleTimeUpdate);
return () => {
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.pause();
audioRef.current = null;
};
}, [audioSrc]);
// Helper to draw waveform bars on canvas with optional progress indicator
const drawWaveform = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[], progress?: number) => {
ctx.clearRect(0, 0, width, height);
const barCount = Math.min(peaks.length, width);
const barWidth = width / barCount;
const barGap = 1;
const progressX = progress !== undefined ? progress * width : -1;
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.fillStyle = x < progressX ? "rgb(139, 92, 246)" : "rgba(139, 92, 246, 0.4)";
ctx.fillRect(x, y, barWidth - barGap, barHeight);
}
// Draw playback position line
if (progressX > 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.9)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(progressX, 0);
ctx.lineTo(progressX, height);
ctx.stroke();
}
},
[]
);
// ResizeObserver — only recreated when waveformData changes
useEffect(() => {
if (!waveformData || !canvasRef.current || !waveformContainerRef.current) return;
const canvas = canvasRef.current;
const container = waveformContainerRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
const height = entry.contentRect.height;
canvas.width = width;
canvas.height = height;
drawWaveform(ctx, width, height, waveformData.peaks);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [waveformData, drawWaveform]);
// Redraw waveform + playback progress (lightweight, no ResizeObserver)
useEffect(() => {
if (!waveformData || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
if (width === 0 || height === 0) return;
const duration = audioRef.current?.duration;
const progress = duration && isFinite(duration) && currentTime > 0 ? currentTime / duration : undefined;
drawWaveform(ctx, width, height, waveformData.peaks, progress);
}, [isPlaying, currentTime, waveformData, drawWaveform]);
// 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 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<HTMLDivElement>) => {
if (!audioRef.current || !audioRef.current.duration || !isFinite(audioRef.current.duration) || !waveformContainerRef.current) return;
const rect = waveformContainerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const progress = x / rect.width;
const newTime = progress * audioRef.current.duration;
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
}, []);
const formatTime = useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}, []);
return {
audioRef,
canvasRef,
waveformContainerRef,
isPlaying,
currentTime,
handlePlayPause,
handleSeek,
formatTime,
waveformData,
isLoadingWaveform,
};
}
Loading…
Cancel
Save