Files
hoerdle/components/WaveformEditor.tsx
T

675 lines
26 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
interface WaveformEditorProps {
audioUrl: string;
startTime: number;
duration: number; // Total puzzle duration (e.g., 60s)
unlockSteps: number[]; // e.g., [2, 4, 7, 11, 16, 30, 60]
onStartTimeChange: (newStartTime: number) => void;
}
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const timelineRef = useRef<HTMLCanvasElement>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
const [audioDuration, setAudioDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playingSegment, setPlayingSegment] = useState<number | null>(null);
const [isPlayingFullTitle, setIsPlayingFullTitle] = useState(false);
const [pausedPosition, setPausedPosition] = useState<number | null>(null); // Position when paused
const [pausedType, setPausedType] = useState<'selection' | 'title' | null>(null); // Type of playback that was paused
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
const [hoverPreviewTime, setHoverPreviewTime] = useState<number | null>(null); // Preview position on hover
const audioContextRef = useRef<AudioContext | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const playbackStartTimeRef = useRef<number>(0); // When playback started
const playbackOffsetRef = useRef<number>(0); // Offset in the audio file
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
const loadAudio = async () => {
try {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
audioContextRef.current = audioContext;
const buffer = await audioContext.decodeAudioData(arrayBuffer);
setAudioBuffer(buffer);
setAudioDuration(buffer.duration);
} catch (error) {
console.error('Error loading audio:', error);
}
};
loadAudio();
return () => {
if (sourceRef.current) {
sourceRef.current.stop();
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [audioUrl]);
// Draw timeline
useEffect(() => {
if (!audioDuration || !timelineRef.current) return;
const timeline = timelineRef.current;
const ctx = timeline.getContext('2d');
if (!ctx) return;
const width = timeline.width;
const height = timeline.height;
// Calculate visible range based on zoom and offset (same as waveform)
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
// Clear timeline
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Draw border
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, width, height);
// Calculate appropriate time interval based on visible duration
let timeInterval = 1; // Start with 1 second
if (visibleDuration > 60) timeInterval = 10;
else if (visibleDuration > 30) timeInterval = 5;
else if (visibleDuration > 10) timeInterval = 2;
else if (visibleDuration > 5) timeInterval = 1;
else if (visibleDuration > 1) timeInterval = 0.5;
else timeInterval = 0.1;
// Draw time markers
ctx.strokeStyle = '#9ca3af';
ctx.lineWidth = 1;
ctx.fillStyle = '#374151';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const startTimeMarker = Math.floor(visibleStart / timeInterval) * timeInterval;
for (let time = startTimeMarker; time <= visibleEnd; time += timeInterval) {
const timePx = ((time - visibleStart) / visibleDuration) * width;
if (timePx >= 0 && timePx <= width) {
// Draw tick mark
ctx.beginPath();
ctx.moveTo(timePx, 0);
ctx.lineTo(timePx, height);
ctx.stroke();
// Draw time label
const timeLabel = time.toFixed(timeInterval < 1 ? 1 : 0);
ctx.fillText(`${timeLabel}s`, timePx, 2);
}
}
// Draw current playback position if playing
if (playbackPosition !== null) {
const playbackPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
if (playbackPx >= 0 && playbackPx <= width) {
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playbackPx, 0);
ctx.lineTo(playbackPx, height);
ctx.stroke();
}
}
}, [audioDuration, zoom, viewOffset, playbackPosition]);
useEffect(() => {
if (!audioBuffer || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Calculate visible range based on zoom and offset
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
// Clear canvas
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, width, height);
// Draw waveform for visible range
const data = audioBuffer.getChannelData(0);
const samplesPerPixel = Math.ceil((data.length * visibleDuration / audioDuration) / width);
const startSample = Math.floor(data.length * visibleStart / audioDuration);
const amp = height / 2;
ctx.fillStyle = '#4f46e5';
for (let i = 0; i < width; i++) {
let min = 1.0;
let max = -1.0;
const sampleIndex = startSample + (i * samplesPerPixel);
for (let j = 0; j < samplesPerPixel && sampleIndex + j < data.length; j++) {
const datum = data[sampleIndex + j];
if (datum < min) min = datum;
if (datum > max) max = datum;
}
ctx.fillRect(i, (1 + min) * amp, 1, Math.max(1, (max - min) * amp));
}
// Draw selection overlay
const selectionStartPx = ((startTime - visibleStart) / visibleDuration) * width;
const selectionWidthPx = (duration / visibleDuration) * width;
if (selectionStartPx + selectionWidthPx > 0 && selectionStartPx < width) {
ctx.fillStyle = 'rgba(79, 70, 229, 0.3)';
ctx.fillRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height);
// Draw selection borders
ctx.strokeStyle = '#4f46e5';
ctx.lineWidth = 2;
ctx.strokeRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height);
}
// Draw segment markers (vertical lines)
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
let cumulativeTime = 0;
unlockSteps.forEach((step, index) => {
const segmentTime = startTime + cumulativeTime;
const segmentPx = ((segmentTime - visibleStart) / visibleDuration) * width;
if (segmentPx >= 0 && segmentPx <= width) {
ctx.beginPath();
ctx.moveTo(segmentPx, 0);
ctx.lineTo(segmentPx, height);
ctx.stroke();
// Draw segment number
ctx.setLineDash([]);
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 12px sans-serif';
ctx.fillText(`${index + 1}`, segmentPx + 3, 15);
ctx.setLineDash([5, 5]);
}
cumulativeTime = step;
});
// Draw end marker for the last segment (at startTime + duration)
const endTime = startTime + duration;
const endPx = ((endTime - visibleStart) / visibleDuration) * width;
if (endPx >= 0 && endPx <= width) {
ctx.beginPath();
ctx.moveTo(endPx, 0);
ctx.lineTo(endPx, height);
ctx.stroke();
// Draw "End" label
ctx.setLineDash([]);
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 12px sans-serif';
ctx.fillText('End', endPx + 3, 15);
ctx.setLineDash([5, 5]);
}
ctx.setLineDash([]);
// Draw hover preview (semi-transparent)
if (hoverPreviewTime !== null) {
const previewStartPx = ((hoverPreviewTime - visibleStart) / visibleDuration) * width;
const previewWidthPx = (duration / visibleDuration) * width;
if (previewStartPx + previewWidthPx > 0 && previewStartPx < width) {
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)'; // Light green
ctx.fillRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height);
// Draw preview borders
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.strokeRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height);
ctx.setLineDash([]);
}
}
// Draw playback cursor
if (playbackPosition !== null) {
const cursorPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
if (cursorPx >= 0 && cursorPx <= width) {
ctx.strokeStyle = '#10b981'; // Green
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cursorPx, 0);
ctx.lineTo(cursorPx, height);
ctx.stroke();
// Draw playhead triangle
ctx.fillStyle = '#10b981';
ctx.beginPath();
ctx.moveTo(cursorPx, 0);
ctx.lineTo(cursorPx - 5, 10);
ctx.lineTo(cursorPx + 5, 10);
ctx.closePath();
ctx.fill();
}
}
}, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps, playbackPosition, hoverPreviewTime]);
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || !audioDuration) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const clickedTime = visibleStart + (x / rect.width) * visibleDuration;
// Center the selection on the clicked point
let newStartTime = clickedTime - (duration / 2);
// Clamp to valid range
newStartTime = Math.max(0, Math.min(newStartTime, audioDuration - duration));
onStartTimeChange(Math.floor(newStartTime));
};
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || !audioDuration) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const hoveredTime = visibleStart + (x / rect.width) * visibleDuration;
// Calculate where the selection would be centered on this point
let previewStartTime = hoveredTime - (duration / 2);
previewStartTime = Math.max(0, Math.min(previewStartTime, audioDuration - duration));
setHoverPreviewTime(previewStartTime);
};
const handleCanvasMouseLeave = () => {
setHoverPreviewTime(null);
};
const stopPlayback = (savePosition = false) => {
if (savePosition && playbackPosition !== null) {
// Save current position for resume
setPausedPosition(playbackPosition);
// Keep playbackPosition visible (don't set to null) so cursor stays visible
} else {
// Clear paused position if stopping completely
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(null);
}
sourceRef.current?.stop();
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
// Animation loop for playback cursor
useEffect(() => {
if (!isPlaying || !audioContextRef.current) {
return;
}
const animate = () => {
if (!audioContextRef.current || !isPlaying) return;
const elapsed = audioContextRef.current.currentTime - playbackStartTimeRef.current;
const currentPos = playbackOffsetRef.current + elapsed;
setPlaybackPosition(currentPos);
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isPlaying]);
const handlePlaySegment = (segmentIndex: number) => {
if (!audioBuffer || !audioContextRef.current) return;
stopPlayback();
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
// Calculate segment start and duration
const segmentStart = startTime + (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0);
const segmentDuration = unlockSteps[segmentIndex] - (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = segmentStart;
source.start(0, segmentStart, segmentDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(segmentIndex);
setPlaybackPosition(segmentStart);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setPlaybackPosition(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
};
const handlePlayFull = () => {
if (!audioBuffer || !audioContextRef.current) return;
// If full selection playback is already playing, pause it
if (isPlaying && playingSegment === null && !isPlayingFullTitle) {
stopPlayback(true); // Save position
setPausedType('selection');
return;
}
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'selection' && pausedPosition !== null
? pausedPosition
: startTime;
const remainingDuration = resumePosition >= startTime + duration
? 0
: (startTime + duration) - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full selection playback
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = resumePosition;
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
};
const handlePlayFullTitle = () => {
if (!audioBuffer || !audioContextRef.current) return;
// If full title playback is already playing, pause it
if (isPlaying && isPlayingFullTitle) {
stopPlayback(true); // Save position
setPausedType('title');
return;
}
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'title' && pausedPosition !== null
? pausedPosition
: 0;
const remainingDuration = resumePosition >= audioDuration
? 0
: audioDuration - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full title playback (from resumePosition to audioDuration)
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = resumePosition;
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(true);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
};
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10));
const handleZoomOut = () => setZoom(prev => Math.max(prev / 1.5, 1));
const handlePanLeft = () => {
const visibleDuration = audioDuration / zoom;
setViewOffset(prev => Math.max(0, prev - visibleDuration * 0.2));
};
const handlePanRight = () => {
const visibleDuration = audioDuration / zoom;
setViewOffset(prev => Math.min(audioDuration - visibleDuration, prev + visibleDuration * 0.2));
};
return (
<div style={{ marginTop: '1rem' }}>
{/* Zoom and Pan Controls */}
<div style={{ marginBottom: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={handleZoomOut}
disabled={zoom <= 1}
style={{
padding: '0.25rem 0.5rem',
background: zoom <= 1 ? '#e5e7eb' : '#4f46e5',
color: zoom <= 1 ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: zoom <= 1 ? 'not-allowed' : 'pointer',
fontSize: '0.875rem'
}}
>
🔍−
</button>
<button
onClick={handleZoomIn}
disabled={zoom >= 10}
style={{
padding: '0.25rem 0.5rem',
background: zoom >= 10 ? '#e5e7eb' : '#4f46e5',
color: zoom >= 10 ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: zoom >= 10 ? 'not-allowed' : 'pointer',
fontSize: '0.875rem'
}}
>
🔍+
</button>
<span style={{ fontSize: '0.75rem', color: '#666' }}>Zoom: {zoom.toFixed(1)}x</span>
{zoom > 1 && (
<>
<button onClick={handlePanLeft} style={{ padding: '0.25rem 0.5rem', background: '#4f46e5', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.875rem' }}>
</button>
<button onClick={handlePanRight} style={{ padding: '0.25rem 0.5rem', background: '#4f46e5', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.875rem' }}>
</button>
</>
)}
</div>
<div style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
width={800}
height={150}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
style={{
width: '100%',
height: 'auto',
cursor: 'pointer',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem 0.5rem 0 0',
display: 'block'
}}
/>
<canvas
ref={timelineRef}
width={800}
height={30}
style={{
width: '100%',
height: '30px',
border: '1px solid #e5e7eb',
borderTop: 'none',
borderRadius: '0 0 0.5rem 0.5rem',
display: 'block',
background: '#ffffff'
}}
/>
</div>
{/* Playback Controls */}
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button
onClick={handlePlayFull}
style={{
padding: '0.5rem 1rem',
background: '#4f46e5',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
{isPlaying && playingSegment === null && !isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'selection' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Selection')}
</button>
<button
onClick={handlePlayFullTitle}
style={{
padding: '0.5rem 1rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
{isPlaying && isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'title' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Title')}
</button>
<div style={{ fontSize: '0.875rem', color: '#666' }}>
Start: {startTime}s | Duration: {duration}s | Total: {Math.floor(audioDuration)}s
</div>
</div>
{/* Segment Playback Buttons */}
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.875rem', color: '#666', marginRight: '0.5rem' }}>Play Segments:</span>
{unlockSteps.map((step, index) => {
const segmentStart = index > 0 ? unlockSteps[index - 1] : 0;
const segmentDuration = step - segmentStart;
return (
<button
key={index}
onClick={() => handlePlaySegment(index)}
style={{
padding: '0.25rem 0.75rem',
background: playingSegment === index ? '#ef4444' : '#f3f4f6',
color: playingSegment === index ? 'white' : '#374151',
border: '1px solid #e5e7eb',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: playingSegment === index ? 'bold' : 'normal'
}}
>
{index + 1} ({segmentDuration}s)
</button>
);
})}
</div>
</div>
);
}