Mike Zaby

Web Audio Engine Part 4 - implementing Advanced I/O

In the previous post, we began integrating Web Audio and implemented a basic connect functionality, allowing us to link modules together. However, this basic functionality has limitations when it comes to building more complex audio systems. It lacks the flexibility and control needed for advanced audio processing tasks.

To overcome these limitations, in this post, we will explore and implement a more advanced and modular I/O system. This new system will provide greater control over connections, enabling us to create more intricate and scalable audio setups. Additionally, this enhanced I/O system will offer users detailed information about the available I/Os of a module and the current connections between these I/Os.

Before we continue I'm suggesting to do a fast diagonal reading if you have already read it before, or a more detailed reading if you haven't to part1, part2, part3.

You can find the codebase up to this point on the advanced-io branch, or you can view the additions compared with the previous post here.

Data Structure

We will start with the data structure.

First, let's describe the data structure of an I/O:

{
  id: string,
  name: string,
  ioType: IOType,
  moduleId: string,
}

The IOType will keep all the available I/O types that we will implement. In this post, we will implement AudioInput and AudioOutput, but in the next iteration, we will add support for MidiInput and MidiOutput.

// file: /src/core/IO/Base.ts
enum IOType {
  AudioInput = "audioInput",
  AudioOutput = "audioOutput",
}

Next, we want to define the data structure that describes the connection between an input and an output. We name this connection a Route.

{
  id: string,
  source: { ioName: string, moduleId: string },
  destination: { ioName: string, moduleId: string }
}

We decided to use ioName and moduleId as the information for the source and destination instead of just the I/O id. This decision allows us to quickly determine which module an I/O belongs to, and using ioName instead of an id makes it more human-friendly. We also enforce unique name validation to avoid problems with duplicate names.

So this information is enough to know which I/Os are available and the connections between them.

File Structure

In this post, we will implement the Base class for our I/O, the AudioIO, which will handle the connections between AudioNode outputs and AudioNode inputs or AudioParams. Additionally, we will implement a collection that will help us define easily the desired inputs and outputs for each module.

- src
  - core
    - IO
      - index.ts
      - Base.ts
      - AudioIO.ts
      - Collection.ts

The Base IO Class

We want to define the interface for the I/O based on the data that we showed before. To initialize an I/O, we need to know the name of the I/O and its type.

// file: /src/core/IO/Base.ts
interface IOProps {
  name: string;
  ioType: IOType;
}

For serialization, we need to extend the previous interface with the information of I/O id and moduleId.

// file: /src/core/IO/Base.ts
interface IIOSerialize extends IOProps {
  id: string;
  moduleId: string;
}

Constructor

To define an I/O, we also need to know which module it belongs to, for this reason, we pass this argument in the constructor. We use a deterministic id generation based on module id and I/O name. We do this because we want unique ids, but we also want the ability to reproduce the same id when we have the same module id and I/O name. Lastly, we want to keep track of the connections of this I/O.

// file: /src/core/IO/Base.ts
abstract class Base implements IOProps {
  id: string;
  ioType: IOType;
  name: string;
  module: AnyModule;
  connections: Base[];

  constructor(module: AnyModule, props: IOProps) {
    this.module = module;
    this.name = props.name;
    this.ioType = props.ioType;
    this.id = deterministicId(this.module.id, this.name);
    this.connections = [];
  }
}

Plug/UnPlug

We want a standardized procedure for any I/O type that can be plugged or unplugged, and these functions can be overridden with extra logic based on specific I/O type needs. The only thing that we want from basic plug/unplug functionality is to update the connections. Also, because we want both input and output informed about the connection, we have a flag to know if we need to call the plug/unPlug function in the opposite way. We also have an unPlugAll functionality to easily unplug all connections.

// file: /src/core/IO/Base.ts
plug(io: Base, plugOther: boolean = true) {
  this.connections.push(io);
  if (plugOther) io.plug(this, false);
}

unPlug(io: Base, plugOther: boolean = true) {
  this.connections = this.connections.filter(
    (currentIO) => currentIO.id !== io.id,
  );
  if (plugOther) io.unPlug(this, false);
}

