Skip to content

Add Native SFZ Player#8391

Open
regulus79 wants to merge 142 commits into
LMMS:masterfrom
regulus79:sfzsampler
Open

Add Native SFZ Player#8391
regulus79 wants to merge 142 commits into
LMMS:masterfrom
regulus79:sfzsampler

Conversation

@regulus79

@regulus79 regulus79 commented May 16, 2026

Copy link
Copy Markdown
Member

This PR adds a native SFZ instrument loader.

2026-05-15.18-10-15.mp4

It also supports pitch bending, microtuning, and per-note panning:

2026-05-27.17-36-56.mp4

What is SFZ?

SFZ is not SoundFont. SFZ is a modern, text-based, sampled-instrument format which provides incredible amounts of control to the creator and user. By its text-based nature, it is fundamentally open-source.

Countless high-quality SFZ instruments have been created by individuals. Sonatina Symphonic Orchestra is a large collection of orchestral SFZs, from strings, to brass, to wind, to percussion. Metal GTX is a famous electric guitar SFZ, one of the most realistic ones you can get for free. This PR allows you to load these directly into LMMS, without any third-party plugins.

Please check out the information at sfzformat.com if you are unfamiliar with the SFZ format.

Nonetheless, here is a brief overview:

An SFZ file essentially defines which samples are played by which keys (or ranges of keys), in what situations they are played (whether there are conditions on velocity, switch keys, current CC values, etc), and how they are played (what kind of envelopes/lfos, filters, etc).

NOTE: The samples are NOT stored in the .sfz file. The .sfz file contains paths to the samples, which are often stored in a separate folder in .wav or .flac. When you download an SFZ instrument, you usually download a .zip folder which contains both the .sfz file and the audio files.

Example sfz file:

<region>
sample=MyFavoriteSample.flac // The path to the sample file
pitch_keycenter=C4 // The pitch of the sample, where to center it on the keyboard
lovel=0 // Minimum velocity to trigger this region
hivel=63 // Maximum velocity to trigger this region

<region>
sample=MyOtherFavoriteSample.flac
pitch_keycenter=B4
lovel=64
hivel=127

This instrument has two samples, one for low velocities, one for high. These individual settings are call “opcodes”, and there are many many many opcodes supported by various SFZ players (this PR only contains a small subset). There are also headers, which group the opcodes together. Only the <region> header is used here, but other headers for different purposes exist such as <group>, <global>, <control>, etc.

Opcodes

There are many opcodes used by SFZs, too many to fit into this initial PR. However, I have implemented the vast majority of opcodes used by SFZs, so most of them should load fine.

Supported Opcodes
sample
default_path

trigger (partial support)

lokey
hikey

sw_lokey
sw_hikey
sw_last
sw_label

lovel
hivel

offset

loop_mode (partial support)

delay
delayccN
delay_random

tune
key
pitch_keycenter
pitch_veltrack

fil_type
cutoff
resonance
fil_veltrack

amplitude
amplitudeccN

amp_veltrack

ampeg_delay
ampeg_delayccN
ampeg_attack
ampeg_attackccN
ampeg_hold
ampeg_holdccN
ampeg_decay
ampeg_decayccN
ampeg_sustain
ampeg_sustainccN
ampeg_release
ampeg_releaseccN

amplfo_delay
amplfo_delayccN
amplfo_fade
amplfo_fadeccN
amplfo_freq
amplfo_freqccN
amplfo_depth
amplfo_depthccN

pitcheg_delay
pitcheg_delayccN
pitcheg_attack
pitcheg_attackccN
pitcheg_hold
pitcheg_holdccN
pitcheg_decay
pitcheg_decayccN
pitcheg_sustain
pitcheg_sustainccN
pitcheg_release
pitcheg_releaseccN

pitchlfo_delay
pitchlfo_delayccN
pitchlfo_fade
pitchlfo_fadeccN
pitchlfo_freq
pitchlfo_freqccN
pitchlfo_depth
pitchlfo_depthccN

volume
volumeccN

pan
panccN

loccN
hiccN

set_ccN

label_ccN

*_mod

GUI

The GUI is work in progress! This PR is intended to give the core support for loading SFZ files, while the visuals are secondary. In the future, a real GUI is planned.

