Skip to content

SoundFont 2 Player ​

The SoundFont player makes your MIDI play back with real recorded instruments — a real piano, real drums — instead of synthesized tones. You give it a SoundFont file; it does the rest.

A SoundFont (SF2) is a single file that bundles those recorded instrument samples together with the rules for playing them: which sample to use at which pitch and velocity, how to loop it, and how envelopes, filters, and LFOs shape it. Think of it as a sample library in one file. libsonare ships a GS-compatible SF2 player that reads a SoundFont you supply and renders your MIDI tracks through it — offline via project bounce, or live through the realtime engine — with full 16-part multitimbral playback and GS extensions layered on top of the General MIDI baseline.

You must bring your own SF2 file

No SoundFont ships with the library — an SF2 is licensed instrument data, not code, and nothing is baked into the binaries. You fetch a .sf2 and hand its bytes to the player. If you have no SF2 (or a program your SF2 does not cover), playback does not go silent: it falls through to the built-in NativeSynth GM bank, the data-free floor.

Terms to know first

A preset is one selectable sound (a "program") in the SoundFont. A program change picks a preset on a MIDI channel. A bank groups 128 programs; GS uses extra banks for tonal variations, and inside an SF2 file drum kits live in bank 128, the SoundFont percussion bank. Channel 10 is the drum channel by General MIDI convention (index 9 in the API, which counts channels from 0; see Counting from 0 or from 1). How each standard reaches a drum kit is tabulated in GM and GS Fallback Bank.

Multitimbral means the player can sound several different instruments at once, one per MIDI channel (up to 16). GS is the General MIDI extension set defined by Roland — extra banks and per-part controls layered on top of plain General MIDI; a "GS-compatible" player honors those extras so GS-authored songs play as intended. The player here is an independent re-creation, not affiliated with or endorsed by any manufacturer.

How each note picks its sound ​

For every note, the player asks one question: does a zone in the loaded SoundFont cover this note's channel, bank, program, key and velocity? We call that the SoundFont covering the note. If it does, the note plays from the SF2 sample. If not — or if you loaded no SoundFont at all — the note falls through to the built-in NativeSynth General MIDI bank. Either way, the note makes sound: MIDI never renders silent here.

Sound resolution
coveredmissing / no SF2Note playedProgram inloaded SF2?SF2 sampleNativeSynthGM fallbackAudio out
Either path reaches the output — MIDI never renders silent here.

The program manifest below lets you see this decision ahead of time, per program.

The demo below uses built-in voices rather than a user-supplied SF2 file, but it shows the same MIDI idea the SoundFont player relies on: the notes stay fixed while the instrument that renders them changes. In your app, the selected instrument can be an SF2 preset when your SoundFont covers that program, or the NativeSynth fallback when it does not.

MIDI · PIANO ROLLIDLE
A MIDI passage — same notes, any instrument

A three-voice phrase — melody, chords, and bass — drawn as a piano roll. The notes never change; switch the instrument and the engine bounces the exact same MIDI through a different built-in voice. Drag the tempo and the whole sequence speeds up or slows down. Press play to hear the passage; the playhead tracks the audio.

Instrument
Tempo
100 BPM

Pick the right entry point ​

The player is exposed at two levels. Use the project level for offline rendering and the engine level for live playback.

Your situationUseWhy
Render a finished MIDI arrangement to audioProject.bounceWithSf2Instrument(...)Deterministic offline render of the whole project
Inspect coverage before renderingProject.soundFontManifest()Per-program sf2 vs synth backend report
Play a keyboard or live MIDI streamRealtimeEngine.setSf2Instrument(...)Binds the player to a realtime MIDI destination

One engine, every runtime

The same SF2 player is exposed through WASM/JS, Node native, and Python. Names follow each language's convention (loadSoundFont ↔ load_soundfont, bounceWithSf2Instrument ↔ bounce_with_sf2_instrument, soundFontManifest ↔ soundfont_manifest), while the parser, voice model, and GS behavior are identical. The CLI does not wire SF2 — use the Project API for SoundFont-backed bounces.

Load a caller-supplied SF2 ​

Fetch the .sf2 yourself and hand the player its raw bytes. The player makes its own copy during the call, so you can discard your buffer right after. A few things to know: loading a SoundFont replaces any previous one, and a rejected load throws while leaving the previously loaded SoundFont intact. A successful replacement silences any note still sounding from the old SoundFont, since its samples are released; notes played by the NativeSynth fallback keep going.

A broken file and a broken call are different errors

