Web MIDI
This tutorial explains the basics of controlling an Elementary Audio app with MIDI. We will use the Web MIDI API (opens in a new tab) in this tutorial, but the approach to controlling Elementary through MIDI could be adapted to MIDI in a plugin or another MIDI source.
The code discussed in this tutorial is available in the MIT-licensed elementary-webmidi-demo (opens in a new tab) repository.
Playing notes
We will create a simple web app that plays notes sent by a MIDI controller. The app will:
- Detect when a MIDI device is connected and disconnected
- Provide an interface for selecting a MIDI device
- Display the last played MIDI note and its associated frequency
The app uses vanilla JavaScript and no framework to make it broadly accessible. Keep in mind this is a toy application intended to convey ideas. Your app will likely use a framework, stronger types, defensive programming, and other niceties.
At a high level, the app has three parts:
- A Web MIDI module for interfacing with devices
- Audio engine and synth modules for generating audio graphs and rendering them
- An event emitter that sends note events from Web MIDI to the audio implementation
The web-renderer (opens in a new tab) ultimately renders audio using the Web Audio API (opens in a new tab) because we are working in the browser.
Control
Our app uses WEBMIDI.js (opens in a new tab) to access the Web MIDI API. This library abstracts away many low-level Web MIDI details and makes detecting devices and capturing note events easy.
Our midi module (opens in a new tab) includes a noteEmitter
to send note events and selectedInput
to track the active MIDI controller. We set the selectedInput
to null
until a device is selected.
import { WebMidi } from "webmidi";
export class Midi {
noteEmitter;
selectedInput;
constructor(noteEmitter) {
this.noteEmitter = noteEmitter;
this.selectedInput = null;
}
// Class methods...
}
Accessing MIDI devices requires an async call and must be made in a secure context (localhost
or a site with properly configured https
). In addition, some browsers will prompt users for permission when a site first requests Web MIDI access.
Our Midi
class has an initialize
method that enables Web MIDI and sets up event handlers to update available MIDI devices.
async initialize(displayControllers) {
try {
await WebMidi.enable();
displayControllers(this.#getInputNames(), this.selectedInput?.name);
WebMidi.addListener("connected", () => {
displayControllers(this.#getInputNames(), this.selectedInput?.name);
});
WebMidi.addListener("disconnected", () => {
displayControllers(this.#getInputNames(), this.selectedInput?.name);
});
} catch (err) {
console.error("WebMidi could not be initialized:", err);
}
}
#getInputNames
is a private helper function to get MIDI device names.
#getInputNames() {
return WebMidi.inputs.map((input) => input.name);
}
We won't cover the details of displayControllers
or any UI functions in this tutorial. Check the comments in the code for more information.
Lastly, the Midi
class has a setController
method to set the active controller and connect it to the noteEmitter
.
setController(controller) {
// Stop any active notes
this.noteEmitter.emit("stopAll");
// Remove listeners from the previous input
if (this.selectedInput) {
this.selectedInput.removeListener("noteon");
this.selectedInput.removeListener("noteoff");
}
// Set the new input
this.selectedInput = WebMidi.getInputByName(controller);
// Add note on listener
this.selectedInput.addListener("noteon", (event) => {
const midiNote = event.note.number;
this.noteEmitter.emit("play", { midiNote });
});
// Add note off listener
this.selectedInput.addListener("noteoff", (event) => {
const midiNote = event.note.number;
this.noteEmitter.emit("stop", { midiNote });
});
}
The noteEmitter
sends note events that our audio implementation will receive. The events include:
play
. Play a note.stop
. Stop a note.stopAll
. Stop all notes.
When a new controller is selected, we stop all notes and remove event listeners from the previously selected input. Then, we look up the new controller by name and set it as the selected input. Lastly, we add noteon
and noteoff
event listeners to the selected input that will emit play
and stop
note events.
Audio
The audio implementation includes an audio engine and a synth. The audio engine initializes an Elementary web renderer and connects it to a Web Audio context. The synth creates audio graphs representing our notes and passes them to the audio engine for rendering.
Engine
The engine module (opens in a new tab) includes a core
web renderer and a Web audio context
.
import WebRenderer from "@elemaudio/web-renderer";
export class Engine {
context;
core;
constructor() {
this.core = new WebRenderer();
this.context = new AudioContext();
this.context.suspend();
}
// Class methods...
}
Web Audio requires a user interaction before we can produce any sound, so we initially suspend the audio context.
The Engine
class has an initialize
method that we will call on user interaction.
async initialize() {
// Start the audio context
this.context.resume();
// Initialize web renderer
const node = await this.core.initialize(this.context, {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [1],
});
// Connect web renderer to audio context
node.connect(this.context.destination);
}
initialize
resumes the audio context, initializes the WebRenderer
, and connects it to the Web Audio context destination. The destination is conceptually your speakers, but it can be any device your browser connects to.
We configure the WebRenderer with zero inputs, one output, and one output channel. Our app needs no inputs and outputs a mono signal.
The Engine
class also has a render
method that renders an audio graph with an ElemNode
type.
render(node) {
if (this.context.state === "running") {
this.core.render(node);
}
}
We check if the audio context is running and render the audio graph if so.
Synth
The synth module (opens in a new tab) contains a few helper functions to create and sum polyphonic synth voices and generate silence.
The synthVoice
function creates an ElemNode
with a single voice.
function synthVoice(voice) {
return el.mul(
el.const({ key: `${voice.key}:gate`, value: voice.gate }),
el.blepsaw(el.const({ key: `${voice.key}:freq`, value: voice.freq })),
);
}
The voice is an el.blepsaw
with a gate to control whether the voice is on or off. We key (opens in a new tab) both parts to help Elementary Audio optimize rendering.
The synth
function sums multiple synth voices into a single audio graph and decreases the overall amplitude to a reasonable level.
function synth(voices) {
return el.mul(el.add(...voices.map((voice) => synthVoice(voice))), 0.1);
}
The silence
function generates silence when no notes are held on the selected controller.
function silence() {
return el.const({ key: "silence", value: 0 });
}
The Synth
class uses these functions to generate audio graphs with up to eight voices. The next section will connect it to our note emitter to play and stop notes.
Our Synth
class includes an array of voices
representing held notes on the selected MIDI controller.
import { el } from "@elemaudio/core";
export class Synth {
voices = [];
// Class methods...
}
When a play
note event is received, the playNote
method generates a key, computes the frequency for the note, and updates the voices.
playNote(midiNote) {
const key = `v${midiNote}`;
const freq = computeFrequency(midiNote);
// Add note to voices after removing previous instances.
this.voices = this.voices
.filter((voice) => voice.key !== key)
.concat({ gate: 1, freq, key })
.slice(-8);
return synth(this.voices);
}
Any previous instances of the note are filtered out, the new voice is added with an "on" gate, and we drop any notes exceeding eight-note polyphony.
The computeFrequency
function computes frequency in 12-tone equal temperament tuned to 440Hz with a base MIDI note of 69
.
export function computeFrequency(midiNote) {
return 440 * 2 ** ((midiNote - 69) / 12);
}
The stopNote
method drops the voice associated with a note when receiving a stop
note event.
stopNote(midiNote) {
const key = `v${midiNote}`;
this.voices = this.voices.filter((voice) => voice.key !== key);
if (this.voices.length > 0) {
return synth(this.voices);
} else {
return silence();
}
}
We return silence
if the voice was the last active voice.
Lastly, the stopAllNotes
method removes all voices and returns silence.
stopAllNotes() {
this.voices = [];
return silence();
}
Wiring it all up
We now have all the pieces we need. The Midi
implementation sends note events that the Synth
uses to generate audio graphs, and the Engine
renders them..
The main module (opens in a new tab) imports and instantiates our implementations.
import Emittery from "emittery";
import "./style.css";
import * as ui from "./ui";
import { Engine } from "./audio/engine";
import { Midi } from "./midi";
import { Synth, computeFrequency } from "./audio/synth";
const noteEmitter = new Emittery();
const engine = new Engine();
const midi = new Midi(noteEmitter);
const synth = new Synth();
We pass the noteEmitter
to the Midi
implementation.
Next, we set up note event listeners.
// Play note and update indicators
noteEmitter.on("play", ({ midiNote }) => {
engine.render(synth.playNote(midiNote));
ui.setMIDINote(midiNote);
ui.setFrequency(computeFrequency(midiNote));
});
// Stop note
noteEmitter.on("stop", ({ midiNote }) => {
engine.render(synth.stopNote(midiNote));
});
// Stop all notes
noteEmitter.on("stopAll", () => {
engine.render(synth.stopAllNotes());
});
On play
, stop
, and stopAll
events, the synth generates an audio graph, and the engine renders it.
We mentioned earlier that we must initialize our Midi
and Engine
implementations on user interaction. The getStarted
function does both when a user clicks a "Get Started" button.
async function getStarted() {
await midi.initialize(displayControllers);
await engine.initialize();
ui.getStarted();
}
One final detail and our app is complete. When a user selects a controller in the UI, we call the Midi.setController
method.
function setController(controller) {
midi.setController(controller);
ui.selectController(controller);
}
And that's it! We have a simple app that uses Web MIDI to control Elementary Audio.
We have a few suggested exercises if you want to explore controllers in the browser further.
- Add a gain control to the synth and update it with MIDI CC
- Update the app to render stereo, add a pan control, and update it with MIDI CC
- Add a
keyboard
controller that takes computer keyboard events to play notes