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:
2025-11-17 15:50:42 +01:00
parent 5cf9a69056
commit ed9ac0b24f
6 changed files with 656 additions and 12 deletions

View File

@@ -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>

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

View File

@@ -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
View 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;
}

View File

@@ -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
View 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;
}