unPlugAll() {
  this.connections.forEach((otherIO) => this.unPlug(otherIO));
}

Serialize

We want to serialize the data of the I/O based on what we discussed in the Data Structure section.

// file: /src/core/IO/Base.ts
serialize(): IIOSerialize {
  return {
    id: this.id,
    name: this.name,
    ioType: this.ioType,
    moduleId: this.module.id,
  };
}

Handling Connection As Geneneric

In some cases may we don't want to change to plug/unPlug anything else than what is the Connection, so we make an aditional class that will extends Base with the ability to set a generic about the Connection.

// file: /src/core/IO/Base.ts
abstract class IO<Connection extends Base> extends Base {
  declare connections: Connection[];

  constructor(module: AnyModule, props: IOProps) {
    super(module, props);
  }

  plug(io: Connection, plugOther?: boolean): void {
    super.plug(io, plugOther);
  }

  unPlug(io: Connection, plugOther?: boolean): void {
    super.unPlug(io, plugOther);
  }
}

AudioIO

We want to adjust IOProps to set specific types for AudioInputProps and AudioOutputProps, and extend them with an extra field getAudioNode, which is a callback that returns an AudioNode for output, as only the AudioNode has the ability to call connect. For inputs, the callback returns an AudioNode, AudioParam, or AudioDestinationNode because all of this are able to be connected to an AudioNode. We use a callback instead of a simple property because the AudioNode may change. For example, with an Oscillator, we have to create a new OscillatorNode every time we stop it.

// file: /src/core/IO/AudioIO.ts
export interface AudioInputProps extends IOProps {
  ioType: IOType.AudioInput;
  getAudioNode: () => AudioNode | AudioParam | AudioDestinationNode;
}

export interface AudioOutputProps extends IOProps {
  ioType: IOType.AudioOutput;
  getAudioNode: () => AudioNode;
}

The implementation of AudioInput is straightforward:

// file: /src/core/IO/AudioIO.ts
class AudioInput extends IO<AudioOutput> implements AudioInputProps {
  declare ioType: IOType.AudioInput;
  getAudioNode: AudioInputProps["getAudioNode"];

  constructor(module: AnyModule, props: AudioInputProps) {
    super(module, props);
    this.getAudioNode = props.getAudioNode;
  }
}

In the AudioOutput, we will add the extra code that should be executed when we plug or unplug.

// file: /src/core/IO/AudioIO.ts
export class AudioOutput extends IO<AudioInput> implements AudioOutputProps {
  declare ioType: IOType.AudioOutput;
  getAudioNode!: AudioOutputProps["getAudioNode"];

  constructor(module: AnyModule, props: AudioOutputProps) {
    super(module, props);
    this.getAudioNode = props.getAudioNode;
  }

  plug(io: AudioInput, plugOther: boolean = true) {
    super.plug(io, plugOther);
    const input = io.getAudioNode();

    if (input instanceof AudioParam) {
      this.getAudioNode().connect(input);
    } else {
      this.getAudioNode().connect(input);
    }
  }

  unPlug(io: AudioInput, plugOther: boolean = true) {
    super.unPlug(io, plugOther);
    const input = io.getAudioNode();

    try {
      if (input instanceof AudioParam) {
        this.getAudioNode().disconnect(input);
      } else {
        this.getAudioNode().disconnect(input);
      }
    } catch (e) {
      console.error(e);
    }
  }
}

Unfortunately, we have an awkward handling to connect an AudioNode to an AudioParam. While the connect method allows connecting an AudioNode to an AudioParam, due to a TypeScript issue, we have to handle it like this.

Collection

The last tool for I/O will be the Collection. We want the integration to be as easy as possible and ensure proper separation of concerns. Therefore, we need a class that will serve as a collection of Inputs or Outputs and handle tasks such as adding a new I/O, finding an existing one, and serializing the collection.

As mentioned, we want two collection types: Input and Output. Currently, we have only one type of Node for each collection type.

