From 4df063f9b3c76a4caad3b6db2f276db177224b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 13 Oct 2025 10:58:03 +0200 Subject: [PATCH] add undo option --- src/App.svelte | 59 +++++++++++++++++++++++++++++++++ src/lib/utils/UndoManager.ts | 64 ++++++++++++++++++++++++++++++++++++ src/lib/utils/keyboard.ts | 4 +++ 3 files changed, 127 insertions(+) create mode 100644 src/lib/utils/UndoManager.ts diff --git a/src/App.svelte b/src/App.svelte index 6acf357..80502e2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,11 +18,13 @@ import { Input } from "./lib/audio/engines/Input"; import { createKeyboardHandler } from "./lib/utils/keyboard"; import { parseFrequencyInput, formatFrequency } from "./lib/utils/pitch"; + import { UndoManager, type AudioState } from "./lib/utils/UndoManager"; let currentEngineIndex = $state(0); const engine = $derived(engines[currentEngineIndex]); const engineType = $derived(engine.getType()); const audioService = new AudioService(); + const undoManager = new UndoManager(20); let currentParams = $state(null); let currentBuffer = $state(null); @@ -42,6 +44,7 @@ let pitchLockInputValid = $state(true); let selectionStart = $state(null); let selectionEnd = $state(null); + let canUndo = $state(false); const showDuration = $derived(engineType !== 'sample'); const showRandomButton = $derived(engineType === 'generative'); @@ -77,11 +80,50 @@ generateRandom(); }); + function captureCurrentState(): AudioState | null { + return UndoManager.captureState( + currentBuffer, + currentParams, + isProcessed, + waveformColor, + currentEngineIndex + ); + } + + function pushState(): void { + const state = captureCurrentState(); + if (state) { + undoManager.pushState(state); + canUndo = true; + } + } + + function restoreState(state: AudioState): void { + currentBuffer = audioService.createAudioBuffer([state.leftChannel, state.rightChannel]); + currentParams = state.params; + isProcessed = state.isProcessed; + waveformColor = state.waveformColor; + if (state.engineIndex !== currentEngineIndex) { + currentEngineIndex = state.engineIndex; + } + clearSelection(); + audioService.play(currentBuffer); + } + + function undo(): void { + const previousState = undoManager.undo(); + if (previousState) { + restoreState(previousState); + canUndo = undoManager.canUndo(); + } + } + const keyboardHandler = createKeyboardHandler({ onMutate: mutate, onRandom: generateRandom, onProcess: processSound, onDownload: download, + onUndo: undo, onDurationDecrease: (large) => { duration = Math.max(0.05, duration - (large ? 1 : 0.05)); }, @@ -104,6 +146,8 @@ }); function generateRandom() { + pushState(); + currentParams = engine.randomParams(pitchLock); waveformColor = generateRandomColor(); isProcessed = false; @@ -116,6 +160,8 @@ generateRandom(); return; } + pushState(); + currentParams = engine.mutateParams(currentParams, 0.15, pitchLock); waveformColor = generateRandomColor(); regenerateBuffer(); @@ -166,6 +212,8 @@ async function applyProcessor(processor: AudioProcessor) { if (!currentBuffer) return; + pushState(); + let processedLeft: Float32Array; let processedRight: Float32Array; @@ -194,6 +242,8 @@ } function switchEngine(index: number) { + pushState(); + currentEngineIndex = index; currentBuffer = null; currentParams = null; @@ -214,6 +264,8 @@ async function loadAudioFile(file: File) { if (!(engine instanceof Sample)) return; + pushState(); + try { await engine.loadFile(file); currentParams = engine.randomParams(pitchLock); @@ -229,6 +281,8 @@ async function recordAudio() { if (!(engine instanceof Input) || isRecording) return; + pushState(); + try { isRecording = true; await engine.record(duration); @@ -299,6 +353,8 @@ function cropSelection() { if (!currentBuffer || selectionStart === null || selectionEnd === null) return; + pushState(); + const start = Math.min(selectionStart, selectionEnd); const end = Math.max(selectionStart, selectionEnd); @@ -313,6 +369,8 @@ function cutSelection() { if (!currentBuffer || selectionStart === null || selectionEnd === null) return; + pushState(); + const start = Math.min(selectionStart, selectionEnd); const end = Math.max(selectionStart, selectionEnd); @@ -479,6 +537,7 @@ {/if} + {/if} diff --git a/src/lib/utils/UndoManager.ts b/src/lib/utils/UndoManager.ts new file mode 100644 index 0000000..3f7ac61 --- /dev/null +++ b/src/lib/utils/UndoManager.ts @@ -0,0 +1,64 @@ +export interface AudioState { + leftChannel: Float32Array; + rightChannel: Float32Array; + params: any; + isProcessed: boolean; + waveformColor: string; + engineIndex: number; +} + +export class UndoManager { + private undoStack: AudioState[] = []; + private readonly maxHistorySize: number; + + constructor(maxHistorySize: number = 20) { + this.maxHistorySize = maxHistorySize; + } + + pushState(state: AudioState): void { + this.undoStack.push({ + leftChannel: state.leftChannel.slice(), + rightChannel: state.rightChannel.slice(), + params: state.params, + isProcessed: state.isProcessed, + waveformColor: state.waveformColor, + engineIndex: state.engineIndex, + }); + + if (this.undoStack.length > this.maxHistorySize) { + this.undoStack.shift(); + } + } + + undo(): AudioState | null { + const previousState = this.undoStack.pop(); + return previousState || null; + } + + canUndo(): boolean { + return this.undoStack.length > 0; + } + + clear(): void { + this.undoStack = []; + } + + static captureState( + buffer: AudioBuffer | null, + params: any, + isProcessed: boolean, + waveformColor: string, + engineIndex: number + ): AudioState | null { + if (!buffer) return null; + + return { + leftChannel: buffer.getChannelData(0).slice(), + rightChannel: buffer.getChannelData(1).slice(), + params, + isProcessed, + waveformColor, + engineIndex, + }; + } +} diff --git a/src/lib/utils/keyboard.ts b/src/lib/utils/keyboard.ts index 0eb9366..9ad1556 100644 --- a/src/lib/utils/keyboard.ts +++ b/src/lib/utils/keyboard.ts @@ -8,6 +8,7 @@ export interface KeyboardActions { onVolumeDecrease?: (large: boolean) => void; onVolumeIncrease?: (large: boolean) => void; onEscape?: () => void; + onUndo?: () => void; } export function createKeyboardHandler(actions: KeyboardActions) { @@ -18,6 +19,9 @@ export function createKeyboardHandler(actions: KeyboardActions) { const isLargeAdjustment = event.shiftKey; switch (key) { + case 'z': + actions.onUndo?.(); + break; case 'm': actions.onMutate?.(); break;