Files
audio-ui/components/editor/AudioEditor.tsx
Sebastian Krüger ed9ac0b24f feat: implement Phase 4 - selection and editing features
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>
2025-11-17 15:50:42 +01:00

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>
);
}