// file: /src/core/IO/Collection.ts
enum CollectionType {
  Input = "Input",
  Output = "Output",
}

Constructor

We need a class that will take a generic type representing the CollectionType. We will use this generic type later to match properties appropriately based on the collection type.

// file: /src/core/IO/Collection.ts
abstract class IOCollection<T extends CollectionType> {
  module: AnyModule;
  collection: Base[] = [];
  collectionType: T;

  constructor(collectionType: T, module: AnyModule) {
    this.collectionType = collectionType;
    this.module = module;
  }
}

Add I/O

We want to create an interface that will map the appropriate properties based on the collection type. This allows us to avoid incorrect usage at an early stage through type checking. Finally, we need to validate the uniqueness of the I/O name in this collection.

// file: /src/core/IO/Collection.ts
interface IMappedIOProps {
  [CollectionType.Input]: AudioInputProps;
  [CollectionType.Output]: AudioOutputProps;
}
// file: /src/core/IO/Collection.ts
add(props: IMappedIOProps[T]) {
  let io: Base;
  this.validateUniqName(props.name);

  switch (props.ioType) {
    case IOType.AudioInput:
      io = new AudioInput(this.module, props);
      break;
    case IOType.AudioOutput:
      io = new AudioOutput(this.module, props);
      break;
    default:
      assertNever(props);
  }

  this.collection.push(io);

  return io;
}
// file: /src/core/IO/Collection.ts
private validateUniqName(name: string) {
  if (this.collection.some((io) => io.name === name)) {
    throw Error(`An I/O with name ${name} already exists`);
  }
}

Finders And Serializer

These functions are straightforward:

// file: /src/core/IO/Collection.ts
find(id: string) {
  const io = this.collection.find((io) => io.id === id);
  if (!io) throw Error(`The I/O with id ${id} does not exists`);

  return io;
}

findByName(name: string) {
  const io = this.collection.find((io) => io.name === name);
  if (!io) throw Error(`The I/O with name ${name} does not exists`);

  return io;
}

serialize() {
  return this.collection.map((io) => io.serialize());
}

Input/OutputCollection

To facilitate easier usage in the module, we expose InputCollection and OutputCollection:

// file: /src/core/IO/Collection.ts
export class InputCollection extends IOCollection<CollectionType.Input> {
  constructor(module: AnyModule) {
    super(CollectionType.Input, module);
  }
}

export class OutputCollection extends IOCollection<CollectionType.Output> {
  constructor(module: AnyModule) {
    super(CollectionType.Output, module);
  }
}

Base Module Integration

In the Base module constructor, we initialize Input/OutputCollection:

// file: /src/core/Module.ts
// function: constructor
this.inputs = new InputCollection(this);
this.outputs = new OutputCollection(this);

Then we want to provide an easy way to define audio inputs/outputs:

// file: /src/core/Module.ts
protected registerAudioInput(props: Omit<AudioInputProps, "ioType">) {
  this.inputs.add({ ...props, ioType: IOType.AudioInput });
}

protected registerAudioOutput(props: Omit<AudioOutputProps, "ioType">) {
  this.outputs.add({ ...props, ioType: IOType.AudioOutput });
}

We will see the usage of this as we also want to give the ability to define some common I/Os. The most common case is to define the module's main input and output AudioNode.

// file: /src/core/Module.ts
protected registerDefaultIOs(value: "both" | "in" | "out" = "both") {
  if (value === "in" || value === "both") {
    this.registerAudioInput({
      name: "in",
      getAudioNode: () => this.audioNode,
    });
  }

  if (value === "out" || value === "both") {
    this.registerAudioOutput({
      name: "out",
      getAudioNode: () => this.audioNode,
    });
  }
}

We also want to add information about the available I/Os to the serialize function:

// file: /src/core/Module.ts
interface IModuleSerialize<T extends ModuleType> extends IModule<T> {
  inputs: IIOSerialize[];
  outputs: IIOSerialize[];
}
// file: /src/core/Module.ts
serialize(): IModuleSerialize<T> {
  return {
    id: this.id,
    name: this.name,
    moduleType: this.moduleType,
    props: this.props,
    inputs: this.inputs.serialize(),
    outputs: this.outputs.serialize(),
  };
}