The load reports the two apart, so your handler can tell a user "this file is not usable" without also catching its own bugs:

  • SONARE_ERROR_INVALID_PARAMETER — the call is wrong: a NULL project or buffer, or a zero-length buffer. Nothing about the data was examined.
  • SONARE_ERROR_INVALID_FORMAT — the data is unusable: either the SF2 failed to parse, or the file is larger than the resource budget the loader accepts.

A sample rate outside 400 to 50000 Hz inside the file is not an error: it is clamped into that range and the file loads.

Bindings surface these as their own error type (a thrown Error in JS, SonareError in Python) carrying the message.

typescript
import { init, Project } from '@libraz/libsonare';

await init();

// You provide the file — e.g. fetched from your own asset host or picked by the user.
const sf2Bytes = new Uint8Array(await (await fetch('/instruments/my-bank.sf2')).arrayBuffer());

const project = new Project();
try {
  project.loadSoundFont(sf2Bytes);          // throws: unparseable or over the size budget
  project.soundFontPresetCount();           // e.g. 3 — presets in the loaded bank
  // ... build/edit the arrangement, then bounce (see below) ...
  project.clearSoundFont();                 // optional: release the loaded bank
} finally {
  project.delete();                          // the WASM handle is NOT garbage-collected
}
python
import libsonare as sonare

with open("instruments/my-bank.sf2", "rb") as f:
    sf2_bytes = f.read()

project = sonare.Project()
try:
    project.load_soundfont(sf2_bytes)        # raises SonareError: unparseable or oversized
    project.soundfont_preset_count()         # e.g. 3
    # ... build/edit the arrangement, then bounce (see below) ...
    project.clear_soundfont()                # optional: release the loaded bank
finally:
    project.close()                          # release the native handle

Always release the project

Project, like every embind object, holds a WASM heap handle that JavaScript's garbage collector cannot reclaim. Call project.delete() in a finally block (Python uses project.close()). A loaded SoundFont's sample pool is freed with the project, or earlier with clearSoundFont() / clear_soundfont().

Know what resolves: the program manifest ​

Before you render, ask the project which programs your arrangement actually plays and where each one resolves. soundFontManifest() enumerates every (channel, bank, program) a note plays through, in first-use order, and reports the backend:

  • 'sf2' — the loaded SoundFont covers every played note in the group (GS variation/drum fallbacks included), with the resolved presetName;
  • 'synth' — no preset covers the program, or at least one played note in the group used the NativeSynth GM fallback; notes the SoundFont covers still play from SF2. presetName is empty.

Without a loaded SoundFont, every entry is a synth fallback. This is your honest coverage report: a synth row means "this part will sound, but from the data-free floor, not your samples". The fallback is still program-aware: for example, GM program 6 (Harpsichord) uses the dedicated jack-and-plectrum harpsichord engine, while program 7 (Clavi) uses the plucked-string model.

typescript
project.loadSoundFont(sf2Bytes);
for (const p of project.soundFontManifest()) {
  // { channel, bank, program, backend: 'sf2' | 'synth', presetName }
  console.log(`ch${p.channel} bank${p.bank} prog${p.program} -> ${p.backend} ${p.presetName}`);
}
// e.g. ch0 bank0 prog0 -> sf2 Piano 1
python
project.load_soundfont(sf2_bytes)
for p in project.soundfont_manifest():
    # Sf2ProgramStatus(channel, bank, program, backend, preset_name)
    print(f"ch{p.channel} bank{p.bank} prog{p.program} -> {p.backend} {p.preset_name}")

Drum channels report bank 128

A manifest row for channel 9 (MIDI channel 10, 1-based) reports bank: 128, the SoundFont percussion bank. A covered kit resolves to sf2; otherwise the GM drum map from the fallback plays.

Bouncing MIDI through SF2 instruments ​

bounceWithSf2Instrument(...) compiles and renders the whole project, driving each bound MIDI destination through a GS-compatible SF2 player fed by the loaded SoundFont. It mirrors bounceWithBuiltinInstrument but with sampled sounds. The render is deterministic: the same project, options, SoundFont, and patch produce bit-identical audio.

You bind a player to a MIDI destination id (the value you set with setTrackMidiDestination). The patch is an Sf2InstrumentConfig — every field is optional, so {} is a usable default.

typescript
import { init, Project } from '@libraz/libsonare';

await init();

