feat: complete Phase 3 - Advanced waveform visualization and zoom controls
Phase 3 Complete Features: ✅ Drag-to-scrub audio functionality ✅ Horizontal zoom (1x-20x) with smooth scaling ✅ Vertical amplitude zoom (0.5x-5x) ✅ Horizontal scrolling for zoomed waveform ✅ Grid lines every second for time reference ✅ Viewport culling for better performance ✅ Zoom controls UI component Components Added: - ZoomControls: Complete zoom control panel with: - Horizontal zoom slider and buttons - Amplitude zoom slider - Zoom in/out buttons - Fit to view button - Real-time zoom level display Waveform Enhancements: - Drag-to-scrub: Click and drag to scrub through audio - Zoom support: View waveform at different zoom levels - Scroll support: Navigate through zoomed waveform - Grid lines: Visual time markers every second - Viewport culling: Only render visible portions - Cursor feedback: Grabbing cursor when dragging AudioEditor Updates: - Integrated zoom and scroll state management - Auto-reset zoom on file clear - Scroll slider appears when zoomed - Smooth zoom transitions Technical Improvements: - Viewport culling: Only render visible waveform portions - Grid rendering: Time-aligned vertical grid lines - Smart scroll clamping: Prevent scrolling beyond bounds - Zoom-aware seeking: Accurate time calculation with zoom - Performance optimized rendering Features Working: ✅ Drag waveform to scrub audio ✅ Zoom in up to 20x for detailed editing ✅ Adjust amplitude for better visualization ✅ Scroll through zoomed waveform ✅ Grid lines show time markers ✅ Smooth cursor interactions Phase 3 Status: 95% complete - Completed: All major features - Optional: Measure/beat markers, OffscreenCanvas, Web Workers Build verified and working ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,9 @@ export interface WaveformProps {
|
||||
onSeek?: (time: number) => void;
|
||||
className?: string;
|
||||
height?: number;
|
||||
zoom?: number;
|
||||
scrollOffset?: number;
|
||||
amplitudeScale?: number;
|
||||
}
|
||||
|
||||
export function Waveform({
|
||||
@@ -20,10 +23,14 @@ export function Waveform({
|
||||
onSeek,
|
||||
className,
|
||||
height = 128,
|
||||
zoom = 1,
|
||||
scrollOffset = 0,
|
||||
amplitudeScale = 1,
|
||||
}: WaveformProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = React.useState(800);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
// Handle resize
|
||||
React.useEffect(() => {
|
||||
@@ -58,30 +65,55 @@ export function Waveform({
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-waveform-bg') || '#f5f5f5';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Generate peaks
|
||||
const { min, max } = generateMinMaxPeaks(audioBuffer, width, 0);
|
||||
// Calculate visible width based on zoom
|
||||
const visibleWidth = Math.floor(width * zoom);
|
||||
|
||||
// Generate peaks for visible portion
|
||||
const { min, max } = generateMinMaxPeaks(audioBuffer, visibleWidth, 0);
|
||||
|
||||
// Draw waveform
|
||||
const middle = height / 2;
|
||||
const scale = height / 2;
|
||||
const baseScale = (height / 2) * amplitudeScale;
|
||||
|
||||
// Waveform color
|
||||
const waveformColor = getComputedStyle(canvas).getPropertyValue('--color-waveform') || '#3b82f6';
|
||||
const progressColor = getComputedStyle(canvas).getPropertyValue('--color-waveform-progress') || '#10b981';
|
||||
|
||||
// Calculate progress position
|
||||
const progressX = duration > 0 ? (currentTime / duration) * width : 0;
|
||||
const progressX = duration > 0 ? ((currentTime / duration) * visibleWidth) - scrollOffset : 0;
|
||||
|
||||
// Draw waveform
|
||||
for (let i = 0; i < width; i++) {
|
||||
const minVal = min[i] * scale;
|
||||
const maxVal = max[i] * scale;
|
||||
// Draw grid lines (every 1 second)
|
||||
ctx.strokeStyle = 'rgba(128, 128, 128, 0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
const secondsPerPixel = duration / visibleWidth;
|
||||
const pixelsPerSecond = visibleWidth / duration;
|
||||
|
||||
for (let sec = 0; sec < duration; sec++) {
|
||||
const x = (sec * pixelsPerSecond) - scrollOffset;
|
||||
if (x >= 0 && x <= width) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw waveform with scroll offset
|
||||
const startIdx = Math.max(0, Math.floor(scrollOffset));
|
||||
const endIdx = Math.min(visibleWidth, Math.floor(scrollOffset + width));
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const x = i - scrollOffset;
|
||||
if (x < 0 || x >= width) continue;
|
||||
|
||||
const minVal = min[i] * baseScale;
|
||||
const maxVal = max[i] * baseScale;
|
||||
|
||||
// Use different color for played portion
|
||||
ctx.fillStyle = i < progressX ? progressColor : waveformColor;
|
||||
ctx.fillStyle = x < progressX ? progressColor : waveformColor;
|
||||
|
||||
ctx.fillRect(
|
||||
i,
|
||||
x,
|
||||
middle + minVal,
|
||||
1,
|
||||
Math.max(1, maxVal - minVal)
|
||||
@@ -97,7 +129,7 @@ export function Waveform({
|
||||
ctx.stroke();
|
||||
|
||||
// Draw playhead
|
||||
if (progressX > 0) {
|
||||
if (progressX >= 0 && progressX <= width) {
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
@@ -105,28 +137,69 @@ export function Waveform({
|
||||
ctx.lineTo(progressX, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [audioBuffer, width, height, currentTime, duration]);
|
||||
}, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!onSeek || !duration) return;
|
||||
if (!onSeek || !duration || isDragging) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const clickedTime = (x / width) * duration;
|
||||
|
||||
// Account for zoom and scroll
|
||||
const visibleWidth = width * zoom;
|
||||
const actualX = x + scrollOffset;
|
||||
const clickedTime = (actualX / visibleWidth) * duration;
|
||||
|
||||
onSeek(clickedTime);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!onSeek || !duration) return;
|
||||
setIsDragging(true);
|
||||
handleClick(e);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDragging || !onSeek || !duration) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
|
||||
// Account for zoom and scroll
|
||||
const visibleWidth = width * zoom;
|
||||
const actualX = x + scrollOffset;
|
||||
const clickedTime = (actualX / visibleWidth) * duration;
|
||||
|
||||
onSeek(Math.max(0, Math.min(duration, clickedTime)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('w-full', className)}>
|
||||
{audioBuffer ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onClick={handleClick}
|
||||
className="w-full cursor-pointer rounded-lg border border-border"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-border',
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
)}
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user