Added comprehensive audio editing capabilities: - Region selection with Shift+drag on waveform - Visual selection feedback with blue overlay and borders - AudioBuffer manipulation utilities (cut, copy, paste, delete, trim) - EditControls UI component with edit buttons - Keyboard shortcuts (Ctrl+A, Ctrl+X, Ctrl+C, Ctrl+V, Delete, Escape) - Clipboard management for cut/copy/paste operations - Updated useAudioPlayer hook with loadBuffer method New files: - types/selection.ts - Selection and ClipboardData interfaces - lib/audio/buffer-utils.ts - AudioBuffer manipulation utilities - components/editor/EditControls.tsx - Edit controls UI Modified files: - components/editor/Waveform.tsx - Added selection support - components/editor/AudioEditor.tsx - Integrated edit operations - lib/hooks/useAudioPlayer.ts - Added loadBuffer method 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { FileUpload } from './FileUpload';
|
|
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<Selection | null>(null);
|
|
const [clipboard, setClipboard] = React.useState<ClipboardData | null>(null);
|
|
const {
|
|
loadFile,
|
|
loadBuffer,
|
|
clearFile,
|
|
play,
|
|
pause,
|
|
stop,
|
|
seek,
|
|
setVolume,
|
|
isPlaying,
|
|
isPaused,
|
|
currentTime,
|
|
duration,
|
|
volume,
|
|
audioBuffer,
|
|
fileName,
|
|
isLoading,
|
|
error,
|
|
currentTimeFormatted,
|
|
durationFormatted,
|
|
} = useAudioPlayer();
|
|
|
|
const { addToast } = useToast();
|
|
|
|
const handleFileSelect = async (file: File) => {
|
|
try {
|
|
await loadFile(file);
|
|
addToast({
|
|
title: 'File loaded',
|
|
description: `Successfully loaded ${file.name}`,
|
|
variant: 'success',
|
|
duration: 3000,
|
|
});
|
|
} catch (err) {
|
|
addToast({
|
|
title: 'Error loading file',
|
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
variant: 'error',
|
|
duration: 5000,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
clearFile();
|
|
setZoom(1);
|
|
setScrollOffset(0);
|
|
setAmplitudeScale(1);
|
|
setSelection(null);
|
|
setClipboard(null);
|
|
addToast({
|
|
title: 'Audio cleared',
|
|
description: 'Audio file has been removed',
|
|
variant: 'info',
|
|
duration: 2000,
|
|
});
|
|
};
|
|
|
|
// 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));
|
|
};
|
|
|
|
const handleZoomOut = () => {
|
|
setZoom((prev) => Math.max(1, prev - 1));
|
|
};
|
|
|
|
const handleFitToView = () => {
|
|
setZoom(1);
|
|
setScrollOffset(0);
|
|
};
|
|
|
|
// Auto-adjust scroll when zoom changes
|
|
React.useEffect(() => {
|
|
if (!audioBuffer) return;
|
|
|
|
// Reset scroll if zoomed out completely
|
|
if (zoom === 1) {
|
|
setScrollOffset(0);
|
|
}
|
|
}, [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) {
|
|
addToast({
|
|
title: 'Error',
|
|
description: error,
|
|
variant: 'error',
|
|
duration: 5000,
|
|
});
|
|
}
|
|
}, [error, addToast]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* File Upload or Audio Info */}
|
|
{!audioBuffer ? (
|
|
<FileUpload onFileSelect={handleFileSelect} />
|
|
) : (
|
|
<AudioInfo
|
|
fileName={fileName || 'Unknown'}
|
|
audioBuffer={audioBuffer}
|
|
onClear={handleClear}
|
|
/>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{isLoading && (
|
|
<Card>
|
|
<CardContent className="p-8">
|
|
<div className="flex flex-col items-center justify-center gap-3">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
<p className="text-sm text-muted-foreground">Loading audio file...</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Waveform and Controls */}
|
|
{audioBuffer && !isLoading && (
|
|
<>
|
|
{/* Waveform */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Waveform</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Waveform
|
|
audioBuffer={audioBuffer}
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
onSeek={seek}
|
|
height={150}
|
|
zoom={zoom}
|
|
scrollOffset={scrollOffset}
|
|
amplitudeScale={amplitudeScale}
|
|
selection={selection}
|
|
onSelectionChange={setSelection}
|
|
/>
|
|
|
|
{/* Horizontal scroll for zoomed waveform */}
|
|
{zoom > 1 && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-foreground">
|
|
Scroll Position
|
|
</label>
|
|
<Slider
|
|
value={scrollOffset}
|
|
onChange={setScrollOffset}
|
|
min={0}
|
|
max={Math.max(0, (800 * zoom) - 800)}
|
|
step={1}
|
|
/>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Edit Controls */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Edit</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<EditControls
|
|
selection={selection}
|
|
hasClipboard={clipboard !== null}
|
|
onCut={handleCut}
|
|
onCopy={handleCopy}
|
|
onPaste={handlePaste}
|
|
onDelete={handleDelete}
|
|
onTrim={handleTrim}
|
|
onClearSelection={handleClearSelection}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Zoom Controls */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Zoom & View</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ZoomControls
|
|
zoom={zoom}
|
|
onZoomChange={setZoom}
|
|
amplitudeScale={amplitudeScale}
|
|
onAmplitudeScaleChange={setAmplitudeScale}
|
|
onZoomIn={handleZoomIn}
|
|
onZoomOut={handleZoomOut}
|
|
onFitToView={handleFitToView}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Playback Controls */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Playback</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<PlaybackControls
|
|
isPlaying={isPlaying}
|
|
isPaused={isPaused}
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
volume={volume}
|
|
onPlay={play}
|
|
onPause={pause}
|
|
onStop={stop}
|
|
onSeek={seek}
|
|
onVolumeChange={setVolume}
|
|
currentTimeFormatted={currentTimeFormatted}
|
|
durationFormatted={durationFormatted}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|