dawg / dawg

A DAW built using Electron and the Web Audio API

Home Page:https://dawg.github.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Undo/Redo Framework

jsmith opened this issue · comments

commented

Problem

Undo redo is very hard. A very simple implementation of undo/redo uses the command pattern however this becomes error prone and verbose.

Consider the following example:

interface Command {
  execute(): void;
  undo(): void;
}

interface Instrument {
  volume: number;
  setVolume(volume: number): void;
}

First, the volume parameter needs to be reactive. This means that if the setVolume is called and the volume attribute is updated, the UI should update as well. Second, the appropriate attribute in the web audio objects need to be updated. For example, if this instrument were a Synth, the gain value would need to be updated. As you can see, it is already fairly complex and this is one of the simple examples. The most difficult would be the array of scheduled elements (e.g. the notes in a score). These elements MUST be stored in an array for reactivity and must schedule/unschedule the element from the transport as the user adds/removes element from the sequencer.

Solution

An undo/redo framework (called olyger) that allows dependency chains to be created, abstracts the complex logic and exposes a simple interface. The two core items are the following:

type Undo = () => void;
type Execute = () => Undo | Disposer[] | Disposer;

// OlyRef
interface ElementChangeContext<T> {
  newValue: T;
  oldValue: T;
  onExecute: (cb: Execute) => void;
}

interface ElementChaining<T> {
  onDidChange: (cb: (o: ElementChangeContext<T>) => void) => Disposer;
}

type OlyRef<T> = { value: T; } & ElementChaining<T>;

// OlyArr
interface Items<T> {
  items: T[];
  startingIndex: number;
  onExecute: (cb: Execute) => void;
}

interface ArrayChaining<T> {
  onDidAdd: (cb: (o: Items<T>) => void) => Disposer;
  onDidRemove: (cb: (o: Items<T>) => void) => Disposer;
}

export type OlyArr<T> = T[] & ArrayChaining<T>;

The key components are the OlyRef and the OlyArr. The key idea for both of these is that once an action has been performed, all of the steps should be recorded such that they can be undone/redone. Here are two:

  1. When changing the OlyRef for the pan value of an instrument, the audio signal value must also be updated. See how this is done below using the onDidChange and the onExecute functions. The key thing to understand here is that onDidChange is called once for every the the value is changed but is not called when redone. The onExecute function allows us to register functions to be called when executed (either during the initial execution or during a redo) and when undone.
const ref = oly.olyRef(initialPan);
ref.onDidChange(({ onExecute, newValue, oldValue }) => {
  onExecute(() => {
    signal.value = newValue;
    return () => {
      signal.value = oldValue;
    };
  });
});

ref.value++; // the value is updated internally and onDidChange and onExecute are called
oly.undo(); // the value is updated internally and the function returned from onExecute is called
oly.redo(); // the value is updated internally and only onExecute is called
  1. This example concerns audio samples and the scheduled playlist elements and uses the onDidRemove function. Notice that this doesn't use the onDidExecute and demonstrate the idea of chaining. To explain what this does, when a sample is deleted from the sample list we also have to remove all instances of this sample from the playlist. This is a single action that involves multiple steps.
// samples: oly.OlyArr<Sample>
samples.onDidRemove(({ items }) => {
  const removed = new Set(items);
    const toRemove: number[] = [];
    master.elements.forEach((el, ind) => {
      if (el.type === type && removed.has(el.element)) {
        toRemove.push(ind);
      }
    });

    for (const ind of reverse(toRemove)) {
      master.elements.splice(ind, 1);
    }
});

// Remove the third element from the list of samples
// During this call, the onDidRemove function is called the the appropriate element(s) are also removed
// from the playlist. `master.elements` is also an OlyArr so this registers additional steps in the *same* 
// action.
samples.splice(3, 1);
samples.undo(); // Adds the removed element(s) from master and then re-adds the sample(s)
samples.redo(); // Removes the sample(s) and then removes the element(s) from master