Docs/Sampler.md
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, OfflineMidiRenderer and LiveMidiInstrument. These work with any IMidiInstrument, not just the sampler.NAudio.Core — the file-format layers: the SoundFont parser and resolved-instrument model (NAudio.SoundFont namespace) and the SFZ parser and opcode semantics (NAudio.Sfz namespace).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.
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).
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.SoundFontSampler.PercussionBank). The channel identified by the PercussionChannel property (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). Set PercussionChannel = -1 to 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 via TreatGsXgDrumBanksAsPercussion = false.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).
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.
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.
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.
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.
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.
smpl chunk. Per the SFZ spec, an embedded loop makes loop_mode default to loop_continuous for that region; explicit loop_start/loop_end/loop_mode opcodes always win.NAudio.SoundFile, which NAudio.Sampler references. 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 explicit loop_start/loop_end opcodes 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).
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:
RootKey, LoKey/HiKey, LoVelocity/HiVelocityStart, End, LoopMode (None/Continuous/UntilRelease, an enum from NAudio.Dsp), LoopStart, LoopEnd, LoopCrossfadeSeconds (smooths a loop seam that doesn't fall on matching samples)TuneCents, VolumeDb, Pan, VelocityTrackingPercentDelaySeconds, AttackSeconds, HoldSeconds, DecaySeconds, SustainLevel, ReleaseSecondsvar 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.
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.
Supported:
sample, lokey/hikey/key, lovel/hivel — with note names (c#4/db4, case-insensitive, c4 = 60) accepted wherever a key is expected. key supplies defaults for whichever of lokey/hikey/pitch_keycenter are 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, and set_ccN initial controller values (seeded silently into every channel at load — visible to loccN/hiccN gates and modulator sources, without edge-firing on_loccN trigger regions)pitch_keycenter, tune (alias pitch), transpose, pitch_keytrackvolume (boosts above 0 dB included), pan, amp_veltrack (including negative values), amp_velcurve_N velocity-curve points (undefined velocities are linearly interpolated; the curve replaces the default velocity-squared term inside the amp_veltrack law), ampeg_delay/ampeg_attack/ampeg_hold/ampeg_decay/ampeg_sustain/ampeg_releasecutoff, 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 shapeoffset, end (inclusive, per the spec; an explicit end=-1 disables the region), loop_mode/loopmode (no_loop, one_shot, loop_continuous, loop_sustain), loop_start/loop_end (inclusive; aliases loopstart/loopend), WAV smpl-chunk loop points as the default looptrigger (attack/release/first/legato), rt_decay (release samples attenuated by held time), group/off_by (directional choke groups), off_mode (fast cuts a choked voice with a ~5 ms fade; normal releases it through its own ampeg_release; ARIA's off_mode=time+off_time is not supported and is treated as fast), polyphony (per-region voice cap — striking beyond it silences the region's oldest voice, honouring its off_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 itsw_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)xfin_lokey/xfin_hikey, xfout_lokey/xfout_hikey, xfin_lovel/xfin_hivel, xfout_lovel/xfout_hivel, with xf_keycurve/xf_velcurve (gain or power); a layer faded to zero doesn't spawn a voicepitchlfo_freq/pitchlfo_depth/pitchlfo_delay (vibrato), amplfo_* (tremolo), fillfo_* (filter LFO), fileg_* (filter envelope: delay/attack/hold/decay/sustain/release plus depth) and pitcheg_* (pitch envelope) — see the shared-source note beloweq1_freq/eq1_gain/eq1_bw (and eq2_*, eq3_*) — up to three peaking bands per voiceeffect1 (to the shared reverb bus) and effect2 (to the shared chorus bus)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):
eg01_*, …) and <curve> tablesHonoured (not merely parsed):
Preset.ResolveRegions() exposes this resolution layer in NAudio.SoundFont if you want it without the sampler)sampleModes), with the start/end/start-loop/end-loop address offset generators, fine and coarseoverridingRootKey, coarseTune, fineTune, scaleTuning, and the sample header's pitch correctioninitialFilterFc, initialFilterQ, modEnvToFilterFc, modLfoToFilterFckeynumToVolEnvHold/keynumToVolEnvDecay and the modulation-envelope equivalents (hold/decay times scale by key number, so bass notes can ring longer)modLfoToPitch, vibLfoToPitch, modEnvToPitch, modLfoToVolumeinitialAttenuation, pan (equal-power), keynum and velocity overridesexclusiveClass (choke groups, click-free fade, sparing sibling layers of the same note-on)reverbEffectsSend, chorusEffectsSend, rendered through the shared busessm24 sub-chunk is read and combined)Known deviations:
initialAttenuation centibels 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 with MasterGain if needed.PercussionChannel. Gated by SoundFontSampler.TreatGsXgDrumBanksAsPercussion (default true). Full GS/XG mode detection via SysEx (GS Reset / XG System On, part-mode messages) is not supported.The engine understands these channel messages (anything else is ignored):
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.