diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 932f3c1..c35adba 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -6,19 +6,32 @@ import { AudioInfo } from './AudioInfo'; import { Waveform } from './Waveform'; import { PlaybackControls } from './PlaybackControls'; import { ZoomControls } from './ZoomControls'; +import { EditControls } from './EditControls'; import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer'; import { useToast } from '@/components/ui/Toast'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; import { Slider } from '@/components/ui/Slider'; import { Loader2 } from 'lucide-react'; +import type { Selection, ClipboardData } from '@/types/selection'; +import { + extractBufferSegment, + deleteBufferSegment, + insertBufferSegment, + trimBuffer, +} from '@/lib/audio/buffer-utils'; export function AudioEditor() { // Zoom and scroll state const [zoom, setZoom] = React.useState(1); const [scrollOffset, setScrollOffset] = React.useState(0); const [amplitudeScale, setAmplitudeScale] = React.useState(1); + + // Selection state + const [selection, setSelection] = React.useState(null); + const [clipboard, setClipboard] = React.useState(null); const { loadFile, + loadBuffer, clearFile, play, pause, @@ -64,6 +77,8 @@ export function AudioEditor() { setZoom(1); setScrollOffset(0); setAmplitudeScale(1); + setSelection(null); + setClipboard(null); addToast({ title: 'Audio cleared', description: 'Audio file has been removed', @@ -72,6 +87,150 @@ export function AudioEditor() { }); }; + // Edit operations + const handleCut = () => { + if (!selection || !audioBuffer) return; + + try { + // Copy to clipboard + const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end); + setClipboard({ + buffer: clipData, + start: selection.start, + end: selection.end, + duration: selection.end - selection.start, + }); + + // Delete from buffer + const newBuffer = deleteBufferSegment(audioBuffer, selection.start, selection.end); + loadBuffer(newBuffer); + + setSelection(null); + addToast({ + title: 'Cut', + description: 'Selection cut to clipboard', + variant: 'success', + duration: 2000, + }); + } catch (error) { + addToast({ + title: 'Error', + description: 'Failed to cut selection', + variant: 'error', + duration: 3000, + }); + } + }; + + const handleCopy = () => { + if (!selection || !audioBuffer) return; + + try { + const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end); + setClipboard({ + buffer: clipData, + start: selection.start, + end: selection.end, + duration: selection.end - selection.start, + }); + + addToast({ + title: 'Copied', + description: 'Selection copied to clipboard', + variant: 'success', + duration: 2000, + }); + } catch (error) { + addToast({ + title: 'Error', + description: 'Failed to copy selection', + variant: 'error', + duration: 3000, + }); + } + }; + + const handlePaste = () => { + if (!clipboard || !audioBuffer) return; + + try { + const insertTime = currentTime; + const newBuffer = insertBufferSegment(audioBuffer, clipboard.buffer, insertTime); + loadBuffer(newBuffer); + + addToast({ + title: 'Pasted', + description: 'Clipboard pasted at current position', + variant: 'success', + duration: 2000, + }); + } catch (error) { + addToast({ + title: 'Error', + description: 'Failed to paste clipboard', + variant: 'error', + duration: 3000, + }); + } + }; + + const handleDelete = () => { + if (!selection || !audioBuffer) return; + + try { + const newBuffer = deleteBufferSegment(audioBuffer, selection.start, selection.end); + loadBuffer(newBuffer); + + setSelection(null); + addToast({ + title: 'Deleted', + description: 'Selection deleted', + variant: 'success', + duration: 2000, + }); + } catch (error) { + addToast({ + title: 'Error', + description: 'Failed to delete selection', + variant: 'error', + duration: 3000, + }); + } + }; + + const handleTrim = () => { + if (!selection || !audioBuffer) return; + + try { + const newBuffer = trimBuffer(audioBuffer, selection.start, selection.end); + loadBuffer(newBuffer); + + setSelection(null); + addToast({ + title: 'Trimmed', + description: 'Audio trimmed to selection', + variant: 'success', + duration: 2000, + }); + } catch (error) { + addToast({ + title: 'Error', + description: 'Failed to trim audio', + variant: 'error', + duration: 3000, + }); + } + }; + + const handleSelectAll = () => { + if (!audioBuffer) return; + setSelection({ start: 0, end: duration }); + }; + + const handleClearSelection = () => { + setSelection(null); + }; + // Zoom controls const handleZoomIn = () => { setZoom((prev) => Math.min(20, prev + 1)); @@ -96,6 +255,55 @@ export function AudioEditor() { } }, [zoom, audioBuffer]); + // Keyboard shortcuts + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Prevent shortcuts if typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + // Ctrl+A: Select all + if (e.ctrlKey && e.key === 'a') { + e.preventDefault(); + handleSelectAll(); + } + + // Ctrl+X: Cut + if (e.ctrlKey && e.key === 'x') { + e.preventDefault(); + handleCut(); + } + + // Ctrl+C: Copy + if (e.ctrlKey && e.key === 'c') { + e.preventDefault(); + handleCopy(); + } + + // Ctrl+V: Paste + if (e.ctrlKey && e.key === 'v') { + e.preventDefault(); + handlePaste(); + } + + // Delete: Delete selection + if (e.key === 'Delete') { + e.preventDefault(); + handleDelete(); + } + + // Escape: Clear selection + if (e.key === 'Escape') { + e.preventDefault(); + handleClearSelection(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selection, clipboard, audioBuffer, currentTime]); + // Show error toast React.useEffect(() => { if (error) { @@ -151,6 +359,8 @@ export function AudioEditor() { zoom={zoom} scrollOffset={scrollOffset} amplitudeScale={amplitudeScale} + selection={selection} + onSelectionChange={setSelection} /> {/* Horizontal scroll for zoomed waveform */} @@ -171,6 +381,25 @@ export function AudioEditor() { + {/* Edit Controls */} + + + Edit + + + + + + {/* Zoom Controls */} diff --git a/components/editor/EditControls.tsx b/components/editor/EditControls.tsx new file mode 100644 index 0000000..22036bc --- /dev/null +++ b/components/editor/EditControls.tsx @@ -0,0 +1,139 @@ +'use client'; + +import * as React from 'react'; +import { Scissors, Copy, Clipboard, Trash2, CropIcon, Info } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/utils/cn'; +import type { Selection } from '@/types/selection'; +import { formatDuration } from '@/lib/audio/decoder'; + +export interface EditControlsProps { + selection: Selection | null; + hasClipboard: boolean; + onCut: () => void; + onCopy: () => void; + onPaste: () => void; + onDelete: () => void; + onTrim: () => void; + onClearSelection: () => void; + className?: string; +} + +export function EditControls({ + selection, + hasClipboard, + onCut, + onCopy, + onPaste, + onDelete, + onTrim, + onClearSelection, + className, +}: EditControlsProps) { + const hasSelection = selection !== null; + const selectionDuration = selection ? selection.end - selection.start : 0; + + return ( +
+ {/* Selection Info */} + {hasSelection && ( +
+
+ +
+

Selection Active

+

+ Duration: {formatDuration(selectionDuration)} | + Start: {formatDuration(selection.start)} | + End: {formatDuration(selection.end)} +

+

+ Tip: Hold Shift and drag on the waveform to select a region +

+
+
+
+ )} + + {/* Edit Buttons */} +
+ + + + + + + + + + + +
+ + {/* Keyboard Shortcuts Info */} +
+

Keyboard Shortcuts:

+

• Shift+Drag: Select region

+

• Ctrl+A: Select all

+

• Ctrl+X: Cut

+

• Ctrl+C: Copy

+

• Ctrl+V: Paste

+

• Delete: Delete selection

+

• Escape: Clear selection

+
+
+ ); +} diff --git a/components/editor/Waveform.tsx b/components/editor/Waveform.tsx index 95d0f3b..20b5c38 100644 --- a/components/editor/Waveform.tsx +++ b/components/editor/Waveform.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { cn } from '@/lib/utils/cn'; import { generateMinMaxPeaks } from '@/lib/waveform/peaks'; +import type { Selection } from '@/types/selection'; export interface WaveformProps { audioBuffer: AudioBuffer | null; @@ -14,6 +15,8 @@ export interface WaveformProps { zoom?: number; scrollOffset?: number; amplitudeScale?: number; + selection?: Selection | null; + onSelectionChange?: (selection: Selection | null) => void; } export function Waveform({ @@ -26,11 +29,15 @@ export function Waveform({ zoom = 1, scrollOffset = 0, amplitudeScale = 1, + selection = null, + onSelectionChange, }: WaveformProps) { const canvasRef = React.useRef(null); const containerRef = React.useRef(null); const [width, setWidth] = React.useState(800); const [isDragging, setIsDragging] = React.useState(false); + const [isSelecting, setIsSelecting] = React.useState(false); + const [selectionStart, setSelectionStart] = React.useState(null); // Handle resize React.useEffect(() => { @@ -128,6 +135,38 @@ export function Waveform({ ctx.lineTo(width, middle); ctx.stroke(); + // Draw selection + if (selection) { + const selectionStartX = ((selection.start / duration) * visibleWidth) - scrollOffset; + const selectionEndX = ((selection.end / duration) * visibleWidth) - scrollOffset; + + if (selectionEndX >= 0 && selectionStartX <= width) { + const clampedStart = Math.max(0, selectionStartX); + const clampedEnd = Math.min(width, selectionEndX); + + ctx.fillStyle = 'rgba(59, 130, 246, 0.3)'; + ctx.fillRect(clampedStart, 0, clampedEnd - clampedStart, height); + + // Selection borders + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 2; + + if (selectionStartX >= 0 && selectionStartX <= width) { + ctx.beginPath(); + ctx.moveTo(selectionStartX, 0); + ctx.lineTo(selectionStartX, height); + ctx.stroke(); + } + + if (selectionEndX >= 0 && selectionEndX <= width) { + ctx.beginPath(); + ctx.moveTo(selectionEndX, 0); + ctx.lineTo(selectionEndX, height); + ctx.stroke(); + } + } + } + // Draw playhead if (progressX >= 0 && progressX <= width) { ctx.strokeStyle = '#ef4444'; @@ -137,7 +176,7 @@ export function Waveform({ ctx.lineTo(progressX, height); ctx.stroke(); } - }, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale]); + }, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale, selection]); const handleClick = (e: React.MouseEvent) => { if (!onSeek || !duration || isDragging) return; @@ -157,34 +196,64 @@ export function Waveform({ }; const handleMouseDown = (e: React.MouseEvent) => { - if (!onSeek || !duration) return; - setIsDragging(true); - handleClick(e); - }; - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isDragging || !onSeek || !duration) return; + if (!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))); + // Shift key for selection + if (e.shiftKey && onSelectionChange) { + setIsSelecting(true); + setSelectionStart(clickedTime); + onSelectionChange({ start: clickedTime, end: clickedTime }); + } else if (onSeek) { + // Regular dragging for scrubbing + setIsDragging(true); + onSeek(clickedTime); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!duration) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const visibleWidth = width * zoom; + const actualX = x + scrollOffset; + const currentTime = (actualX / visibleWidth) * duration; + const clampedTime = Math.max(0, Math.min(duration, currentTime)); + + // Handle selection dragging + if (isSelecting && onSelectionChange && selectionStart !== null) { + const start = Math.min(selectionStart, clampedTime); + const end = Math.max(selectionStart, clampedTime); + onSelectionChange({ start, end }); + } + // Handle scrubbing + else if (isDragging && onSeek) { + onSeek(clampedTime); + } }; const handleMouseUp = () => { setIsDragging(false); + setIsSelecting(false); + setSelectionStart(null); }; const handleMouseLeave = () => { setIsDragging(false); + setIsSelecting(false); + setSelectionStart(null); }; return ( @@ -198,7 +267,7 @@ export function Waveform({ onMouseLeave={handleMouseLeave} className={cn( 'w-full rounded-lg border border-border', - isDragging ? 'cursor-grabbing' : 'cursor-pointer' + isDragging ? 'cursor-grabbing' : isSelecting ? 'cursor-text' : 'cursor-pointer' )} style={{ height: `${height}px` }} /> diff --git a/lib/audio/buffer-utils.ts b/lib/audio/buffer-utils.ts new file mode 100644 index 0000000..62fdc2d --- /dev/null +++ b/lib/audio/buffer-utils.ts @@ -0,0 +1,167 @@ +/** + * AudioBuffer manipulation utilities + */ + +import { getAudioContext } from './context'; + +/** + * Extract a portion of an AudioBuffer + */ +export function extractBufferSegment( + buffer: AudioBuffer, + startTime: number, + endTime: number +): AudioBuffer { + const audioContext = getAudioContext(); + const startSample = Math.floor(startTime * buffer.sampleRate); + const endSample = Math.floor(endTime * buffer.sampleRate); + const length = endSample - startSample; + + const segment = audioContext.createBuffer( + buffer.numberOfChannels, + length, + buffer.sampleRate + ); + + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + const sourceData = buffer.getChannelData(channel); + const targetData = segment.getChannelData(channel); + + for (let i = 0; i < length; i++) { + targetData[i] = sourceData[startSample + i]; + } + } + + return segment; +} + +/** + * Delete a portion of an AudioBuffer + */ +export function deleteBufferSegment( + buffer: AudioBuffer, + startTime: number, + endTime: number +): AudioBuffer { + const audioContext = getAudioContext(); + const startSample = Math.floor(startTime * buffer.sampleRate); + const endSample = Math.floor(endTime * buffer.sampleRate); + + const beforeLength = startSample; + const afterLength = buffer.length - endSample; + const newLength = beforeLength + afterLength; + + const newBuffer = audioContext.createBuffer( + buffer.numberOfChannels, + newLength, + buffer.sampleRate + ); + + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + const sourceData = buffer.getChannelData(channel); + const targetData = newBuffer.getChannelData(channel); + + // Copy before segment + for (let i = 0; i < beforeLength; i++) { + targetData[i] = sourceData[i]; + } + + // Copy after segment + for (let i = 0; i < afterLength; i++) { + targetData[beforeLength + i] = sourceData[endSample + i]; + } + } + + return newBuffer; +} + +/** + * Insert an AudioBuffer at a specific position + */ +export function insertBufferSegment( + buffer: AudioBuffer, + insertBuffer: AudioBuffer, + insertTime: number +): AudioBuffer { + const audioContext = getAudioContext(); + const insertSample = Math.floor(insertTime * buffer.sampleRate); + const newLength = buffer.length + insertBuffer.length; + + const newBuffer = audioContext.createBuffer( + buffer.numberOfChannels, + newLength, + buffer.sampleRate + ); + + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + const sourceData = buffer.getChannelData(channel); + const insertData = insertBuffer.getChannelData( + Math.min(channel, insertBuffer.numberOfChannels - 1) + ); + const targetData = newBuffer.getChannelData(channel); + + // Copy before insert point + for (let i = 0; i < insertSample; i++) { + targetData[i] = sourceData[i]; + } + + // Copy insert buffer + for (let i = 0; i < insertBuffer.length; i++) { + targetData[insertSample + i] = insertData[i]; + } + + // Copy after insert point + for (let i = insertSample; i < buffer.length; i++) { + targetData[insertBuffer.length + i] = sourceData[i]; + } + } + + return newBuffer; +} + +/** + * Trim buffer to selection + */ +export function trimBuffer( + buffer: AudioBuffer, + startTime: number, + endTime: number +): AudioBuffer { + return extractBufferSegment(buffer, startTime, endTime); +} + +/** + * Concatenate two audio buffers + */ +export function concatenateBuffers( + buffer1: AudioBuffer, + buffer2: AudioBuffer +): AudioBuffer { + const audioContext = getAudioContext(); + const newLength = buffer1.length + buffer2.length; + const channels = Math.max(buffer1.numberOfChannels, buffer2.numberOfChannels); + + const newBuffer = audioContext.createBuffer( + channels, + newLength, + buffer1.sampleRate + ); + + for (let channel = 0; channel < channels; channel++) { + const targetData = newBuffer.getChannelData(channel); + + // Copy first buffer + if (channel < buffer1.numberOfChannels) { + const data1 = buffer1.getChannelData(channel); + targetData.set(data1, 0); + } + + // Copy second buffer + if (channel < buffer2.numberOfChannels) { + const data2 = buffer2.getChannelData(channel); + targetData.set(data2, buffer1.length); + } + } + + return newBuffer; +} diff --git a/lib/hooks/useAudioPlayer.ts b/lib/hooks/useAudioPlayer.ts index daca1bf..14ee20c 100644 --- a/lib/hooks/useAudioPlayer.ts +++ b/lib/hooks/useAudioPlayer.ts @@ -7,6 +7,7 @@ import { decodeAudioFile, formatDuration } from '@/lib/audio/decoder'; export interface UseAudioPlayerReturn { // File management loadFile: (file: File) => Promise; + loadBuffer: (buffer: AudioBuffer, name?: string) => void; clearFile: () => void; // Playback controls @@ -99,6 +100,21 @@ export function useAudioPlayer(): UseAudioPlayerReturn { [player] ); + const loadBuffer = React.useCallback( + (buffer: AudioBuffer, name?: string) => { + if (!player) return; + + player.loadBuffer(buffer); + setAudioBuffer(buffer); + if (name) setFileName(name); + setDuration(buffer.duration); + setCurrentTime(0); + setIsPlaying(false); + setIsPaused(false); + }, + [player] + ); + const clearFile = React.useCallback(() => { if (!player) return; @@ -173,6 +189,7 @@ export function useAudioPlayer(): UseAudioPlayerReturn { return { loadFile, + loadBuffer, clearFile, play, pause, diff --git a/types/selection.ts b/types/selection.ts new file mode 100644 index 0000000..65145c1 --- /dev/null +++ b/types/selection.ts @@ -0,0 +1,23 @@ +/** + * Selection and region types + */ + +export interface Selection { + start: number; + end: number; +} + +export interface Region { + id: string; + start: number; + end: number; + label?: string; + color?: string; +} + +export interface ClipboardData { + buffer: AudioBuffer; + start: number; + end: number; + duration: number; +}