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>
This commit is contained in:
@@ -6,19 +6,32 @@ import { AudioInfo } from './AudioInfo';
|
|||||||
import { Waveform } from './Waveform';
|
import { Waveform } from './Waveform';
|
||||||
import { PlaybackControls } from './PlaybackControls';
|
import { PlaybackControls } from './PlaybackControls';
|
||||||
import { ZoomControls } from './ZoomControls';
|
import { ZoomControls } from './ZoomControls';
|
||||||
|
import { EditControls } from './EditControls';
|
||||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||||
import { Slider } from '@/components/ui/Slider';
|
import { Slider } from '@/components/ui/Slider';
|
||||||
import { Loader2 } from 'lucide-react';
|
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() {
|
export function AudioEditor() {
|
||||||
// Zoom and scroll state
|
// Zoom and scroll state
|
||||||
const [zoom, setZoom] = React.useState(1);
|
const [zoom, setZoom] = React.useState(1);
|
||||||
const [scrollOffset, setScrollOffset] = React.useState(0);
|
const [scrollOffset, setScrollOffset] = React.useState(0);
|
||||||
const [amplitudeScale, setAmplitudeScale] = React.useState(1);
|
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 {
|
const {
|
||||||
loadFile,
|
loadFile,
|
||||||
|
loadBuffer,
|
||||||
clearFile,
|
clearFile,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
@@ -64,6 +77,8 @@ export function AudioEditor() {
|
|||||||
setZoom(1);
|
setZoom(1);
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
setAmplitudeScale(1);
|
setAmplitudeScale(1);
|
||||||
|
setSelection(null);
|
||||||
|
setClipboard(null);
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Audio cleared',
|
title: 'Audio cleared',
|
||||||
description: 'Audio file has been removed',
|
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
|
// Zoom controls
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setZoom((prev) => Math.min(20, prev + 1));
|
setZoom((prev) => Math.min(20, prev + 1));
|
||||||
@@ -96,6 +255,55 @@ export function AudioEditor() {
|
|||||||
}
|
}
|
||||||
}, [zoom, audioBuffer]);
|
}, [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
|
// Show error toast
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -151,6 +359,8 @@ export function AudioEditor() {
|
|||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
scrollOffset={scrollOffset}
|
scrollOffset={scrollOffset}
|
||||||
amplitudeScale={amplitudeScale}
|
amplitudeScale={amplitudeScale}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={setSelection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Horizontal scroll for zoomed waveform */}
|
{/* Horizontal scroll for zoomed waveform */}
|
||||||
@@ -171,6 +381,25 @@ export function AudioEditor() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Zoom Controls */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
139
components/editor/EditControls.tsx
Normal file
139
components/editor/EditControls.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Selection Info */}
|
||||||
|
{hasSelection && (
|
||||||
|
<div className="rounded-lg border border-info bg-info/10 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="h-4 w-4 text-info-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0 text-sm">
|
||||||
|
<p className="font-medium text-info-foreground">Selection Active</p>
|
||||||
|
<p className="text-info-foreground/90 mt-1">
|
||||||
|
Duration: {formatDuration(selectionDuration)} |
|
||||||
|
Start: {formatDuration(selection.start)} |
|
||||||
|
End: {formatDuration(selection.end)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-info-foreground/75 mt-1">
|
||||||
|
Tip: Hold Shift and drag on the waveform to select a region
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCut}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
title="Cut (Ctrl+X)"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Scissors className="h-4 w-4 mr-2" />
|
||||||
|
Cut
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCopy}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
title="Copy (Ctrl+C)"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPaste}
|
||||||
|
disabled={!hasClipboard}
|
||||||
|
title="Paste (Ctrl+V)"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Clipboard className="h-4 w-4 mr-2" />
|
||||||
|
Paste
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
title="Delete (Del)"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onTrim}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
title="Trim to Selection"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<CropIcon className="h-4 w-4 mr-2" />
|
||||||
|
Trim
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
title="Clear Selection (Esc)"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts Info */}
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1 p-3 rounded-lg bg-muted/30">
|
||||||
|
<p className="font-medium mb-2">Keyboard Shortcuts:</p>
|
||||||
|
<p>• Shift+Drag: Select region</p>
|
||||||
|
<p>• Ctrl+A: Select all</p>
|
||||||
|
<p>• Ctrl+X: Cut</p>
|
||||||
|
<p>• Ctrl+C: Copy</p>
|
||||||
|
<p>• Ctrl+V: Paste</p>
|
||||||
|
<p>• Delete: Delete selection</p>
|
||||||
|
<p>• Escape: Clear selection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { generateMinMaxPeaks } from '@/lib/waveform/peaks';
|
import { generateMinMaxPeaks } from '@/lib/waveform/peaks';
|
||||||
|
import type { Selection } from '@/types/selection';
|
||||||
|
|
||||||
export interface WaveformProps {
|
export interface WaveformProps {
|
||||||
audioBuffer: AudioBuffer | null;
|
audioBuffer: AudioBuffer | null;
|
||||||
@@ -14,6 +15,8 @@ export interface WaveformProps {
|
|||||||
zoom?: number;
|
zoom?: number;
|
||||||
scrollOffset?: number;
|
scrollOffset?: number;
|
||||||
amplitudeScale?: number;
|
amplitudeScale?: number;
|
||||||
|
selection?: Selection | null;
|
||||||
|
onSelectionChange?: (selection: Selection | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Waveform({
|
export function Waveform({
|
||||||
@@ -26,11 +29,15 @@ export function Waveform({
|
|||||||
zoom = 1,
|
zoom = 1,
|
||||||
scrollOffset = 0,
|
scrollOffset = 0,
|
||||||
amplitudeScale = 1,
|
amplitudeScale = 1,
|
||||||
|
selection = null,
|
||||||
|
onSelectionChange,
|
||||||
}: WaveformProps) {
|
}: WaveformProps) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = React.useState(800);
|
const [width, setWidth] = React.useState(800);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const [isSelecting, setIsSelecting] = React.useState(false);
|
||||||
|
const [selectionStart, setSelectionStart] = React.useState<number | null>(null);
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -128,6 +135,38 @@ export function Waveform({
|
|||||||
ctx.lineTo(width, middle);
|
ctx.lineTo(width, middle);
|
||||||
ctx.stroke();
|
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
|
// Draw playhead
|
||||||
if (progressX >= 0 && progressX <= width) {
|
if (progressX >= 0 && progressX <= width) {
|
||||||
ctx.strokeStyle = '#ef4444';
|
ctx.strokeStyle = '#ef4444';
|
||||||
@@ -137,7 +176,7 @@ export function Waveform({
|
|||||||
ctx.lineTo(progressX, height);
|
ctx.lineTo(progressX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale]);
|
}, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale, selection]);
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (!onSeek || !duration || isDragging) return;
|
if (!onSeek || !duration || isDragging) return;
|
||||||
@@ -157,34 +196,64 @@ export function Waveform({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (!onSeek || !duration) return;
|
if (!duration) return;
|
||||||
setIsDragging(true);
|
|
||||||
handleClick(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
if (!isDragging || !onSeek || !duration) return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
|
|
||||||
// Account for zoom and scroll
|
|
||||||
const visibleWidth = width * zoom;
|
const visibleWidth = width * zoom;
|
||||||
const actualX = x + scrollOffset;
|
const actualX = x + scrollOffset;
|
||||||
const clickedTime = (actualX / visibleWidth) * duration;
|
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<HTMLCanvasElement>) => {
|
||||||
|
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 = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectionStart(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectionStart(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,7 +267,7 @@ export function Waveform({
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-lg border border-border',
|
'w-full rounded-lg border border-border',
|
||||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
isDragging ? 'cursor-grabbing' : isSelecting ? 'cursor-text' : 'cursor-pointer'
|
||||||
)}
|
)}
|
||||||
style={{ height: `${height}px` }}
|
style={{ height: `${height}px` }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
167
lib/audio/buffer-utils.ts
Normal file
167
lib/audio/buffer-utils.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { decodeAudioFile, formatDuration } from '@/lib/audio/decoder';
|
|||||||
export interface UseAudioPlayerReturn {
|
export interface UseAudioPlayerReturn {
|
||||||
// File management
|
// File management
|
||||||
loadFile: (file: File) => Promise<void>;
|
loadFile: (file: File) => Promise<void>;
|
||||||
|
loadBuffer: (buffer: AudioBuffer, name?: string) => void;
|
||||||
clearFile: () => void;
|
clearFile: () => void;
|
||||||
|
|
||||||
// Playback controls
|
// Playback controls
|
||||||
@@ -99,6 +100,21 @@ export function useAudioPlayer(): UseAudioPlayerReturn {
|
|||||||
[player]
|
[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(() => {
|
const clearFile = React.useCallback(() => {
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
@@ -173,6 +189,7 @@ export function useAudioPlayer(): UseAudioPlayerReturn {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loadFile,
|
loadFile,
|
||||||
|
loadBuffer,
|
||||||
clearFile,
|
clearFile,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
|
|||||||
23
types/selection.ts
Normal file
23
types/selection.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user