Currently, only some basic information is shown about the currently loaded SFZ file, the last played sample, and the details of the switch keys. The midi CC knobs which the instrument uses are also shown in the GUI for easy access (they control the models in the instrument track’s midi CC rack).

Testing

Please feel free to test it on whatever SFZ’s you find! If the SFZ makes heavy use of ARIA extenions/extra opcodes, it may not function as well, but it might still work. The SFZ player will print an enormous amount of debug info into the terminal, which is helpful when determining what opcodes still need to be implemented or tracking down bugs.

@musicguy423

musicguy423 commented May 16, 2026

Copy link
Copy Markdown

In case people immediately want an executable without taking the hassle to build it: https://lmms.io/download/pull-request/8391

My feedback:

First, specifically the plugin window's built-in keyboard doesn't seem to play all the velocity samples, as I've tested with VCSL Keys and tried to click the keys from top to bottom area. Velocity samples themselves work fine, however. (e.g. in piano rolls)

Second, even though velocity-tracked cutoff is mentioned in the supported opcode list, it doesn't seem to be implemented, as I've tested with Metal-GTX.

@regulus79

regulus79 commented May 16, 2026

Copy link
Copy Markdown
Member Author

Thank you for testing!

First, specifically the plugin window's built-in keyboard doesn't seem to play all the velocity samples, as I've tested with VCSL Keys and tried to click the keys from top to bottom area. Velocity samples themselves work fine, however. (e.g. in piano rolls)

Yes, this is an issue with LMMS's instrument window piano widget; playing at the very bottom only gives half the maximum velocity. You can sort of fix this by changing the VELOCITY MAPPING lcd number in the midi tab of the instrument window to 127 instead of 63 (this is an lmms thing, not an sfz thing. I don't know why it defaults 63, that doesn't feel right.)

Second, even though velocity-tracked cutoff is mentioned in the supported opcode list, it doesn't seem to be implemented, as I've tested with Metal-GTX.

Thanks for catching this! It turns out I was incorrectly adding instead of multiplying when applying fil_veltrack. I remember when I first implemented this, I was confused because I saw SFZs like Metal GTX using some crazy high value like fil_veltrack=20000, and according to the SFZ website, that would be in cents, which would come out to like 16 octaves(!). But now I see that the initial filter cutoff was something extremely low, like 30 Hz, so going up 8-16 octaves isn't actually too crazy.

I tried it out, and wow, the extra expression you get from the velocity is awesome.

2026-05-16.19-16-05.mp4

I hope you enjoy!

@musicguy423

musicguy423 commented May 17, 2026

Copy link
Copy Markdown

Yes, this is an issue with LMMS's instrument window piano widget; playing at the very bottom only gives half the maximum velocity. You can sort of fix this by changing the VELOCITY MAPPING lcd number in the midi tab of the instrument window to 127 instead of 63 (this is an lmms thing, not an sfz thing. I don't know why it defaults 63, that doesn't feel right.)

Alright, I made the issue #8392. I did make sure to search for previous issue threads about it and I couldn't seem to find any.

@jackcrowley420

Copy link
Copy Markdown

this is peak. I am so excited to get this in the future versions

@regulus79

Copy link
Copy Markdown
Member Author

The SFZ player now supports pitch bending, microtuning, and per-note panning!

2026-05-27.17-36-56.mp4

@CorruptVoidSoul

CorruptVoidSoul commented May 28, 2026

Copy link
Copy Markdown

So I just tested this and here are my findings :

  • Pitch bending works
  • Microtuning works
  • note panning works
  • very high ram usage due to loading the entire SFZ even if I don't use the entire SFZ
  • Some weird audio bug that I discovered thanks to the Metal-GTX where the SFZ player adds parasitic frequencies between 50 and 200 Hz. This seems negligible, but is not, especially if you have a guitar amp on your effect stack because it ruins long, sustained notes.

I tried uploading videos but couldn't, because of the horrendous upload speed of GitHub, so all you get is audio

Sfizz.mp3
SFZ player.mp3

About the CC64 knob tested with a piano (but that also counts for anything that uses it) :
Pressing the same key successively, multiple times, cancels the release note of the previous press.
I know that this is how pianos are supposed to work in real life, but for some reason it feels off to me.
Maybe consider giving us the same option than Sfizz, I think theirs is called "Sustain cancels release" and it's off by default.

