feat: implement multi-track waveform selection and editing with undo/redo
Added comprehensive selection and editing capabilities to multi-track editor: - Visual selection overlay with Shift+drag interaction on waveforms - Multi-track edit commands (cut, copy, paste, delete, duplicate) - Full keyboard shortcut support (Ctrl+X/C/V/D, Delete, Ctrl+Z/Y) - Complete undo/redo integration via command pattern - Per-track selection state with localStorage persistence - Audio buffer manipulation utilities (extract, insert, delete, duplicate segments) - Toast notifications for all edit operations - Red playhead to distinguish from blue selection overlay All edit operations are fully undoable and integrated with the existing history manager system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,15 +14,30 @@ import { useToast } from '@/components/ui/Toast';
|
|||||||
import { TrackList } from '@/components/tracks/TrackList';
|
import { TrackList } from '@/components/tracks/TrackList';
|
||||||
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
|
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
|
||||||
import { formatDuration } from '@/lib/audio/decoder';
|
import { formatDuration } from '@/lib/audio/decoder';
|
||||||
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import {
|
||||||
|
createMultiTrackCutCommand,
|
||||||
|
createMultiTrackCopyCommand,
|
||||||
|
createMultiTrackDeleteCommand,
|
||||||
|
createMultiTrackPasteCommand,
|
||||||
|
createMultiTrackDuplicateCommand,
|
||||||
|
} from '@/lib/history/commands/multi-track-edit-command';
|
||||||
|
import { extractBufferSegment } from '@/lib/audio/buffer-utils';
|
||||||
|
|
||||||
export function AudioEditor() {
|
export function AudioEditor() {
|
||||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
|
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
|
||||||
const [zoom, setZoom] = React.useState(1);
|
const [zoom, setZoom] = React.useState(1);
|
||||||
const [masterVolume, setMasterVolume] = React.useState(0.8);
|
const [masterVolume, setMasterVolume] = React.useState(0.8);
|
||||||
|
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
|
||||||
|
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// Command history for undo/redo
|
||||||
|
const { execute: executeCommand, undo, redo, state: historyState } = useHistory();
|
||||||
|
const canUndo = historyState.canUndo;
|
||||||
|
const canRedo = historyState.canRedo;
|
||||||
|
|
||||||
// Multi-track hooks
|
// Multi-track hooks
|
||||||
const {
|
const {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -135,6 +150,146 @@ export function AudioEditor() {
|
|||||||
updateTrack(selectedTrack.id, { effectChain: updatedChain });
|
updateTrack(selectedTrack.id, { effectChain: updatedChain });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Selection handler
|
||||||
|
const handleSelectionChange = (trackId: string, selection: { start: number; end: number } | null) => {
|
||||||
|
updateTrack(trackId, { selection });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit handlers
|
||||||
|
const handleCut = React.useCallback(() => {
|
||||||
|
const track = tracks.find((t) => t.selection);
|
||||||
|
if (!track || !track.audioBuffer || !track.selection) return;
|
||||||
|
|
||||||
|
// Extract to clipboard
|
||||||
|
const extracted = extractBufferSegment(
|
||||||
|
track.audioBuffer,
|
||||||
|
track.selection.start,
|
||||||
|
track.selection.end
|
||||||
|
);
|
||||||
|
setClipboard(extracted);
|
||||||
|
|
||||||
|
// Execute cut command
|
||||||
|
const command = createMultiTrackCutCommand(
|
||||||
|
track.id,
|
||||||
|
track.audioBuffer,
|
||||||
|
track.selection,
|
||||||
|
(trackId, buffer, selection) => {
|
||||||
|
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
executeCommand(command);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: 'Cut',
|
||||||
|
description: 'Selection cut to clipboard',
|
||||||
|
variant: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||||
|
|
||||||
|
const handleCopy = React.useCallback(() => {
|
||||||
|
const track = tracks.find((t) => t.selection);
|
||||||
|
if (!track || !track.audioBuffer || !track.selection) return;
|
||||||
|
|
||||||
|
// Extract to clipboard
|
||||||
|
const extracted = extractBufferSegment(
|
||||||
|
track.audioBuffer,
|
||||||
|
track.selection.start,
|
||||||
|
track.selection.end
|
||||||
|
);
|
||||||
|
setClipboard(extracted);
|
||||||
|
|
||||||
|
// Execute copy command (doesn't modify buffer, just for undo history)
|
||||||
|
const command = createMultiTrackCopyCommand(
|
||||||
|
track.id,
|
||||||
|
track.audioBuffer,
|
||||||
|
track.selection,
|
||||||
|
(trackId, buffer, selection) => {
|
||||||
|
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
executeCommand(command);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: 'Copy',
|
||||||
|
description: 'Selection copied to clipboard',
|
||||||
|
variant: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||||
|
|
||||||
|
const handlePaste = React.useCallback(() => {
|
||||||
|
if (!clipboard || !selectedTrackId) return;
|
||||||
|
|
||||||
|
const track = tracks.find((t) => t.id === selectedTrackId);
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
// Paste at current time or at end of buffer
|
||||||
|
const pastePosition = currentTime || track.audioBuffer?.duration || 0;
|
||||||
|
|
||||||
|
const command = createMultiTrackPasteCommand(
|
||||||
|
track.id,
|
||||||
|
track.audioBuffer,
|
||||||
|
clipboard,
|
||||||
|
pastePosition,
|
||||||
|
(trackId, buffer, selection) => {
|
||||||
|
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
executeCommand(command);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: 'Paste',
|
||||||
|
description: 'Clipboard content pasted',
|
||||||
|
variant: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}, [clipboard, selectedTrackId, tracks, currentTime, executeCommand, updateTrack, addToast]);
|
||||||
|
|
||||||
|
const handleDelete = React.useCallback(() => {
|
||||||
|
const track = tracks.find((t) => t.selection);
|
||||||
|
if (!track || !track.audioBuffer || !track.selection) return;
|
||||||
|
|
||||||
|
const command = createMultiTrackDeleteCommand(
|
||||||
|
track.id,
|
||||||
|
track.audioBuffer,
|
||||||
|
track.selection,
|
||||||
|
(trackId, buffer, selection) => {
|
||||||
|
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
executeCommand(command);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: 'Delete',
|
||||||
|
description: 'Selection deleted',
|
||||||
|
variant: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||||
|
|
||||||
|
const handleDuplicate = React.useCallback(() => {
|
||||||
|
const track = tracks.find((t) => t.selection);
|
||||||
|
if (!track || !track.audioBuffer || !track.selection) return;
|
||||||
|
|
||||||
|
const command = createMultiTrackDuplicateCommand(
|
||||||
|
track.id,
|
||||||
|
track.audioBuffer,
|
||||||
|
track.selection,
|
||||||
|
(trackId, buffer, selection) => {
|
||||||
|
updateTrack(trackId, { audioBuffer: buffer, selection });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
executeCommand(command);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: 'Duplicate',
|
||||||
|
description: 'Selection duplicated',
|
||||||
|
variant: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||||
|
|
||||||
// Zoom controls
|
// Zoom controls
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setZoom((prev) => Math.min(20, prev + 1));
|
setZoom((prev) => Math.min(20, prev + 1));
|
||||||
@@ -265,6 +420,59 @@ export function AudioEditor() {
|
|||||||
|
|
||||||
if (isTyping) return;
|
if (isTyping) return;
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Z: Undo
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canUndo) {
|
||||||
|
undo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y: Redo
|
||||||
|
if (((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) || ((e.ctrlKey || e.metaKey) && e.key === 'y')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canRedo) {
|
||||||
|
redo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+X: Cut
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'x') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+C: Copy
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCopy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+V: Paste
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePaste();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+D: Duplicate
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDuplicate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete or Backspace: Delete selection
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Escape: Clear selection
|
// Escape: Clear selection
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -274,7 +482,7 @@ export function AudioEditor() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [togglePlayPause]);
|
}, [togglePlayPause, canUndo, canRedo, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -331,6 +539,7 @@ export function AudioEditor() {
|
|||||||
onRemoveTrack={handleRemoveTrack}
|
onRemoveTrack={handleRemoveTrack}
|
||||||
onUpdateTrack={updateTrack}
|
onUpdateTrack={updateTrack}
|
||||||
onSeek={seek}
|
onSeek={seek}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface TrackProps {
|
|||||||
onRemoveEffect?: (effectId: string) => void;
|
onRemoveEffect?: (effectId: string) => void;
|
||||||
onUpdateEffect?: (effectId: string, parameters: any) => void;
|
onUpdateEffect?: (effectId: string, parameters: any) => void;
|
||||||
onAddEffect?: (effectType: EffectType) => void;
|
onAddEffect?: (effectType: EffectType) => void;
|
||||||
|
onSelectionChange?: (selection: { start: number; end: number } | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Track({
|
export function Track({
|
||||||
@@ -52,6 +53,7 @@ export function Track({
|
|||||||
onRemoveEffect,
|
onRemoveEffect,
|
||||||
onUpdateEffect,
|
onUpdateEffect,
|
||||||
onAddEffect,
|
onAddEffect,
|
||||||
|
onSelectionChange,
|
||||||
}: TrackProps) {
|
}: TrackProps) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -63,6 +65,10 @@ export function Track({
|
|||||||
const [themeKey, setThemeKey] = React.useState(0);
|
const [themeKey, setThemeKey] = React.useState(0);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
const [isSelecting, setIsSelecting] = React.useState(false);
|
||||||
|
const [selectionStart, setSelectionStart] = React.useState<number | null>(null);
|
||||||
|
|
||||||
const handleNameClick = () => {
|
const handleNameClick = () => {
|
||||||
setIsEditingName(true);
|
setIsEditingName(true);
|
||||||
setNameInput(String(track.name || 'Untitled Track'));
|
setNameInput(String(track.name || 'Untitled Track'));
|
||||||
@@ -175,27 +181,98 @@ export function Track({
|
|||||||
ctx.lineTo(width, height / 2);
|
ctx.lineTo(width, height / 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw selection overlay
|
||||||
|
if (track.selection && duration > 0) {
|
||||||
|
const selStartX = (track.selection.start / duration) * width;
|
||||||
|
const selEndX = (track.selection.end / duration) * width;
|
||||||
|
|
||||||
|
// Draw selection background
|
||||||
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
||||||
|
ctx.fillRect(selStartX, 0, selEndX - selStartX, height);
|
||||||
|
|
||||||
|
// Draw selection borders
|
||||||
|
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
// Start border
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(selStartX, 0);
|
||||||
|
ctx.lineTo(selStartX, height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// End border
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(selEndX, 0);
|
||||||
|
ctx.lineTo(selEndX, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
// Draw playhead
|
// Draw playhead
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const playheadX = (currentTime / duration) * width;
|
const playheadX = (currentTime / duration) * width;
|
||||||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)';
|
ctx.strokeStyle = 'rgba(239, 68, 68, 0.8)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(playheadX, 0);
|
ctx.moveTo(playheadX, 0);
|
||||||
ctx.lineTo(playheadX, height);
|
ctx.lineTo(playheadX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}, [track.audioBuffer, track.color, track.collapsed, track.height, zoom, currentTime, duration, themeKey]);
|
}, [track.audioBuffer, track.color, track.collapsed, track.height, zoom, currentTime, duration, themeKey, track.selection]);
|
||||||
|
|
||||||
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (!onSeek || !duration) return;
|
if (!duration) return;
|
||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const clickTime = (x / rect.width) * duration;
|
const clickTime = (x / rect.width) * duration;
|
||||||
onSeek(clickTime);
|
|
||||||
|
// Shift+drag to create selection
|
||||||
|
if (e.shiftKey) {
|
||||||
|
setIsSelecting(true);
|
||||||
|
setSelectionStart(clickTime);
|
||||||
|
onSelectionChange?.({ start: clickTime, end: clickTime });
|
||||||
|
} else {
|
||||||
|
// Regular click clears selection and seeks
|
||||||
|
onSelectionChange?.(null);
|
||||||
|
if (onSeek) {
|
||||||
|
onSeek(clickTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isSelecting || selectionStart === null || !duration) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const currentTime = (x / rect.width) * duration;
|
||||||
|
|
||||||
|
// Clamp to valid time range
|
||||||
|
const clampedTime = Math.max(0, Math.min(duration, currentTime));
|
||||||
|
|
||||||
|
// Update selection (ensure start < end)
|
||||||
|
const start = Math.min(selectionStart, clampedTime);
|
||||||
|
const end = Math.max(selectionStart, clampedTime);
|
||||||
|
|
||||||
|
onSelectionChange?.({ start, end });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseUp = () => {
|
||||||
|
setIsSelecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle mouse leaving canvas during selection
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleGlobalMouseUp = () => {
|
||||||
|
if (isSelecting) {
|
||||||
|
setIsSelecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||||
|
return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||||
|
}, [isSelecting]);
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file || !onLoadAudio) return;
|
if (!file || !onLoadAudio) return;
|
||||||
@@ -422,8 +499,10 @@ export function Track({
|
|||||||
{track.audioBuffer ? (
|
{track.audioBuffer ? (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="absolute inset-0 w-full h-full cursor-pointer"
|
className="absolute inset-0 w-full h-full cursor-crosshair"
|
||||||
onClick={handleCanvasClick}
|
onMouseDown={handleCanvasMouseDown}
|
||||||
|
onMouseMove={handleCanvasMouseMove}
|
||||||
|
onMouseUp={handleCanvasMouseUp}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
!track.collapsed && (
|
!track.collapsed && (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface TrackListProps {
|
|||||||
onRemoveTrack: (trackId: string) => void;
|
onRemoveTrack: (trackId: string) => void;
|
||||||
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onSelectionChange?: (trackId: string, selection: { start: number; end: number } | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrackList({
|
export function TrackList({
|
||||||
@@ -34,6 +35,7 @@ export function TrackList({
|
|||||||
onRemoveTrack,
|
onRemoveTrack,
|
||||||
onUpdateTrack,
|
onUpdateTrack,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onSelectionChange,
|
||||||
}: TrackListProps) {
|
}: TrackListProps) {
|
||||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -144,6 +146,11 @@ export function TrackList({
|
|||||||
};
|
};
|
||||||
onUpdateTrack(track.id, { effectChain: updatedChain });
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
}}
|
}}
|
||||||
|
onSelectionChange={
|
||||||
|
onSelectionChange
|
||||||
|
? (selection) => onSelectionChange(track.id, selection)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -165,3 +165,15 @@ export function concatenateBuffers(
|
|||||||
|
|
||||||
return newBuffer;
|
return newBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a segment of audio buffer (extract and insert it after the selection)
|
||||||
|
*/
|
||||||
|
export function duplicateBufferSegment(
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number
|
||||||
|
): AudioBuffer {
|
||||||
|
const segment = extractBufferSegment(buffer, startTime, endTime);
|
||||||
|
return insertBufferSegment(buffer, segment, endTime);
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
|||||||
effectChain: createEffectChain(`${trackName} Effects`),
|
effectChain: createEffectChain(`${trackName} Effects`),
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
|
selection: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
190
lib/history/commands/multi-track-edit-command.ts
Normal file
190
lib/history/commands/multi-track-edit-command.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Multi-track edit commands for audio operations across tracks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseCommand } from '../command';
|
||||||
|
import type { Track } from '@/types/track';
|
||||||
|
import type { Selection } from '@/types/selection';
|
||||||
|
import {
|
||||||
|
extractBufferSegment,
|
||||||
|
deleteBufferSegment,
|
||||||
|
insertBufferSegment,
|
||||||
|
duplicateBufferSegment,
|
||||||
|
} from '@/lib/audio/buffer-utils';
|
||||||
|
|
||||||
|
export type MultiTrackEditType = 'cut' | 'copy' | 'delete' | 'paste' | 'duplicate';
|
||||||
|
|
||||||
|
export interface MultiTrackEditParams {
|
||||||
|
type: MultiTrackEditType;
|
||||||
|
trackId: string;
|
||||||
|
beforeBuffer: AudioBuffer | null;
|
||||||
|
afterBuffer: AudioBuffer | null;
|
||||||
|
selection?: Selection;
|
||||||
|
clipboardData?: AudioBuffer;
|
||||||
|
pastePosition?: number;
|
||||||
|
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command for multi-track edit operations
|
||||||
|
*/
|
||||||
|
export class MultiTrackEditCommand extends BaseCommand {
|
||||||
|
private type: MultiTrackEditType;
|
||||||
|
private trackId: string;
|
||||||
|
private beforeBuffer: AudioBuffer | null;
|
||||||
|
private afterBuffer: AudioBuffer | null;
|
||||||
|
private selection?: Selection;
|
||||||
|
private clipboardData?: AudioBuffer;
|
||||||
|
private pastePosition?: number;
|
||||||
|
private onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void;
|
||||||
|
|
||||||
|
constructor(params: MultiTrackEditParams) {
|
||||||
|
super();
|
||||||
|
this.type = params.type;
|
||||||
|
this.trackId = params.trackId;
|
||||||
|
this.beforeBuffer = params.beforeBuffer;
|
||||||
|
this.afterBuffer = params.afterBuffer;
|
||||||
|
this.selection = params.selection;
|
||||||
|
this.clipboardData = params.clipboardData;
|
||||||
|
this.pastePosition = params.pastePosition;
|
||||||
|
this.onApply = params.onApply;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
// For copy, don't modify the buffer, just update selection
|
||||||
|
if (this.type === 'copy') {
|
||||||
|
this.onApply(this.trackId, this.beforeBuffer, this.selection || null);
|
||||||
|
} else {
|
||||||
|
this.onApply(this.trackId, this.afterBuffer, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
this.onApply(this.trackId, this.beforeBuffer, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
switch (this.type) {
|
||||||
|
case 'cut':
|
||||||
|
return 'Cut';
|
||||||
|
case 'copy':
|
||||||
|
return 'Copy';
|
||||||
|
case 'delete':
|
||||||
|
return 'Delete';
|
||||||
|
case 'paste':
|
||||||
|
return 'Paste';
|
||||||
|
case 'duplicate':
|
||||||
|
return 'Duplicate';
|
||||||
|
default:
|
||||||
|
return 'Edit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory functions to create multi-track edit commands
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function createMultiTrackCutCommand(
|
||||||
|
trackId: string,
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
selection: Selection,
|
||||||
|
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||||
|
): MultiTrackEditCommand {
|
||||||
|
const afterBuffer = deleteBufferSegment(buffer, selection.start, selection.end);
|
||||||
|
|
||||||
|
return new MultiTrackEditCommand({
|
||||||
|
type: 'cut',
|
||||||
|
trackId,
|
||||||
|
beforeBuffer: buffer,
|
||||||
|
afterBuffer,
|
||||||
|
selection,
|
||||||
|
onApply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMultiTrackCopyCommand(
|
||||||
|
trackId: string,
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
selection: Selection,
|
||||||
|
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||||
|
): MultiTrackEditCommand {
|
||||||
|
// Copy doesn't modify the buffer
|
||||||
|
return new MultiTrackEditCommand({
|
||||||
|
type: 'copy',
|
||||||
|
trackId,
|
||||||
|
beforeBuffer: buffer,
|
||||||
|
afterBuffer: buffer,
|
||||||
|
selection,
|
||||||
|
onApply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMultiTrackDeleteCommand(
|
||||||
|
trackId: string,
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
selection: Selection,
|
||||||
|
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||||
|
): MultiTrackEditCommand {
|
||||||
|
const afterBuffer = deleteBufferSegment(buffer, selection.start, selection.end);
|
||||||
|
|
||||||
|
return new MultiTrackEditCommand({
|
||||||
|
type: 'delete',
|
||||||
|
trackId,
|
||||||
|
beforeBuffer: buffer,
|
||||||
|
afterBuffer,
|
||||||
|
selection,
|
||||||
|
onApply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMultiTrackPasteCommand(
|
||||||
|
trackId: string,
|
||||||
|
buffer: AudioBuffer | null,
|
||||||
|
clipboardData: AudioBuffer,
|
||||||
|
pastePosition: number,
|
||||||
|
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||||
|
): MultiTrackEditCommand {
|
||||||
|
const targetBuffer = buffer || createSilentBuffer(clipboardData.sampleRate, clipboardData.numberOfChannels, pastePosition);
|
||||||
|
const afterBuffer = insertBufferSegment(targetBuffer, clipboardData, pastePosition);
|
||||||
|
|
||||||
|
return new MultiTrackEditCommand({
|
||||||
|
type: 'paste',
|
||||||
|
trackId,
|
||||||
|
beforeBuffer: buffer,
|
||||||
|
afterBuffer,
|
||||||
|
clipboardData,
|
||||||
|
pastePosition,
|
||||||
|
onApply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMultiTrackDuplicateCommand(
|
||||||
|
trackId: string,
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
selection: Selection,
|
||||||
|
onApply: (trackId: string, buffer: AudioBuffer | null, selection: Selection | null) => void
|
||||||
|
): MultiTrackEditCommand {
|
||||||
|
const afterBuffer = duplicateBufferSegment(buffer, selection.start, selection.end);
|
||||||
|
|
||||||
|
return new MultiTrackEditCommand({
|
||||||
|
type: 'duplicate',
|
||||||
|
trackId,
|
||||||
|
beforeBuffer: buffer,
|
||||||
|
afterBuffer,
|
||||||
|
selection,
|
||||||
|
onApply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a silent buffer
|
||||||
|
*/
|
||||||
|
function createSilentBuffer(sampleRate: number, numberOfChannels: number, duration: number): AudioBuffer {
|
||||||
|
const audioContext = new OfflineAudioContext(
|
||||||
|
numberOfChannels,
|
||||||
|
Math.ceil(duration * sampleRate),
|
||||||
|
sampleRate
|
||||||
|
);
|
||||||
|
return audioContext.createBuffer(numberOfChannels, Math.ceil(duration * sampleRate), sampleRate);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export function useMultiTrack() {
|
|||||||
name: String(t.name || 'Untitled Track'), // Ensure name is always a string
|
name: String(t.name || 'Untitled Track'), // Ensure name is always a string
|
||||||
audioBuffer: null, // Will need to be reloaded
|
audioBuffer: null, // Will need to be reloaded
|
||||||
effectChain: t.effectChain || createEffectChain(`${t.name} Effects`), // Restore effect chain or create new
|
effectChain: t.effectChain || createEffectChain(`${t.name} Effects`), // Restore effect chain or create new
|
||||||
|
selection: t.selection || null, // Initialize selection
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EffectChain } from '@/lib/audio/effects/chain';
|
import type { EffectChain } from '@/lib/audio/effects/chain';
|
||||||
|
import type { Selection } from './selection';
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,6 +25,9 @@ export interface Track {
|
|||||||
// UI state
|
// UI state
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
|
||||||
|
// Selection (for editing operations)
|
||||||
|
selection: Selection | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackState {
|
export interface TrackState {
|
||||||
|
|||||||
Reference in New Issue
Block a user