const project = new Project();
try {
  project.setSampleRate(48000);

  // A one-note MIDI clip routed to destination 0.
  const { trackId, clipId } = project.addMidiClip(0, 4);
  project.setTrackMidiDestination(trackId, 0);
  project.setMidiEvents(clipId, [
    Project.midiNoteOn(0, 0, 0, 60, 100),   // ppq, group, channel, note, velocity
    Project.midiNoteOff(2, 0, 0, 60, 0),
  ]);

  project.loadSoundFont(sf2Bytes);

  // Bind a default SF2 player to destination 0, render 4096 frames of stereo.
  const audio = project.bounceWithSf2Instrument(
    { destinationId: 0, gain: 1 },
    { totalFrames: 4096, numChannels: 2, sampleRate: 48000 },
  );
  // audio is interleaved Float32Array (frames * channels)
} finally {
  project.delete();
}
python
import libsonare as sonare

project = sonare.Project()
try:
    project.set_sample_rate(48000.0)

    track, clip = project.add_midi_clip(0.0, 4.0)
    project.set_track_midi_destination(track, 0)
    project.set_midi_events(clip, [
        sonare.Project.midi_note_on(0.0, 0, 0, 60, 100),
        sonare.Project.midi_note_off(2.0, 0, 0, 60, 0),
    ])

    project.load_soundfont(sf2_bytes)

    audio = project.bounce_with_sf2_instrument(
        sonare.Sf2InstrumentConfig(gain=1.0),
        destination_id=0,
        total_frames=4096, num_channels=2, sample_rate=48000,
    )
    # audio is a (frames, channels) float32 ndarray
finally:
    project.close()

Empty bindings render silence

Passing an explicitly empty array [] (rather than a patch or undefined) binds zero instruments, so MIDI tracks render silent. To bind multiple destinations, pass an array of patches each carrying its own destinationId.

MIDI never renders silent for lack of data

You can bounce with no SoundFont loaded at all — bound destinations still sound, because uncovered programs play through the NativeSynth GM fallback. The manifest tells you exactly which parts will use samples and which will use the fallback. See NativeSynth for the fallback engine.

That fallback is broad enough for practical previews: pianos, strings, winds, voices and synth programs each run on the closest engine, so missing programs do not collapse to one generic tone. GM Tone Map lists the engine for every program. It is still a fallback, not a replacement for a carefully chosen SF2.

Instrument config and the voice model ​

Sf2InstrumentConfig is the per-player patch. Every field is optional; a non-positive or omitted numeric field takes the C-ABI default. In the JS bindings the config also carries destinationId (default 0), the MIDI destination the player answers to; in Python that is the separate destination_id argument.

Field (JS / Python)MeaningDefault
gainMaster output gain, linear0.5
polyphonyMax simultaneous voices, clamped to [1, 64]48
preferModelForModeledFamilies / prefer_model_for_modeled_familiesRoute covered melodic GM programs to the dedicated physical model instead of the SoundFontfalse
clearBankRig / clear_bank_rigRemove the bank's default part rig, including the guitar amplifier and cabinet, for a direct renderfalse
gsEfxRealization / gs_efx_realizationRealise GS insertion effects with the modern or classic pathmodern

When the player runs out of voices it uses deterministic voice stealing, so a dense passage degrades the same way on every render.

Turn preferModelForModeledFamilies on when your SoundFont's samples for a family are weaker than the built-in model for it — a thin plucked-string or reed set, say. Drums stay SoundFont-first either way, so a good kit is never traded away for the modeled percussion voice. Program 7 (Clavi) is among the programs it routes to the built-in string model.

clearBankRig is a whole-player option for a direct comparison. For per-part control, use the project or engine part-rig API described in GM and GS: bank keeps the current bank rig, none selects the direct voice, and chain supplies 1–8 inserts. A GS EFX assignment runs after the part rig, so routing a part into an EFX unit does not remove its default amplifier or cabinet.

What the player implements ​

The player is a faithful SF2 synthesis core with a GS architecture layer on top (an independent re-creation, not affiliated with or endorsed by any manufacturer). You do not call these features directly — they respond to the MIDI events, CCs, NRPNs, and SysEx in your arrangement — but knowing what is honored explains why a part sounds the way it does.

