add undo option

This commit is contained in:
2025-10-13 10:58:03 +02:00
parent cb730237f5
commit 4df063f9b3
3 changed files with 127 additions and 0 deletions

View File

@ -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<any>(null);
let currentBuffer = $state<AudioBuffer | null>(null);
@ -42,6 +44,7 @@
let pitchLockInputValid = $state(true);
let selectionStart = $state<number | null>(null);
let selectionEnd = $state<number | null>(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}
</div>
<button onclick={download}>Download (D)</button>
<button onclick={undo} disabled={!canUndo}>Undo (Z)</button>
{/if}
</div>
</div>

View File

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

View File

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