Things that should be added either here or in a follow-up PR (but most likely here because we're in a feature-freeze) :

  • An option for the SFZ player to load the samples as the keys are pressed instead of loading everything upfront.
  • Some kind of shared SFZ database in RAM that all instances of the SFZ player can pull from.
  • A toggle that makes it so the CC64 sustain does not cancel previous identical notes.
  • The UI, of course, when everything is done.

@regulus79

Copy link
Copy Markdown
Member Author

Thank you for testing!

Some weird audio bug that I discovered thanks to the Metal-GTX where the SFZ player adds parasitic frequencies between 50 and 200 Hz. This seems negligible, but is not, especially if you have a guitar amp on your effect stack because it ruins long, sustained notes.

(After further correspondence on the discord, it appears this issue was due to the pitch of the notes drifting up and down ever so slightly. This turned out to be due to float being used to store the sample frame index, which did not provide enough precision. Changing it to double in 5d9f5d4 should have fixed it.

My understanding is that when playing a simple interval/power chord with two notes, the harmonics often line up. But when the pitch of a note starts to drift, it creates a warble/beat effect, making the total amplitude of the audio pulse up and down, in proportion to the frequency difference of the notes. When using a non-linear effect such as a guitar amp which heavily relies on the instantaneous amplitude of the signal, this pulsing has a much more pronounced effect, completely altering the resulting sound.)

As for the frequencies around 50-200 Hz, I am able to reproduce them, but they also seem to appear when loading the raw sample files into sample tracks and playing them, which makes me think it might be an issue outside of the SFZ player. I'm not sure.

About the CC64 knob tested with a piano (but that also counts for anything that uses it) :
Pressing the same key successively, multiple times, cancels the release note of the previous press.
I know that this is how pianos are supposed to work in real life, but for some reason it feels off to me.
Maybe consider giving us the same option than Sfizz, I think theirs is called "Sustain cancels release" and it's off by default.

Unfortunately, LMMS handles the midi events, and when CC64 > half, it does not send NoteOff events. This would be very straightforward to change; perhaps an option could be added in the midi tab of instruments to toggle the behavior. It might be out of scope for this PR, though.

An option for the SFZ player to load the samples as the keys are pressed instead of loading everything upfront.

This sounds like a good option to have. I am still thinking of how to best implement this; it would likely require a bit of refactoring of how samples are currently loaded.

Some kind of shared SFZ database in RAM that all instances of the SFZ player can pull from

This was also on my mind. I will have to experiment whether a static object would work given how LMMS loads plugins. I will also have to start tagging the sample objects based on which instruments are currently using them, and only delete them when they are no longer being used. Sounds like a job for std::shared_ptr, perhaps.

@regulus79

Copy link
Copy Markdown
Member Author

The SFZ Player now caches samples between instances in a shared sample pool!
You can now have as many instances of an SFZ instrument as you wish, without having to load all the samples again each time.

2026-05-31.16-26-06.mp4

@regulus79

Copy link
Copy Markdown
Member Author

The SFZ player now supports loading samples one by one when you play the notes, instead of loading all of them at once when you open the instrument.
That means you can open massive, multi-gigabyte SFZs which don't fit into ram, and it will only load the samples which you need, as you need them.

2026-06-03.18-01-10.mp4

This feature is currently the default. (Is that good or bad? Let me know!) You can forcefully load all the samples by enabling the "Preload All Samples" option. This setting is saved with the instrument, so it will remember your choice the next time you open the project.

Also, when loading an SFZ which has invalid sample paths (such as trying to load an include file instead of the real instrument), instead of showing a hundred popup windows or crashing LMMS, it now gracefully handles it and simply prints a warning message.

Comment on lines +70 to +71
std::atomic<const SfzSampleBuffer*> m_samplePointer = nullptr;
std::shared_ptr<const SfzSampleBuffer> m_sample; // Unfortunately, not all the builds support std::atomic<std::shared_ptr>, so as a workaround, the shared pointer exists so that the sample garbage collection works, but the actual audio processing uses the raw pointer which is atomic.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So when the GUI thread wants to update the data, the GUI thread just updates the std::shared_ptr from under it since the audio thread uses a raw pointer reference?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the main thread is able to directly update the std::shared_ptr without locking or anything since the audio thread doesn't access it (it only uses the atomic raw pointer from SfzRegion::sample()). The only real purpose of the shared_ptr is so that the sample gets automatically deleted from the sample pool when all the active SFZ players using it are closed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that thread safe, in the event that the audio thread is using the structure while it is being updated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I am misunderstanding; The audio thread never accesses the shared pointer, so the main thread can update it whenever it wants without any risk, I believe(?)

@sakertooth sakertooth Jun 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, its either a thread-safety issue, or a memory leak. I thought about it some more and this might just be a memory leak..

The shared pointer holds a pointer to the heap allocated object. In your code, you also have a raw atomic pointer pointing to the same object (via std::shared_ptr::get). I'm assuming you aren't actually modifying that object (if you were, its most likely not thread-safe), but just replacing the shared pointer with a new structure. In that case, that means the audio thread could still be using the old structure, and it would have the only reference to it. It would have to delete it (not real-time safe), or just fetch the new pointer from the atomic variable after its done using the old one (memory leak).

Edit: This might've been slightly inaccurate. I dont think theres a memory leak here, because the deletion does happen as you are replacing the structure on the GUI thread. But if that deletion happens while the audio thread is using it, that's a use-after-free bug and will most likely crash the application.

Me personally, I would just use the requestChangesGuard that we have. The proper thread-safe and real-time solution would need deferred reclamation as well (via some lock-free queue probably), not just atomic updates.

PS: I also call the GUI thread the main thread sometimes.

@sakertooth sakertooth Jun 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait... scratch that.. when you replace the shared pointer on the main thread, it will delete the structure (since the main thread is the only one with the shared reference, the ref count will drop to zero and it will call delete on whatever pointer it is storing). Deleting that structure while the audio thread still has the raw pointer reference is not thread-safe.

So I think best case scenario, you dont crash and its deleted safely (the audio thread was in a convenient time window as you were deleting it), worst case scenario is that you have a segmentation fault, as far as I understand it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean now. You're right, if the sample is deleted while the audio thread is processing, it could be using the old raw pointer which now points to a deleted object.

Currently, the only time the shared pointer is modified is when the sample is initially loaded, when it changes from nullptr to the sample pointer. Once the sample is initialized, it is never changed.

(If the user decides to load a new SFZ file, it goes through a separate system which creates a whole new SfzRegionManager which contains a vector of SfzRegions, and that gets swapped with the old one when the audio thread is ready and a couple of extra buffers have passed. But in that case, the audio thread is guaranteed to not be currently accessing it, so by the time the SfzRegion is deleted and the shared pointer is deleted, the atomic raw pointer is no longer being used.)

@sakertooth sakertooth Jun 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(If the user decides to load a new SFZ file, it goes through a separate system which creates a whole new SfzRegionManager which contains a vector of SfzRegions, and that gets swapped with the old one when the audio thread is ready and a couple of extra buffers have passed. But in that case, the audio thread is guaranteed to not be currently accessing it, so by the time the SfzRegion is deleted and the shared pointer is deleted, the atomic raw pointer is no longer being used.)

Each note that is played with the instrument can run on any number of audio threads, not just one. This might work if there was only one audio thread, but I believe you can have multiple audio threads processing each individual note that is currently being played, and any of them may have a reference. The deletion should only happen when no audio threads are currently using it.

I believe @rubiefawn handled this in the Lb302 refactor. They might have some thoughts to share about this here.

Edit: I looked at the code... and I think you intended to handle the problem I just mentioned (correct me if I'm wrong)... but I cant verify if its entirely correct.

@regulus79 regulus79 Jun 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you can have multiple audio threads processing each individual note that is currently being played

That may be true for NotePlayHandle-based instruments, but here the instrument is single-streamed, so I think it should be fine. Unless the InstrumentPlayHandle can move process multiple buffers at once on different threads (but I would doubt that because things like filters can't be processed on multiple buffers at once without have separate internal states idk).

Comment thread plugins/SfzPlayer/SfzPlayer.h Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants