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:
2025-11-18 13:05:05 +01:00
parent beb7085c89
commit 74879a42cf
8 changed files with 511 additions and 8 deletions

View File

@@ -165,3 +165,15 @@ export function concatenateBuffers(
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);
}

View File

@@ -37,6 +37,7 @@ export function createTrack(name?: string, color?: TrackColor): Track {
effectChain: createEffectChain(`${trackName} Effects`),
collapsed: false,
selected: false,
selection: null,
};
}

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

View File

@@ -31,6 +31,7 @@ export function useMultiTrack() {
name: String(t.name || 'Untitled Track'), // Ensure name is always a string
audioBuffer: null, // Will need to be reloaded
effectChain: t.effectChain || createEffectChain(`${t.name} Effects`), // Restore effect chain or create new
selection: t.selection || null, // Initialize selection
}));
}
} catch (error) {