Define I/Os To Available Modules

Now we are ready to define the I/Os for the actual modules.

Volume

The volume module has both an input and an output, as we connect a signal to its input and take the adjusted signal from its output. So, we will define both input and output.

// file: /src/modules/Volume.ts
// function: constructor
this.registerDefaultIOs();

Master

The master module has only an input, which allows any module to reach our audio interface.

// file: /src/modules/Master.ts
// function: constructor
this.registerDefaultIOs("in");

Oscillator

Conversely, the oscillator has only an output, as the oscillator generates the signal.

// file: /src/modules/Oscillator.ts
// function: constructor

this.initializeGainDetune();
this.registerDefaultIOs("out");
this.registerInputs();

However, we will define an input for the detune AudioParam. With this setup, we allow the ability to automatically mutate the frequency. For example, we can connect another oscillator to detune.

The OscillatorNode.detune parameter changes the original frequency by one semitone for every 100 cents. We want to apply values from -1 to 1 to change the original frequency by one semitone. Therefore, we create an extra GainNode with a value of 100, connect it to audioNode.detune, and then expose the detuneGain instead of audioNode.detune.

// file: /src/modules/Oscillator.ts
private initializeGainDetune() {
  this.detuneGain = new GainNode(this.context, { gain: 100 });
  this.detuneGain.connect(this.audioNode.detune);
}

private registerInputs() {
  this.registerAudioInput({
    name: "detune",
    getAudioNode: () => this.detuneGain,
  });
}

Implement rePlug functionality

Unfortunately, we haven't finished yet with the oscillator. As we discussed in the previous post, every time we stop the oscillator, we have to generate a new OscillatorNode to start it again. This means we need to build a replug mechanism that will allow us to replug all the connected I/Os.

Oscillator

I'll start by explaining how I'd like to use the replug mechanism. We want a function that will accept a callback, so internally we will unplug all I/Os, run the desired code, and then replug the I/Os.

// file: /src/modules/Oscillator.ts
stop(time: number) {
  this.audioNode.stop(time);
  this.rePlugAll(() => {
    this.audioNode = new OscillatorNode(this.context, {
      type: this.props["wave"],
      frequency: this.props["frequency"],
    });
  });

  this.isStated = false;
}

Base Module

The implementation of the rePlugAll mechanism in the base module is simple, as we just delegate the functionality to the input/output collection.

// file: /src/core/Module.ts
protected rePlugAll(callback?: () => void) {
  this.inputs.rePlugAll(callback);
  this.outputs.rePlugAll(callback);
}

Collection

The collection again delegates the functionality to each I/O.

// file: /src/core/IO/Collection.ts
rePlugAll(callback?: () => void) {
  this.collection.forEach((io) => io.rePlugAll(callback));
}

Base I/O

Finally, we will implement the actual replug functionality in the Base I/O. We keep the connected I/Os in a variable, so we can unplug them, call the callback, and then replug all the connections.

// file: /src/core/IO/Base.ts
rePlugAll(callback?: () => void) {
  const connections = this.connections;
  this.unPlugAll();
  if (callback) callback();

  connections.forEach((otherIO) => this.plug(otherIO));
}

unPlugAll() {
  this.connections.forEach((otherIO) => this.unPlug(otherIO));
}

Routes

We have integrated our I/O system into the modules, so it's time to expose it to the engine. We will create a Routes class responsible for keeping route information and providing the API that will be exposed to the engine, allowing us to add and remove routes.

Implementation

We discussed in the beginning how a route data looks.

{
  id: string,
  source: { ioName: string, moduleId: string },
  destination: { ioName: string, moduleId: string }
}

This will be translated to a TypeScript interface like this:

// file: /src/core/Routes.ts
interface IPlug {
  moduleId: string;
  ioName: string;
}

interface IRoute {
  id: string;
  source: IPlug;
  destination: IPlug;
}

