The first result for “play a piano sound in the browser” is almost always a tutorial that loads 30–80 megabytes of recorded piano notes as .mp3 or .ogg files, then plays the right sample for each key. It works. It also makes your page heavier than the rest of your entire site, and it locks you into one instrument.

There is a better way that almost nobody writes about: wavetable SoundFont synthesis. A single ~2 MB preset file lets you play any of the 128 General MIDI instruments — piano, organ, guitar, strings, brass, drums — with realistic envelopes, no per-note sample files, and no audio streaming. This post walks through it end to end.

What a SoundFont actually is

A .sf2 SoundFont is not a set of recordings. It is a database of short WAV samples plus instrument definitions that say “for MIDI note 60, play this 0.4-second WAV, loop this section, apply this envelope, detune by this much.” A single SoundFont file (FluidR3_GM.sf2, the de-facto free one, ~140 MB on disk) contains all 128 General MIDI instruments.

The synthesizer — not a sampler — reads the WAV fragments, pitch-shifts them to the requested MIDI note, applies the amplitude envelope, and loops sustain sections while a key is held. The CPU cost is tiny and the output sounds like a real instrument because, well, it started from a real recording of one.

The two pieces you need

  1. A preset file. A JavaScript file containing the parsed SoundFont data for one instrument, exposed as a global variable. The WebAudioFont data repository hosts all 128 General MIDI instruments as separate ~1–3 MB JS files. Piano is 0000_FluidR3_GM_sf2_file.js.
  2. A player. WebAudioFontPlayer is a small (~10 KB) JavaScript library that reads a preset, computes the right wavetables for any MIDI note, and schedules playback on a Web Audio AudioContext. It is the synthesizer engine.
<script src="https://surikov.github.io/webaudiofontdata/sound/0000_FluidR3_GM_sf2_file.js"></script>
<script src="https://surikov.github.io/webaudiofont/js/WebAudioFontPlayer.js"></script>

Loading these two scripts puts a global _tone_0000_FluidR3_GM_sf2_file object (the preset) and a WebAudioFontPlayer constructor on the page. No bundler, no import map, no npm install — just two <script> tags.

The five-line synthesizer call

This is the whole primitive. One call plays a real piano note:

var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var player = new WebAudioFontPlayer();
var preset = _tone_0000_FluidR3_GM_sf2_file;  // global from the loaded script

// Play middle C (MIDI 60) at "now" for 8 seconds at volume 0.8
var envelope = player.queueWaveTable(
  audioCtx,                 // the AudioContext
  audioCtx.destination,     // output node
  preset,                   // the instrument preset
  audioCtx.currentTime,     // start time, in context seconds
  60,                       // MIDI note number
  8,                        // duration, in seconds
  0.8                       // volume 0..1
);

That is a real piano. No samples downloaded, no audio file. The player synthesised it from the wavetable fragments in the preset.

Note on, note off, and the envelope handle

Real instruments respond to key release. queueWaveTable returns an envelope handle that exposes a cancel() method — calling it kills the note immediately. Track one envelope per note name in a map and you have a polyphonic keyboard:

var active = {};  // noteName -> envelope

function startNote(noteName) {
  var midi = noteNameToMidi(noteName);  // "C4" -> 60
  // Stop any previous instance of the same key (re-strike, not layer)
  if (active[noteName]) {
    try { active[noteName].cancel(); } catch(e) {}
    delete active[noteName];
  }
  var env = player.queueWaveTable(
    audioCtx, audioCtx.destination, preset, audioCtx.currentTime, midi, 8, 0.8
  );
  if (env) active[noteName] = env;
}

function stopNote(noteName) {
  var env = active[noteName];
  if (!env) return;
  try { env.cancel(); } catch(e) {}
  delete active[noteName];
}

The eight-second duration is just a safety cap — cancel() ends the note when the key is released, so the actual audible length is however long you hold the key. The release portion of the SoundFont’s own envelope (the audible decay after cancel()) still plays out, giving you a natural tail.

Switching instruments without loading more data

The same approach generalises to any General MIDI instrument. Each one lives as a separate preset script — 0380_FluidR3_GM_sf2_file.js is Synth Bass 1, 0580_FluidR3_GM_sf2_file.js is Tuba, and so on. You load only the instruments you offer; the piano preset alone is enough for most apps.

var loadedPresets = {};  // program number -> preset

function loadInstrument(program) {
  if (loadedPresets[program]) {
    preset = loadedPresets[program];
    return;
  }
  var fileId = String(program * 1000).padStart(4, '0');  // 0 -> "0000", 38 -> "0380"
  var varName = '_tone_' + fileId + '_FluidR3_GM_sf2_file';
  var url = 'https://surikov.github.io/webaudiofontdata/sound/'
            + fileId + '_FluidR3_GM_sf2_file.js';

  player.loader.startLoad(audioCtx, url, varName);
  player.loader.waitLoad(function() {
    loadedPresets[program] = window[varName];
    preset = loadedPresets[program];
  });
}

The pattern: a single loadInstrument(program) call lazily fetches and caches one instrument. Switching is fast on a warm cache and zero-cost on a repeat visit.

The autoplay policy gotcha

Every modern browser blocks AudioContext from starting until a user gesture. If you create the context on page load and immediately try to play, you will hear silence — the context starts in the suspended state. You must call audioCtx.resume() from inside a user gesture handler (pointerdown, keydown, touchstart). Once it is running, audio works forever after.

// Resume on any user gesture, exactly once per tab session
['pointerdown', 'keydown', 'touchstart'].forEach(function(ev) {
  document.addEventListener(ev, function() {
    if (audioCtx && audioCtx.state !== 'running') audioCtx.resume();
  }, { passive: true, capture: true });
});

// The same resume call inside your actual key-press handler is enough if
// you don't want a global listener — the gesture is what unlocks the API.

Why this beats the sample-file approach

Genuine trade-offs

Takeaways

You can hear the result on the piano game at ggames.mobi. The whole instrument stack is two script tags and one envelope map — about a dozen lines of application code on top of the synth.