Skip to main content

MIDI Playback

Waveform Playlist supports MIDI file loading and playback in two flavors:

  • React — the @waveform-playlist/midi package provides the useMidiTracks hook, which integrates with WaveformPlaylistProvider.
  • Web Components — <daw-editor> from @dawcore/components exposes an editor.loadMidi(source, options) method, backed by the optional @dawcore/midi peer dep.

Both paths use the same framework-agnostic parser (@dawcore/midi) under the hood — the React package re-exports it. MIDI tracks render as piano roll visualizations and play back using SoundFont samples or Tone.js PolySynth synthesis.

React​

Installation​

npm install @waveform-playlist/midi @tonejs/midi

@tonejs/midi and @dawcore/midi are regular dependencies and will be installed automatically. The package is separate from the core library so users who only need audio don't pay the bundle cost (~8-12 KB gzipped).

Basic Usage​

Use the useMidiTracks hook to load .mid files:

import { useMidiTracks } from '@waveform-playlist/midi';
import { WaveformPlaylistProvider, Waveform, PlayButton, StopButton } from '@waveform-playlist/browser';

function MidiPlayer() {
const { tracks, loading, error } = useMidiTracks(
[{ src: '/music/song.mid' }],
{ sampleRate: 48000 },
);

if (loading) return <div>Loading MIDI...</div>;
if (error) return <div>Error: {error}</div>;

return (
<WaveformPlaylistProvider tracks={tracks} controls={{ show: true, width: 200 }}>
<PlayButton /> <StopButton />
<Waveform />
</WaveformPlaylistProvider>
);
}

configs is compared by value, so the array can be written inline as above: tracks reload only when a config actually changes. Several files are fetched in parallel and the tracks keep the order of the configs.

A single .mid file typically produces multiple ClipTrack objects — one per MIDI channel (e.g., Piano, Bass, Drums). All tracks are returned at once after loading completes.

MidiTrackConfig Options​

interface MidiTrackConfig {
src?: string; // URL to .mid file
midiNotes?: MidiNoteData[]; // Pre-parsed notes (skip fetch+parse)
name?: string; // Track display name
muted?: boolean;
soloed?: boolean;
volume?: number; // Default: 1.0
pan?: number; // Default: 0
color?: string;
startTime?: number; // Clip position in seconds (default: 0)
duration?: number; // Override clip duration in seconds
flatten?: boolean; // Merge all MIDI tracks into one (default: false)
}

interface UseMidiTracksOptions {
sampleRate: number; // Required — pass AudioContext.sampleRate
}

MIDI Tracks​

Every track useMidiTracks returns is a MIDI track (kind: 'midi'). A track holds one kind of clip: MIDI clips go on MIDI tracks and audio clips on audio tracks, so put a .mid part and an audio stem on separate tracks. A MIDI track is always drawn as a piano roll.

If you build a MIDI track yourself, set the kind:

import { createTrack } from '@waveform-playlist/core';

const keys = createTrack({ name: 'Keys', kind: 'midi', clips: [midiClip] });

A track whose clips do not match its kind is rejected. The provider reports it through its onError prop (an error boundary does not see it) and the playlist stays in its loading state, so pass onError.

Pre-Parsed Notes​

If you already have note data (e.g., from a custom parser or algorithm), skip the fetch step:

const { tracks } = useMidiTracks(
[
{
midiNotes: [
{ midi: 60, name: 'C4', time: 0, duration: 0.5, velocity: 0.8 },
{ midi: 64, name: 'E4', time: 0.5, duration: 0.5, velocity: 0.7 },
],
name: 'Melody',
duration: 4,
},
],
{ sampleRate: 48000 },
);

Flatten Mode​

By default, each MIDI channel becomes a separate track. Use flatten: true to merge all channels into one visual track:

const { tracks } = useMidiTracks(
[{ src: '/music/song.mid', flatten: true }],
{ sampleRate: 48000 },
);

SoundFont Playback​

For realistic instrument sounds, load a SoundFont file and pass the cache to the provider:

import { SoundFontCache } from '@waveform-playlist/playout';

// Load the SoundFont (do this once, e.g., in a hook or effect).
// fromUrl resolves only after the file is fetched and parsed.
const cache = await SoundFontCache.fromUrl('/soundfonts/piano.sf2');

// Pass to provider — MIDI tracks automatically use the SoundFont samples
<WaveformPlaylistProvider
tracks={tracks}
soundFontCache={cache}
>
<Waveform />
</WaveformPlaylistProvider>

Without a soundFontCache, MIDI tracks fall back to Tone.js PolySynth synthesis. SoundFont playback uses the midiProgram field on each clip to select the correct instrument samples.

Loading the SoundFont late​

The SoundFont decision is made per-track when tracks are set up. If the .sf2 file finishes downloading after the playlist mounted, just pass the cache to the provider when it's ready — the provider forwards it to the live adapter, which upgrades MIDI tracks from PolySynth to samples in place:

const [cache, setCache] = useState<SoundFontCache | undefined>(undefined);

useEffect(() => {
SoundFontCache.fromUrl('/media/soundfont/A320U.sf2')
.then(setCache)
.catch((err) => {
console.error('SoundFont failed to load — MIDI stays on PolySynth:', err);
});
}, []);

<WaveformPlaylistProvider soundFontCache={cache} tracks={tracks} />