SoundFont engine terms (you don't call these directly)

TVF (Time-Variant Filter) is the per-note filter and TVA (Time-Variant Amplifier) is its volume envelope. NRPN and SysEx are MIDI messages for extra or vendor-specific parameters. An exclusive class is the rule that one drum cuts off another — an open hi-hat is silenced the instant the closed hi-hat plays.

SF2 synthesis semantics ​

  • Preset / instrument zone layering — a note resolves through the SF2 two-level zone structure (preset zones over instrument zones), so layered and split presets play correctly. Generators set sample selection, tuning, loop mode, and exclusive classes (e.g. a closed hi-hat cutting an open one).
  • DAHDSR envelopes — separate volume and modulation envelopes with Delay/Attack/Hold/Decay/Sustain/Release stages. Key-scaled hold and decay times follow the file's direction around middle C on both envelopes.
  • LFOs — a vibrato LFO and a modulation LFO change pitch/filter/amplitude per the SF2 generators. The modulation LFO's volume modulation swings both ways by the programmed depth.
  • Fixed velocity — a zone that fixes its velocity plays at that velocity whatever the key press.
  • Low-pass filter with velocity tracking — initial cutoff and resonance, with velocity influencing brightness.
  • The SF2 default modulator set — velocity, CC7 (channel volume), and CC11 (expression) apply a square-law gain; CC1 (modulation wheel) changes vibrato depth; CC91 (reverb send) and CC93 (chorus send) feed the system-effects sends. (A CC is one of MIDI's continuous "knob" control-change messages — see MIDI Input.)
  • GS delay send — CC94 feeds the GS delay unit. It is a GS addition rather than part of the SF2 default set, and SF2 has no zone generator for it (see the GS architecture layer).
  • Pitch bend — honored, with the bend range set by RPN 0 (entered via Data Entry / RPN), so a part can request its own semitone range.
  • Part rigs and GS EFX — the bank's default rig, a direct output, or a host-supplied insert chain runs on each part before a routed GS EFX unit. Programs 29 and 30 put their overdrive or distortion pedal before the amplifier stage, so their fallback audio includes the pedal and amplifier in series.

The GS side of the player — the variation-bank and drum-kit architecture, the NRPN and SysEx part edits, the send-return system effects, and the sixteen insertion-effect units (EFX) — is documented with the rest of the GS material on GM and GS Fallback Bank.

Live engine playback ​

For a keyboard or a live MIDI stream, bind the SF2 player to a realtime MIDI destination with setSf2Instrument(...). The engine routes live note/CC commands and scheduled MIDI clips for that destination through the player, with the same 16-channel, channel-10-drum, GS-NRPN, and GS/GM-SysEx behavior. Load the SoundFont into the engine first (a separate load from the project's).

typescript
import { init, RealtimeEngine } from '@libraz/libsonare';

await init();

const engine = new RealtimeEngine(48000, 128);   // sampleRate, maxBlockSize
try {
  engine.loadSoundFont(sf2Bytes);                // engine-level load
  engine.setSf2Instrument({ gain: 1 }, 7);       // bind player to destination 7
  engine.midiInstrumentCount();                  // 1

  // Feed a live note, then render a block.
  engine.pushMidiNoteOn(7, 0, 0, 60, 100);       // destinationId, group, channel, note, velocity
  const [left, right] = engine.process([new Float32Array(128), new Float32Array(128)]);

  engine.pushMidiProgram(7, 0, 0, 40);           // destinationId, group, channel, program (optional bank and renderFrame follow)
  engine.clearMidiInstrument(7);                 // unbind
} finally {
  engine.destroy();
}
python
import libsonare as sonare

engine = sonare.RealtimeEngine(sample_rate=48000.0, max_block_size=128)
try:
    engine.load_soundfont(sf2_bytes)                    # engine-level load
    engine.set_sf2_instrument(sonare.Sf2InstrumentConfig(gain=1.0), destination_id=7)
    engine.midi_instrument_count()                      # 1

    engine.push_midi_note_on(7, 0, 0, 60, 100)          # destination, group, channel, note, velocity
    out = engine.process([[0.0] * 128, [0.0] * 128])

    engine.clear_midi_instrument(7)                     # unbind
finally:
    engine.close()

Calling setSf2Instrument or loadSoundFont again while the transport rolls replaces what was bound: the outgoing instrument fades over 10 ms, and the new one starts from default channel state (Realtime Engine has the details).

pushMidiProgram queues a live program change, with an optional bank, timed like a note or CC. It is packed as one MIDI 2.0 Program Change, so the receiver's own gates apply: a part with RX PROGRAM CHANGE off accepts it and ignores it (the GS per-part receive switches RX PROGRAM CHANGE and RX BANK SELECT tell a part to ignore program or bank messages). The SoundFont player follows it unless those switches are off; a NativeSynth bound with useGmPrograms: true and a NativeSynth playing the GM kit (the drum-kit preset, or a percussion patch with gmKit on, where the program picks the GS drum set) follow it too. The built-in synth and any other NativeSynth patch accept it and ignore it.

Binding before loading is allowed

You can call setSf2Instrument(...) before any SoundFont is loaded — live MIDI then plays through the NativeSynth GM fallback. Load a SoundFont later and bound destinations begin using its samples. See MIDI Input for wiring live keyboards and Web MIDI.

Recipes ​

Inspect coverage, then bounce

Load your SF2, check the manifest for any synth rows (parts that will use the fallback), then render.

typescript
project.loadSoundFont(sf2Bytes);
const uncovered = project.soundFontManifest().filter((p) => p.backend === 'synth');
if (uncovered.length) {
  console.warn('Falling back to NativeSynth for:', uncovered);
}
const audio = project.bounceWithSf2Instrument(
  { destinationId: 0, gain: 1 },
  { totalFrames: 4096, numChannels: 2, sampleRate: 48000 },
);
python
project.load_soundfont(sf2_bytes)
uncovered = [p for p in project.soundfont_manifest() if p.backend == "synth"]
if uncovered:
    print("Falling back to NativeSynth for:", uncovered)
audio = project.bounce_with_sf2_instrument(
    sonare.Sf2InstrumentConfig(gain=1.0),
    total_frames=4096, num_channels=2, sample_rate=48000,
)
Multitimbral: melody + drums in one bounce

Route a melodic track to one destination and a drum track (channel 10, bank 128) to another, then bind a player to each.

typescript
// Drum part: bank 128 program 0 on channel 10 (index 9).
project.setMidiEvents(drumClipId, [
  ...Project.midiBankProgram(0, 0, 9, 0, 0, 0),
  Project.midiNoteOn(0, 0, 9, 36, 110),   // 36 = Bass Drum 1
  Project.midiNoteOff(1, 0, 9, 36, 0),
]);

const audio = project.bounceWithSf2Instrument(
  [{ destinationId: 0, gain: 1 }, { destinationId: 1, gain: 1 }],
  { totalFrames: 8192, numChannels: 2, sampleRate: 48000 },
);
python
# Drum part: bank 128 program 0 on channel 10 (index 9).
project.set_midi_events(drum_clip, [
    *sonare.Project.midi_bank_program(0, 0, 9, 0, 0, 0),
    sonare.Project.midi_note_on(0, 0, 9, 36, 110),   # 36 = Bass Drum 1
    sonare.Project.midi_note_off(1, 0, 9, 36, 0),
])

cfg = sonare.Sf2InstrumentConfig(gain=1.0)
audio = project.bounce_with_sf2_instrument(
    instruments=[(0, cfg), (1, cfg)],
    total_frames=8192, num_channels=2, sample_rate=48000,
)
Render even without an SF2

With no SoundFont loaded, the bounce still sounds via the NativeSynth GM fallback — useful for a quick preview before you ship instrument data.

typescript
const preview = project.bounceWithSf2Instrument(
  {},   // default patch; nothing loaded -> GM fallback for every program
  { totalFrames: 4096, numChannels: 2, sampleRate: 48000 },
);

NativeSynth and the SoundFont fallback ​

NativeSynth is the safety net under the player. Each note renders from the SF2 when a zone in the loaded SoundFont covers it (GS variation and drum fallbacks included), and otherwise — including when no SoundFont is loaded — from the NativeSynth GM fallback bank: all 128 programs plus the drum map. Because that bank is always present, MIDI never renders silent for lack of data.

soundFontManifest() reports the backend per program in first-use order, as shown above. Which engine voices each fallback program is on GM Tone Map; Bank Select on a capital program with variations is covered under GS variation tones. The routing is separate from the named preset catalog: synthPresetNames() lists the hand-authored presets (e-piano, harp, drum-kit, and so on), while the fallback bank chooses an internal patch for each program number.

  • NativeSynth — the data-free GM fallback engine that keeps MIDI from going silent
  • GM and GS Fallback Bank — the GS architecture layer and insertion effects this player implements, and the fallback bank under them
  • Project Bounce — offline rendering of a project, including the builtin/synth bounce siblings
  • MIDI Input — live keyboards, Web MIDI, and realtime engine routing
  • Project Editing — building the MIDI tracks and clips you render
  • Recording and Takes — capturing performances into the project
  • Link targets — driving the player from C++; note that BUILD_FX=OFF compiles the GS send effects out and the player then renders dry