@gwegash/sf2-player
v0.1.0
Published
Sample-accurate SoundFont 2 (SF2) player for the Web Audio API
Maintainers
Readme
sf2-player
A sample-accurate SoundFont 2 player for the Web Audio API, in TypeScript.
It parses a .sf2 file, exposes the instruments inside it, and turns note events
into Web Audio nodes. Notes are scheduled on the audio clock, not with
setTimeout, so timing is exact to the frame.
- No dependencies, no
AudioWorklet, noeval. PlainAudioBufferSourceNodes andAudioParamautomation. - Ships as an ES module with type declarations, bundled with esbuild.
- Works with any
BaseAudioContext, includingOfflineAudioContextfor rendering faster than real time.
Install
npm install @gwegash/sf2-playerUse
The whole API is one factory function and the object it returns.
import { createSF2Player } from '@gwegash/sf2-player'
const context = new AudioContext()
const player = await createSF2Player(context, '/fonts/piano.sf2')
player.output.connect(context.destination)
player.selectProgram(0)
// Schedule against the audio clock: both notes land on an exact frame.
player.noteOn(60, 100, context.currentTime + 0.5)
player.noteOff(60, context.currentTime + 1.5)createSF2Player accepts a URL string, a URL, an ArrayBuffer, or any typed
array — so a file from an <input type="file"> works directly:
const player = await createSF2Player(context, await file.arrayBuffer())Choosing an instrument
programs lists every preset in the file, sorted by bank then program number.
selectProgram takes an index into that list.
for (const p of player.programs) {
console.log(`${p.index}: ${p.bank}:${p.program} ${p.name}`)
}
player.selectProgram(12)Switching programs affects notes started afterwards. Notes already sounding keep the program they began with.
API
| Member | Description |
| --- | --- |
| output: AudioNode | Connect this into your graph. |
| programs: readonly SF2Program[] | Every preset: { index, name, bank, program }. |
| program: SF2Program | The program new notes will use. |
| info: ReadonlyMap<string, string> | INFO metadata, e.g. INAM, ICOP. |
| selectProgram(index) | Switch programs. Throws RangeError if out of range. |
| noteOn(note, velocity?, time?) | Start a note. velocity defaults to 100, time to now. |
| noteOff(note, time?) | Release a note. A note that is not sounding is ignored. |
| allNotesOff(time?) | Release everything. |
| dispose() | Silence, disconnect, and free buffers. |
time is always a value on the AudioContext clock, the same units as
context.currentTime. A time in the past means "now".
Rendering offline
Because the player only needs a BaseAudioContext, you can render a passage
without a sound card — which is also how this library tests itself.
const context = new OfflineAudioContext(2, 44100 * 4, 44100)
const player = await createSF2Player(context, fontBytes)
player.output.connect(context.destination)
player.noteOn(60, 100, 0)
player.noteOff(60, 2)
const rendered = await context.startRendering()What is implemented
Sample playback with loop modes, key and velocity zone selection, layered regions, preset generators applied over instrument generators, root key and coarse/fine tuning, scale tuning, volume envelope (delay/attack/hold/decay/ sustain/release, with key-number scaling of hold and decay), initial attenuation, velocity response, pan, a low-pass filter, sample address offsets, and exclusive classes for choke groups such as hi-hats.
Plus the modulation layer:
- Modulation envelope to filter cutoff and to pitch, with its own delay/attack/hold/decay/sustain/release.
- Modulation LFO to pitch, cutoff and volume, and vibrato LFO to pitch, both with their own rate and delay.
- Note-on modulators — the SF2 modulator system restricted to sources known when a voice starts (velocity, key number), with all four transfer curves, direction, polarity and the absolute-value transform.
Pitch and cutoff modulation ride on AudioBufferSourceNode.detune and
BiquadFilterNode.detune, which Web Audio already expresses in cents — the same
unit SF2 uses. A voice only builds the nodes for routings its region actually
uses, so an unmodulated patch costs exactly what it did before.
What is not implemented, and why
- Controller-driven modulators (mod wheel, aftertouch, pitch wheel, CC pan and effect sends). These need a live MIDI controller feed, which this player does not expose. Surveying real fonts, the ones they ship are inert at rest anyway — sitting at zero amount, or with a resting controller value that maps to zero — so notes sound correct without them.
- Chorus and reverb sends. The spec names the sends but never defines the
effect, so any implementation would be an invention. Better placed in the
host graph, which is why
outputis a plainAudioNode. sm2424-bit sample chunks, ignored, so such files play at 16-bit depth.
Those choices came out of measuring six real sound fonts (587 presets, 3,721 regions) rather than guessing. Of 2,641 modulators found, all but 312 were inert; the 312 were a single routing, velocity to filter cutoff, which is implemented. Stereo needs no special handling either: every stereo font surveyed supplies both halves as separate regions panned hard left and right, so they already play correctly.
Two details worth knowing:
- Velocity maps to gain as
(velocity / 127)². That is not an approximation — it is the spec's default velocity-to-attenuation modulator (960 cB, concave, decreasing) written out, and the test suite checks the two agree. initialFilterFcat or above its default of 13500 cents means the filter is bypassed entirely, rather than instantiated next to Nyquist where it would ring. A filter is still built when something modulates the cutoff.
Example
npm install
npm run build
npm run serve # then open http://localhost:5173The demo page loads a .sf2 of your choosing (or a small built-in font), lists
its instruments, and gives you a keyboard. The "timed arpeggio" button schedules
twelve notes in advance to show that the rhythm does not depend on the main
thread staying free.
Tests
npm test # typecheck, unit tests, then the browser suite
npm run test:unit # vitest: parsing, generator maths, zone flattening
npm run test:e2e # playwright: real audio rendered in a real browserThe browser suite renders through an OfflineAudioContext and asserts on the
samples that come out: that a note begins on exactly the frame it was scheduled
for, that two notes a frame apart stay a frame apart, that pitch tracks the key,
that velocity scales level, that release tails decay and stop, that the LFO
swings pitch either side of the note, and that the modulation envelope sweeps
the filter open.
It runs against a sound font the test suite writes itself, so the expected output is known exactly. That matters most for the modulation tests: the fixture's samples hold a constant timbre, so any change in brightness has to come from the filter. The same measurement on a real font proves nothing, because a real instrument sample dulls as it decays all by itself.
A font written by this repo cannot catch a misreading of the format shared by its reader and writer, so the parser is also checked against real files:
SF2_FONT=/path/to/font.sf2 npm run test:unitDevelopment
Requires Node 20 or newer (see .nvmrc).
nvm use
npm install
npx playwright install chromium
npm testLicence
MIT