I'll provide the whole code at once as it is relatively simple. In the constructor of the class, we pass the engine so we can find the desired modules from it and, more specifically, the desired I/O of a module. We keep the information of the routes in an object where the key is the id of the route and the value is the IRoute. The only available public functions will be addRoute and removeRoute.

// file: /src/core/Routes.ts
export class Routes {
  engine: Engine;
  routes: { [key: string]: IRoute };

  constructor(engine: Engine) {
    this.engine = engine;
    this.routes = {};
  }

  addRoute(props: Optional<IRoute, "id">) {
    const id = props.id || uuidv4();
    this.routes[id] = { ...props, id };

    this.plug(id);
  }

  removeRoute(id: string) {
    this.unPlug(id);
    delete this.routes[id];
  }

  private plug(id: string) {
    const { sourceIO, destinationIO } = this.getIOs(id);
    sourceIO.plug(destinationIO);
  }

  private unPlug(id: string) {
    const { sourceIO, destinationIO } = this.getIOs(id);
    sourceIO.unPlug(destinationIO);
  }

  private find(id: string): IRoute {
    if (!this.routes[id]) throw Error(`Route with id ${id} not found`);

    return this.routes[id];
  }

  private getIOs(id: string) {
    const route = this.find(id);
    const { source, destination } = route;

    const sourceIO = this.engine.findIO(
      source.moduleId,
      source.ioName,
      "output",
    );
    const destinationIO = this.engine.findIO(
      destination.moduleId,
      destination.ioName,
      "input",
    );

    return { sourceIO, destinationIO };
  }
}

We also have to implement the findIO function as it doesn't exist yet.

// file: /src/Engine.ts
findIO(moduleId: string, ioName: string, type: "input" | "output") {
  const module = this.findModule(moduleId);
  return module[`${type}s`].findByName(ioName);
}

Expose to Engine

Finally, we need to integrate Routes into the engine and expose addRoute and removeRoute.

We start by defining the routes property in the engine:

// file: /src/Engine.ts
routes: Routes;

Initialize it in the constructor:

// file: /src/Engine.ts
// function: constructor
this.routes = new Routes(this);

And then expose the addRoute and removeRoute methods:

// file: /src/Engine.ts
addRoute(props: Optional<IRoute, "id">) {
  this.routes.addRoute(props);
}

remoteRoute(id: string) {
  this.routes.removeRoute(id);
}

Lets Have Some Fun!

I'll write a very simple example of code that creates a routing from an oscillator -> volume -> master. Additionally, I'll create an LFO, which is actually an oscillator, that will be connected to oscillator detune.

import { Engine, ModuleType } from "blibliki";

const context = new AudioContext();
const engine = new Engine(context);

const osc = engine.addModule({
  name: "osc",
  moduleType: ModuleType.Oscillator,
  props: { wave: "sine", frequency: 440 },
});

const lfo = engine.addModule({
  name: "osc",
  moduleType: ModuleType.Oscillator,
  props: { wave: "sine", frequency: 2 },
});

const vol = engine.addModule({
  name: "vol",
  moduleType: ModuleType.Volume,
  props: { volume: 0.01 },
});

const master = engine.addModule({
  name: "master",
  moduleType: ModuleType.Master,
  props: {},
});

engine.addRoute({
  source: { moduleId: osc.id, ioName: "out" },
  destination: { moduleId: vol.id, ioName: "in" },
});
engine.addRoute({
  source: { moduleId: lfo.id, ioName: "out" },
  destination: { moduleId: osc.id, ioName: "detune" },
});
engine.addRoute({
  source: { moduleId: vol.id, ioName: "out" },
  destination: { moduleId: master.id, ioName: "in" },
});

await engine.start();

Now, I'm challenging you to create a crazy patch, and believe it, it's possible even though we have very limited modules for now.

Before Close

If you made the effort to read this post, please leave a comment or contact me. It is really important to me to hear your feedback.

What's Next

In the next post, we will implement MIDI I/O, which will allow us to pass MIDI events to the modules that need them and handle these events based on the needs of each module.