With fromUrl an unloaded cache never reaches your state. The provider reacts to the prop's reference changing — passing the cache eagerly and calling load() on the same object later won't re-trigger the upgrade (you'd see a "SoundFont not loaded" console warning instead).

For non-React consumers (e.g. <daw-editor> web components), the same capability is adapter.setSoundFontCache(cache) on the adapter returned by createToneAdapter().

Mixing MIDI and Audio Tracks​

MIDI and audio tracks can be played together. Both useMidiTracks and useAudioTracks return ClipTrack[], so merge them into a single array:

import { useMidiTracks } from '@waveform-playlist/midi';
import { WaveformPlaylistProvider, Waveform } from '@waveform-playlist/browser';
import { useAudioTracks } from '@waveform-playlist/browser/tone';

function MixedPlayer() {
const { tracks: midiTracks, loading: midiLoading } = useMidiTracks(
[{ src: '/music/song.mid' }],
{ sampleRate: 48000 },
);

const { tracks: audioTracks, loading: audioLoading } = useAudioTracks([
{ src: '/audio/vocals.mp3', name: 'Vocals' },
]);

if (midiLoading || audioLoading) return <div>Loading...</div>;

const allTracks = [...midiTracks, ...audioTracks];

return (
<WaveformPlaylistProvider tracks={allTracks}>
<Waveform />
</WaveformPlaylistProvider>
);
}

Web Components​

For consumers of <daw-editor> (the dawcore Web Components layer), call editor.loadMidi(source, options). This creates one <daw-track> element per note-bearing MIDI track, each a MIDI track (kind="midi"), drawn as a piano roll.

Installation​

npm install @dawcore/components @dawcore/midi

@dawcore/midi is declared as an optional peer dep on @dawcore/components — install it only if you want to use editor.loadMidi. The editor dynamic-imports it on first call.

Basic Usage​

<daw-editor id="editor"></daw-editor>

<script type="module">
import '@dawcore/components';
import { createToneAdapter } from '@waveform-playlist/playout';

const editor = document.getElementById('editor');
editor.adapter = createToneAdapter({ ppqn: 960 });

const result = await editor.loadMidi('/music/song.mid');
console.log('Loaded', result.trackIds.length, 'tracks at', result.bpm, 'bpm');

// Apply tempo / time signature from the file (caller decides — loadMidi
// never mutates editor state implicitly)
editor.bpm = result.bpm;
editor.timeSignature = result.timeSignature;
</script>

API​

editor.loadMidi(source: string | File, options?: MidiLoadOptions): Promise<MidiLoadResult>;

interface MidiLoadOptions {
startTime?: number; // Timeline position in seconds (default 0)
signal?: AbortSignal; // Forwarded to fetch only — see "AbortSignal scope" below
}

interface MidiLoadResult {
readonly trackIds: readonly string[];
readonly bpm: number;
readonly timeSignature: readonly [number, number];
readonly duration: number;
readonly name: string;
}

File-Picker Loading​

source accepts either a URL string or a File object — same return shape:

<input id="picker" type="file" accept=".mid,.midi" />
<script type="module">
document.getElementById('picker').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) await editor.loadMidi(file);
});
</script>

Cleanup-on-Failure​

If any per-track creation fails, every <daw-track> appended during the call is removed — including elements that addTrack left in the DOM before its promise rejected. The editor returns to its pre-call state. Don't worry about orphan tracks on partial failure.

AbortSignal Scope​

options.signal is forwarded to fetch() for URL sources only. Aborting after parsing has finished does NOT cancel the in-flight addTrack calls (a documented v1 limitation). Cancellation typically surfaces as a DOMException named AbortError — distinguish it from a real failure:

try {
await editor.loadMidi(url, { signal: controller.signal });
} catch (err) {
if (err.name === 'AbortError') return; // user cancelled — not an error
console.error('loadMidi failed', err);
}

Install Hint​

If @dawcore/midi isn't installed, loadMidi rejects with a friendly install hint (and console.warns the original module-resolution error so debugging isn't blocked when the failure is something other than "not installed" — broken exports map, 404 chunk, CSP block, etc.).

Pure Parsing (No React, No DOM)​

The parseMidiFile function works without React or web components — useful for Node.js scripts, build-time tooling, or non-React apps. It lives in @dawcore/midi (re-exported from @waveform-playlist/midi).

import { parseMidiFile } from '@dawcore/midi';
// or, equivalently from the React package re-export:
// import { parseMidiFile } from '@waveform-playlist/midi';

const response = await fetch('/music/song.mid');
const buffer = await response.arrayBuffer();
const parsed = parseMidiFile(buffer);

console.log(parsed.name); // Song name
console.log(parsed.bpm); // Tempo (first tempo only for multi-tempo files)
console.log(parsed.tracks.length); // Number of tracks

for (const track of parsed.tracks) {
console.log(track.name, track.notes.length, 'notes');
}

There's also parseMidiUrl for a fetch+parse convenience:

import { parseMidiUrl } from '@dawcore/midi';

const parsed = await parseMidiUrl('/music/song.mid');

Track Naming​

Track names are derived from the MIDI file content:

  • Channel 9 → "Drums" (GM percussion convention)
  • Program > 0 → Title-cased GM instrument name (e.g., "Electric Bass (Finger)")
  • Program 0 → Track name from the MIDI file, or "Acoustic Grand Piano", or "Channel N"

When loading user-dropped files with a config name, individual track names from the MIDI file are used directly (not prefixed with the config name).