Playing SoundFont, SFZ and single-sample instruments with NAudio.Sampler
NAudio.Sampler is a polyphonic software sampler for NAudio 3. It is pure managed C# with no platform dependencies, so it runs anywhere NAudio runs (Windows, Linux, macOS), and it renders 32-bit float stereo through the standard ISampleProvider pull model — the sampler drops straight into any NAudio playback graph or effect chain.
There are three ways to give it an instrument:
| Instrument source | Entry point | What it plays |
|---|---|---|
SoundFont 2 (.sf2) |
SoundFontSampler |
A full multi-timbral bank: 16 MIDI channels, banks/programs, GM percussion |
SFZ (.sfz) |
SfzSampler |
A single instrument defined by an SFZ file plus its WAV/FLAC/Ogg samples |
| A single sample | SingleSampleSampler |
One audio buffer mapped across the keyboard, with live-editable loop points and envelope |
All three are subclasses of the shared SamplerEngine, so they get the same voice engine: pitch from root key and tuning, looping, DAHDSR amplitude and modulation envelopes, two per-voice LFOs, modulated resonant filters, velocity handling, equal-power pan, voice stealing, exclusive (choke) groups, sustain-pedal hold, and shared reverb/chorus send buses.
Where things live:
NAudio.Sampler(NuGet package) — the engine and the three samplers.NAudio.Midi— the MIDI playback hosts:IMidiInstrument(the seam every sampler implements),MidiFileSequence,SequencedMidiPlayer,OfflineMidiRendererandLiveMidiInstrument. These work with anyIMidiInstrument, not just the sampler.NAudio.Core— the file-format layers: the SoundFont parser and resolved-instrument model (NAudio.SoundFontnamespace) and the SFZ parser and opcode semantics (NAudio.Sfznamespace).
Playing a SoundFont
Load a .sf2 with the SoundFont class and hand it to a SoundFontSampler. The constructor takes an optional output sample rate (default 44100) and maximum voice count (default 64):
using NAudio.Midi;
using NAudio.Sampler;
using NAudio.SoundFont;
using NAudio.Wave;
var soundFont = new SoundFont("FluidR3_GM.sf2");
var sampler = new SoundFontSampler(soundFont);
// the engine is single-threaded, so wrap it in LiveMidiInstrument
// before sending it events from outside the audio thread
var live = new LiveMidiInstrument(sampler);
using var device = new WaveOutEvent();
device.Init(live);
device.Play();
live.NoteOn(0, 60, 100); // middle C on channel 0, velocity 100
Thread.Sleep(1000);
live.NoteOff(0, 60);
The sampler consumes MIDI through ProcessMidiEvent(MidiEvent) (plus the NoteOn/NoteOff convenience methods) and produces audio through Read. Both must be called from the same thread — see Live MIDI input for why the example above wraps the sampler in LiveMidiInstrument. When a host such as SequencedMidiPlayer drives the sampler on the audio thread itself, no wrapper is needed.
WaveOutEvent is used in these examples for brevity; the sampler is just an ISampleProvider, so any output device works — WasapiPlayer on Windows, AlsaOut on Linux, or anything else from Choose an output device type.
Channels, banks and percussion
SoundFontSampler is multi-timbral: all 16 MIDI channels play simultaneously, each with its own program, bank, controllers and pitch bend. Channel numbers on the NoteOn/NoteOff/AllSoundOff style methods are zero-based (0–15).
- Bank select follows SF2 semantics: the bank-select MSB (CC0) selects the SoundFont bank (SF2
wBank, 0–127). The LSB (CC32) is tracked but does not affect preset selection. A program in a missing bank falls back to bank 0 (GS "capital tone" style), then to any other melodic bank — never to a percussion kit. - Percussion lives in SF2 bank 128 (
SoundFontSampler.PercussionBank). The channel identified by thePercussionChannelproperty (default 9, i.e. MIDI channel 10) always resolves against the percussion bank regardless of bank-select messages: note numbers pick drums, the program picks the kit (falling back to the Standard Kit). SetPercussionChannel = -1to disable this for a non-GM bank. In addition, a channel whose bank MSB is 120 (the GS rhythm-part convention) or 127 (XG drum kits) resolves against the percussion bank too — disable viaTreatGsXgDrumBanksAsPercussion = false.
Master gain and effects
MasterGain scales the final mix (default 1.0). The shared reverb and chorus buses — fed by each voice's SF2 reverbEffectsSend/chorusEffectsSend generators and CC91/CC93 — are exposed as Reverb (a ReverbEffect) and Chorus (a ChorusEffect) so you can tune or bypass them:
sampler.MasterGain = 0.8f;
sampler.Reverb.RoomSize = 0.7f;
sampler.Chorus.Bypass = true;
ActiveVoiceCount reports how many voices are currently sounding, and GetActivePlaybackPositions(double[]) fills a buffer with each sounding voice's read position (useful for UI playback indicators; safe to call from a UI thread).
Playing a MIDI file
MidiFileSequence loads a standard MIDI file onto the sequencing core's event timeline, building a tempo map from the file's tempo events. From there you can either play it live or render it offline.
Live playback
SequencedMidiPlayer plays the timeline on any IMidiInstrument, driven by a Transport. It dispatches each block's MIDI events at their exact frame offset, so timing is sample-accurate:
using NAudio.Midi;
using NAudio.Sampler;
using NAudio.Sequencing;
using NAudio.SoundFont;
using NAudio.Wave;
var sequence = MidiFileSequence.FromFile("song.mid");
var sampler = new SoundFontSampler(new SoundFont("FluidR3_GM.sf2"));
var transport = new Transport(sequence.TempoMap, sampler.WaveFormat.SampleRate);
var player = new SequencedMidiPlayer(transport, sequence.Timeline, sampler);
using var device = new WaveOutEvent();
device.Init(player);
device.Play();
transport.Play();
The Transport gives you position and seeking: CurrentFrames/CurrentTicks report the playback position, SeekTicks/SeekFrames move it, and Stop/Play pause and resume. While the transport is stopped the player still pulls the instrument, so envelope tails and reverb ring out naturally. After a seek, call the instrument's AllSoundOff() if you don't want notes that were sounding to carry across.
Offline rendering
OfflineMidiRenderer renders a sequence through an instrument faster than real time, with no audio hardware — to a WAV file or an in-memory buffer. A tailSeconds parameter (default 2.0) leaves room for releases and reverb tails after the last event:
var sequence = MidiFileSequence.FromFile("song.mid");
var sampler = new SoundFontSampler(new SoundFont("FluidR3_GM.sf2"));
OfflineMidiRenderer.RenderToWaveFile(sequence, sampler, "song.wav");
// or render to an interleaved float buffer:
float[] audio = OfflineMidiRenderer.Render(sequence, sampler, tailSeconds: 3.0);
The render is deterministic: the same inputs produce the same output, which also makes it a good basis for automated tests.
Live MIDI input
The sampler engine is single-threaded by design: Read and every MIDI entry point must be called from one thread, normally the audio thread. MIDI input callbacks arrive on other threads, so never call the engine directly from one.
LiveMidiInstrument is the bridge. It wraps any IMidiInstrument and exposes a thread-safe Send(MidiEvent) (plus NoteOn/NoteOff and a panic AllSoundOff that queues CC120 on every channel). Events are placed on a lock-free queue and drained on the audio thread at the start of each Read, so the wrapped instrument is only ever touched from one thread. Events take effect at the next block boundary, so the added latency is just your output buffer size.
var sampler = SfzSampler.FromFile(@"instruments\piano.sfz");
var live = new LiveMidiInstrument(sampler);
using var device = new WaveOutEvent();
device.Init(live);
device.Play();
// on Windows, forward a hardware keyboard via WinRTMidiIn (NAudio.Midi);
// any IMidiInput works the same way
var midiIn = await WinRTMidiIn.CreateAsync(deviceId);
midiIn.MessageReceived += (s, e) => live.Send(e.MidiEvent);
midiIn.Start();
LiveMidiInstrument is itself an IMidiInstrument (its ProcessMidiEvent is the thread-safe Send), so hosts can treat live-bridged and directly-driven instruments uniformly.
SFZ instruments
SfzSampler.FromFile parses an .sfz file and loads its samples, resolving relative paths (and default_path) against the file's directory:
var sampler = SfzSampler.FromFile(@"instruments\piano.sfz");
Console.WriteLine($"{sampler.RegionCount} playable regions");
An SFZ file defines a single instrument (no banks or programs), so every MIDI channel plays the same region set. Regions whose sample is missing or cannot be decoded are dropped at load time rather than failing the whole instrument.
The parser handles // and /* */ comments, the #define/$variable preprocessor, #include, sample paths with spaces, and the <control>/<global>/<master>/<group>/<region> hierarchy (opcodes merge down the hierarchy in the usual way). For more control — parsing SFZ text from memory, custom #include resolution, or supplying samples yourself — use SfzParser.Parse/ParseFile (in NAudio.Sfz) and the SfzSampler(SfzInstrument, ISfzSampleLoader, ...) constructor.
Sample formats
- WAV is read natively, including stereo samples and loop points authored in a
smplchunk. Per the SFZ spec, an embedded loop makesloop_modedefault toloop_continuousfor that region; explicitloop_start/loop_end/loop_modeopcodes always win. - FLAC, Ogg Vorbis, Opus (and anything else libsndfile reads) decode through
NAudio.SoundFile, whichNAudio.Samplerreferences. This needs a system libsndfile at runtime; if it is absent or a file cannot be decoded, the region is skipped gracefully rather than failing the load. The libsndfile path does not surface embedded loop points, so FLAC/Ogg samples need explicitloop_start/loop_endopcodes to loop.
Every sample is decoded fully into memory at load time — there is no disk streaming, so very large libraries cost RAM (roughly twice the 16-bit file size, as 32-bit float).
Single-sample instruments
The simplest instrument: one audio buffer mapped across the whole keyboard at a chosen root key. SingleSampleSampler.FromWaveFile is the one-liner entry point:
var sampler = SingleSampleSampler.FromWaveFile("pluck.wav", rootKey: 60);
var live = new LiveMidiInstrument(sampler);
// init a player with live, then live.NoteOn(...) as usual
The Instrument property exposes a SingleSampleInstrument whose settings are plain mutable properties, so a UI can bind to them directly:
- mapping:
RootKey,LoKey/HiKey,LoVelocity/HiVelocity - playback range and looping:
Start,End,LoopMode(None/Continuous/UntilRelease, an enum fromNAudio.Dsp),LoopStart,LoopEnd,LoopCrossfadeSeconds(smooths a loop seam that doesn't fall on matching samples) - tone:
TuneCents,VolumeDb,Pan,VelocityTrackingPercent - amplitude envelope:
DelaySeconds,AttackSeconds,HoldSeconds,DecaySeconds,SustainLevel,ReleaseSeconds
var inst = sampler.Instrument;
inst.LoopMode = LoopMode.Continuous;
inst.LoopStart = 14_300;
inst.LoopEnd = 52_800;
inst.AttackSeconds = 0.01f;
inst.ReleaseSeconds = 0.4f;
Edits are heard on the next note played — the sampler rebuilds the region per note-on, so an interactive editor (like the Single-Sample Editor panel in the NAudioWpfDemo app) just changes properties and plays. You can also construct a SingleSampleInstrument directly from a float buffer (mono, or left + right channels), e.g. from a live recording.
What is supported (and what is not)
The sampler targets a documented, useful subset of each format: SFZ v1 plus common extensions, and the SF2.04 generator/modulator model. This section is the definitive list — if an opcode or feature is not named as supported here, assume it is ignored.
SFZ opcodes
Supported:
- Mapping and selection:
sample,lokey/hikey/key,lovel/hivel— with note names (c#4/db4, case-insensitive, c4 = 60) accepted wherever a key is expected.keysupplies defaults for whichever oflokey/hikey/pitch_keycenterare not explicitly present; an explicit opcode always wins regardless of where it is written (a pragmatic deviation from strict document-order semantics, since opcodes merge across the hierarchy).<control>default_path,note_offset,octave_offset, andset_ccNinitial controller values (seeded silently into every channel at load — visible toloccN/hiccNgates and modulator sources, without edge-firingon_loccNtrigger regions) - Pitch:
pitch_keycenter,tune(aliaspitch),transpose,pitch_keytrack - Amplitude:
volume(boosts above 0 dB included),pan,amp_veltrack(including negative values),amp_velcurve_Nvelocity-curve points (undefined velocities are linearly interpolated; the curve replaces the default velocity-squared term inside theamp_veltracklaw),ampeg_delay/ampeg_attack/ampeg_hold/ampeg_decay/ampeg_sustain/ampeg_release - Filter:
cutoff,resonance,fil_type— all four families: low-pass (lpf_*), high-pass (hpf_*), band-pass (bpf_*) and band-reject (brf_*). The filter is always 2-pole: the 1-pole/4-pole/6-pole variants (lpf_1p,lpf_4p, …) are accepted but play with the 2-pole shape - Sample playback:
offset,end(inclusive, per the spec; an explicitend=-1disables the region),loop_mode/loopmode(no_loop,one_shot,loop_continuous,loop_sustain),loop_start/loop_end(inclusive; aliasesloopstart/loopend), WAVsmpl-chunk loop points as the default loop - Triggers and groups:
trigger(attack/release/first/legato),rt_decay(release samples attenuated by held time),group/off_by(directional choke groups),off_mode(fastcuts a choked voice with a ~5 ms fade;normalreleases it through its ownampeg_release; ARIA'soff_mode=time+off_timeis not supported and is treated asfast),polyphony(per-region voice cap — striking beyond it silences the region's oldest voice, honouring itsoff_mode). Release regions pass the same note-on selection gates (keyswitches,loccN/hiccN,lorand/hirand, round-robin), evaluated against the channel state at note-off time, and release voices are fire-and-forget: like one-shots they ignore later note-offs and play to their own envelope or sample end — a looped release sample therefore rings until its envelope, a choke or All Sound Off ends it - Note-on selection: keyswitches (
sw_lokey/sw_hikey/sw_last/sw_default— keyswitch presses make no sound), round-robin (seq_length/seq_position), random layers (lorand/hirand, one draw per note-on so layers select consistently), CC gating (loccN/hiccN), CC triggers (on_loccN/on_hiccN— the region plays at its root key when the controller rises into the window) - Crossfades:
xfin_lokey/xfin_hikey,xfout_lokey/xfout_hikey,xfin_lovel/xfin_hivel,xfout_lovel/xfout_hivel, withxf_keycurve/xf_velcurve(gainorpower); a layer faded to zero doesn't spawn a voice - Modulation:
pitchlfo_freq/pitchlfo_depth/pitchlfo_delay(vibrato),amplfo_*(tremolo),fillfo_*(filter LFO),fileg_*(filter envelope: delay/attack/hold/decay/sustain/release plus depth) andpitcheg_*(pitch envelope) — see the shared-source note below - Per-region EQ:
eq1_freq/eq1_gain/eq1_bw(andeq2_*,eq3_*) — up to three peaking bands per voice - Effect sends:
effect1(to the shared reverb bus) andeffect2(to the shared chorus bus) - Stereo samples, with per-channel filtering and pan as a balance
One engine limitation to know about: the modulation LFO and modulation envelope are each a single source per voice. A region that uses both amplfo_* and fillfo_* with different rates shares one LFO rate/shape (the amp LFO's settings win) while keeping independent depths; likewise a region using both fileg_* and pitcheg_* shares one envelope shape (the filter EG wins). pitchlfo_* has its own dedicated LFO and is unaffected.
Not supported (parsed where noted, but not honoured):
- ARIA/SFZ v2 flex EGs (
eg01_*, …) and<curve>tables - loop-crossfade opcodes
- disk streaming — every sample decodes fully into memory
SoundFont 2
Honoured (not merely parsed):
- The full preset-zone/instrument-zone generator accumulation per SF2.04 §9.4: instrument generators absolute, preset generators additive, global zones, default generator values, key/velocity-range intersection (
Preset.ResolveRegions()exposes this resolution layer inNAudio.SoundFontif you want it without the sampler) - Loop modes: no-loop, continuous, and loop-until-release-then-tail (
sampleModes), with the start/end/start-loop/end-loop address offset generators, fine and coarse - Pitch:
overridingRootKey,coarseTune,fineTune,scaleTuning, and the sample header's pitch correction - Filter:
initialFilterFc,initialFilterQ,modEnvToFilterFc,modLfoToFilterFc - Envelopes: the full six-stage DAHDSR volume and modulation envelopes (delay/attack/hold/decay/sustain/release, with the spec's timecent times and centibel/permille sustain levels), including
keynumToVolEnvHold/keynumToVolEnvDecayand the modulation-envelope equivalents (hold/decay times scale by key number, so bass notes can ring longer) - LFOs: both LFOs with frequency and start delay;
modLfoToPitch,vibLfoToPitch,modEnvToPitch,modLfoToVolume - Amplitude:
initialAttenuation,pan(equal-power),keynumandvelocityoverrides exclusiveClass(choke groups, click-free fade, sparing sibling layers of the same note-on)- Effects sends:
reverbEffectsSend,chorusEffectsSend, rendered through the shared buses - Modulators: the implicit default modulators of SF2.04 §8.4 (velocity→attenuation, velocity→filter cutoff, CC1 and channel pressure→vibrato depth, CC7/CC11→attenuation, CC10→pan, CC91/CC93→sends) plus file-defined modulators combined per §9.5 (a local modulator supersedes a global one with identical routing; instrument modulators replace defaults, preset modulators add), with the four source curves (linear, concave, convex, switch — each in all polarity/direction combinations) and the linear and absolute-value transforms. Modulators are re-evaluated against live controllers at control rate (64-sample blocks)
- 16- and 24-bit samples (the
sm24sub-chunk is read and combined)
Known deviations:
- The filter is a single 2-pole biquad low-pass. This matches the spec's intent but is not bit-compatible with any particular hardware or reference synth.
- Attenuation is spec-literal:
initialAttenuationcentibels are applied exactly as written. FluidSynth (and synths that copy it) scales attenuation by 0.4× to emulate EMU hardware, so banks voiced against FluidSynth may play quieter here than you are used to. Compensate withMasterGainif needed. - Pitch-wheel handling is realised by the channel pitch-bend path rather than the modulator list, so a file-defined modulator whose destination is initial pitch is ignored.
- Poly (per-note key) pressure is not tracked, so a modulator sourced from it is disabled (ignored entirely, as SF2.04 §7.4 requires for unsupported sources — the same treatment as unknown source enumerations and the §8.2.2-prohibited CC numbers). NRPNs are likewise not decoded. Channel pressure, velocity, key number, CC, pitch wheel and pitch-wheel-sensitivity (RPN 0) sources all work.
- GS/XG drum-bank selection is supported as a heuristic: a channel whose bank MSB (CC0) is 120 (GS rhythm part) or 127 (XG drum kit) resolves notes against the percussion bank, like the forced
PercussionChannel. Gated bySoundFontSampler.TreatGsXgDrumBanksAsPercussion(default true). Full GS/XG mode detection via SysEx (GS Reset / XG System On, part-mode messages) is not supported. - Modulator destinations outside the real generator set (e.g. the "initial pitch" virtual destination) are ignored.
- SoundFonts whose samples live in hardware ROM load and play as silence (their regions are skipped).
MIDI messages
The engine understands these channel messages (anything else is ignored):
- Note on / note off — a note-on with velocity 0 is treated as a note-off, per the MIDI spec
- Program change
- Pitch bend — with the bend range set by RPN 0 (pitch-bend sensitivity); the default range is ±2 semitones
- Channel pressure (aftertouch) — an SF2 modulator source (vibrato depth by default)
- Control change:
- CC0 / CC32 — bank select MSB/LSB (MSB selects the SF2 bank; MSB 120/127 selects the percussion bank on any channel via the GS/XG drum-bank heuristic, unless disabled; the LSB is tracked but unused)
- CC1 — modulation wheel (vibrato depth via the SF2 default modulator)
- CC7 — channel volume
- CC10 — pan
- CC11 — expression
- CC64 — sustain pedal (released notes are held until pedal-up; release triggers fire when the pedal rises)
- CC101/CC100 + CC6/CC38 — RPN select and data entry (RPN 0 sets the pitch-bend range in semitones + cents; RPN null or an NRPN selection via CC99/CC98 deselects, so stray data entry is ignored)
- CC91 / CC93 — reverb / chorus send level
- CC120 — all sound off (immediate, with a short anti-click fade)
- CC121 — reset all controllers
- CC123 — all notes off, per channel (notes release naturally; per the MIDI spec it acts like a note-off for each note, so with the sustain pedal down the channel's notes keep ringing until pedal-up)
- Every other controller's value is stored and visible to SF2 file-defined modulators and SFZ CC gates/triggers, even though it has no hard-wired meaning
Sysex and meta events are not consumed by the engine (MidiFileSequence drops them when loading a file, except Set Tempo which builds the tempo map), and polyphonic key pressure is ignored.