diff --git a/cmake/modules/PluginList.cmake b/cmake/modules/PluginList.cmake index 7d0afb555fe..34435972f75 100644 --- a/cmake/modules/PluginList.cmake +++ b/cmake/modules/PluginList.cmake @@ -63,6 +63,7 @@ SET(LMMS_PLUGIN_LIST Sf2Player Sfxr Sid + SfzPlayer SlewDistortion SlicerT SpectrumAnalyzer diff --git a/include/FileBrowser.h b/include/FileBrowser.h index 4ccd3ccf03a..afeae4e917f 100644 --- a/include/FileBrowser.h +++ b/include/FileBrowser.h @@ -248,6 +248,7 @@ class FileItem : public FileBrowserWidgetItem Preset, Sample, SoundFont, + SFZ, Patch, Midi, VstPlugin, diff --git a/include/InstrumentTrack.h b/include/InstrumentTrack.h index 07350cde033..bc36b67dd91 100644 --- a/include/InstrumentTrack.h +++ b/include/InstrumentTrack.h @@ -239,6 +239,9 @@ class LMMS_EXPORT InstrumentTrack : public Track, public MidiEventProcessor void autoAssignMidiDevice( bool ); + //! Returns a non-owning pointer to the LED in the MIDI CC rack which toggles whether the knobs send midi control change events + BoolModel* midiCCEnableModel() const { return m_midiCCEnable.get(); } + //! Returns a non-owning pointer to the model for the knob at the given index in the track's MIDI CC rack FloatModel* midiCCModel(int index) const { return m_midiCCModel[index].get(); } diff --git a/include/ThreadPool.h b/include/ThreadPool.h index ad4d798505b..18ef35f5acb 100644 --- a/include/ThreadPool.h +++ b/include/ThreadPool.h @@ -25,6 +25,8 @@ #ifndef LMMS_THREAD_POOL_H #define LMMS_THREAD_POOL_H +#include "lmms_export.h" + #include #include #include @@ -38,7 +40,7 @@ namespace lmms { //! A thread pool that can be used for asynchronous processing. -class ThreadPool +class LMMS_EXPORT ThreadPool { public: //! Destroys the `ThreadPool` object. diff --git a/plugins/SfzPlayer/CMakeLists.txt b/plugins/SfzPlayer/CMakeLists.txt new file mode 100644 index 00000000000..a29c702627e --- /dev/null +++ b/plugins/SfzPlayer/CMakeLists.txt @@ -0,0 +1,47 @@ +include(BuildPlugin) + +build_plugin(sfzplayer + SfzPlayer.cpp + SfzPlayer.h + SfzPlayerView.cpp + SfzPlayerView.h + SfzOpcodes.cpp + SfzOpcodes.h + SfzOpcodeState.cpp + SfzOpcodeState.h + SfzRegion.cpp + SfzRegion.h + SfzParser.cpp + SfzParser.h + SfzRegionPlayState.cpp + SfzRegionPlayState.h + SfzRegionManager.cpp + SfzRegionManager.h + SfzGlobalState.cpp + SfzGlobalState.h + SfzControlsConfig.h + SfzTrigger.cpp + SfzTrigger.h + SfzSampleBuffer.cpp + SfzSampleBuffer.h + SfzSamplePool.cpp + SfzSamplePool.h + SfzBasicWaves.cpp + SfzBasicWaves.h + MOCFILES + SfzPlayer.h + SfzPlayerView.h + SfzOpcodes.h + SfzOpcodeState.h + SfzRegion.h + SfzParser.h + SfzRegionPlayState.h + SfzRegionManager.h + SfzGlobalState.h + SfzControlsConfig.h + SfzTrigger.h + SfzSampleBuffer.h + SfzSamplePool.h + SfzBasicWaves.h + EMBEDDED_RESOURCES "${CMAKE_CURRENT_SOURCE_DIR}/*.svg" +) diff --git a/plugins/SfzPlayer/SfzBasicWaves.cpp b/plugins/SfzPlayer/SfzBasicWaves.cpp new file mode 100644 index 00000000000..55197fc6f40 --- /dev/null +++ b/plugins/SfzPlayer/SfzBasicWaves.cpp @@ -0,0 +1,87 @@ +/* + * SfzBasicWaves.cpp - Generators of basic wave shapes + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzBasicWaves.h" +#include "lmms_math.h" +#include + +namespace lmms +{ + +const float SfzBasicWaves::generate(Shape shape, float sampleRate, int frame) +{ + // By default, the basic waves are played at C4 (midi key 60). This isn't in the spec afaik, but it makes things simple since pitch_keycenter defaults to 60. + constexpr float baseFreq = 261.625565301f; + // Amount through period + const float t = absFraction(frame * baseFreq / sampleRate); + // The region playback state handles incrementing the frame count at different rates based on the pitch, so we don't need to handle the actual frequency calculation here. + + switch (shape) + { + case Shape::Sine: + return sine(t); + case Shape::Saw: + return saw(t); + case Shape::Square: + return square(t); + case Shape::Triangle: + return triangle(t); + case Shape::Noise: + return noise(t); + case Shape::Silence: + return 0.0f; + } + return 0.0f; +} + + +const float SfzBasicWaves::sine(float t) +{ + return sin(t * 2 * std::numbers::pi); +} + +const float SfzBasicWaves::saw(float t) +{ + return 2.0f * t - 1.0f; +} + +const float SfzBasicWaves::square(float t) +{ + return t > 0.5 ? 1.0f : -1.0f; +} + +const float SfzBasicWaves::triangle(float t) +{ + return t < 0.5f + ? 4.0f * t - 1.0f + : -(4.0f * (t - 0.5f) - 1.0f); +} + +const float SfzBasicWaves::noise(float t) +{ + return fastRand(-1.0f, 1.0f); +} + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzBasicWaves.h b/plugins/SfzPlayer/SfzBasicWaves.h new file mode 100644 index 00000000000..68c2e98f16a --- /dev/null +++ b/plugins/SfzPlayer/SfzBasicWaves.h @@ -0,0 +1,56 @@ +/* + * SfzBasicWaves.h - Generators for audio wave shapes such as sine, saw, triangle, noise, etc + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_BASIC_WAVES_H +#define LMMS_SFZ_BASIC_WAVES_H + +namespace lmms +{ + +class SfzBasicWaves +{ +public: + enum class Shape + { + Sine, + Saw, + Square, + Triangle, + Noise, + Silence + }; + //! Returns the calculated value of the waveform shape at the given number of frames from the start, given the sample rate. + static const float generate(Shape shape, float sampleRate, int frame); + +private: + static const float sine(float t); + static const float saw(float t); + static const float square(float t); + static const float triangle(float t); + static const float noise(float t); +}; + +} // namespace lmms + +#endif // LMMS_SFZ_BASIC_WAVES_H diff --git a/plugins/SfzPlayer/SfzControlsConfig.h b/plugins/SfzPlayer/SfzControlsConfig.h new file mode 100644 index 00000000000..515c404881f --- /dev/null +++ b/plugins/SfzPlayer/SfzControlsConfig.h @@ -0,0 +1,55 @@ +/* + * SfzControlsConfig.h - Helper class for storing info about which keyswitches exist, and which midi CC's are used by the sfz + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + + +#ifndef LMMS_SFZ_CONTROLS_CONFIG_H +#define LMMS_SFZ_CONTROLS_CONFIG_H + +#include "SfzOpcodeState.h" + +namespace lmms +{ + +class SfzControlsConfig : public SfzOpcodeState +{ +public: + //! Keeps track of which CC's are actually used by the instrument, so that the GUI only needs to display those, not all 128 + std::array m_activeMidiCCs = {}; + + //! Stores information about each switch key, to make it easy to display on the GUI + struct SwitchKeyInfo + { + QString sw_label; + std::optional sw_default; + int sw_lokey; + int sw_hikey; + }; + std::map m_switchKeyInfo; +}; + + +} // namespace lmms + + +#endif // LMMS_SFZ_CONTROLS_CONFIG_H diff --git a/plugins/SfzPlayer/SfzGlobalState.cpp b/plugins/SfzPlayer/SfzGlobalState.cpp new file mode 100644 index 00000000000..cc1abf2463b --- /dev/null +++ b/plugins/SfzPlayer/SfzGlobalState.cpp @@ -0,0 +1,115 @@ +/* + * SfzGlobalState.cpp - Class which holds information about the current state of the + * SFZ player, such as currently/previously pressed keys, and current midi CC values + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + + +#include "SfzGlobalState.h" +#include "lmms_math.h" + +namespace lmms +{ + +void SfzGlobalState::processTrigger(const SfzTrigger& trigger) +{ + if (trigger.type() == SfzTrigger::Type::ControlChange) + { + m_ccValues.at(trigger.controlChangeNumber().value()) = trigger.controlChangeValue().value(); + } + else if (trigger.type() == SfzTrigger::Type::NoteOn) + { + if (m_lastPlayedSwitchKeys.contains(trigger.key().value())) + { + m_switchKeyPressCounter++; + m_lastPlayedSwitchKeys.at(trigger.key().value()) = m_switchKeyPressCounter; + m_pressedSwitchKeys.at(trigger.key().value()) = true; + m_lastPlayedSwitchKey = trigger.key().value(); + } + } + else if (trigger.type() == SfzTrigger::Type::NoteOff) + { + if (m_lastPlayedSwitchKeys.contains(trigger.key().value())) + { + m_pressedSwitchKeys.at(trigger.key().value()) = false; + } + } + // Update the current random value + m_rand = fastRand(1.0f); +} + +std::optional SfzGlobalState::lastSwitchKeyPressedInRange(int lowKey, int highKey, const std::optional defaultKey) const +{ + // The range might only include some of the switch keys + // If the last played switch key happens to fall in the range, that's awesome! + if (m_lastPlayedSwitchKey >= lowKey && m_lastPlayedSwitchKey <= highKey) { return m_lastPlayedSwitchKey; } + // Otherwise, we need to loop over the list of when each switch key was played and figure out which one was last played + std::optional lastPlayedKey = std::nullopt; + std::optional bestScore = std::nullopt; + for (const auto& [key, counter] : m_lastPlayedSwitchKeys) + { + if (counter == std::nullopt) { continue; } // This key has not been played yet + + if (bestScore == std::nullopt || counter > bestScore) + { + lastPlayedKey = key; + bestScore = counter; + } + } + // If no keys in the region have been played yet, return the default key + if (lastPlayedKey == std::nullopt) { return defaultKey; } + else { return lastPlayedKey; } +} + + +void SfzGlobalState::updateNphFreq(const int key, const float freq) +{ + const float originalKeyFreq = 440.0f * std::exp2((key - 69) / 12.0f); + m_nphFreqRatios.at(key) = freq / originalKeyFreq; +} + +void SfzGlobalState::updateNphPanning(const int key, const float panning) +{ + m_nphPanning.at(key) = panning; +} + + + +void SfzGlobalState::initializeMidiCCValues(const SfzControlsConfig& controlsConfig) +{ + for (int i = 0; i < NumMidiCCs; ++i) + { + m_ccValues.at(i) = controlsConfig.m_set_cc.at(i); + } +} + +void SfzGlobalState::initializeSwitchKeysMap(const SfzControlsConfig& controlsConfig) +{ + for (const auto& [key, info] : controlsConfig.m_switchKeyInfo) + { + m_lastPlayedSwitchKeys[key] = std::nullopt; + m_pressedSwitchKeys[key] = false; + } +} + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzGlobalState.h b/plugins/SfzPlayer/SfzGlobalState.h new file mode 100644 index 00000000000..228c25e1231 --- /dev/null +++ b/plugins/SfzPlayer/SfzGlobalState.h @@ -0,0 +1,109 @@ +/* + * SfzGlobalState.h - Class which holds information about the current state of the + * SFZ player, such as currently/previously pressed keys, and current midi CC values + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_GLOBAL_STATE_H +#define LMMS_SFZ_GLOBAL_STATE_H + +#include +#include +#include + +#include "SfzControlsConfig.h" +#include "SfzTrigger.h" + +namespace lmms +{ + + +class SfzGlobalState +{ +public: + //! Handles updating the last played switch keys and keeps track of which keys are currently being pressed + void processTrigger(const SfzTrigger& trigger); + + //! Returns the midi key number of the last pressed switch key in the range [lowKey, highKey]. + //! It uses m_lastPlayedSwitchKeys array which only tracks when valid switch keys have been pressed, not just any key. + //! If no keys have been pressed yet, it returns defaultKey. + //! If the key range is invalid, it returns defaultKey + //! If no default key was given, it returns std::nullopt + // TODO add unit tests + std::optional lastSwitchKeyPressedInRange(int lowKey, int highKey, const std::optional defaultKey) const; + + //! Returns the current value of the given midi CC knob/controller, or the default value if we haven't recieved any midi CC signals for it yet. + int midiCCValue(const int index) const { return m_ccValues.at(index); } + //! Returns a const array of the current CC knob values + const std::array midiCCValues() const { return m_ccValues; } + + //! Returns the ratio of the frequency of the last played NotePlayHandle on this key compared to the base freq of the key. + //! This is updated every buffer when a NotePlayHandle is active, and is useful for applying pitch bending and microtuning + const float nphKeyFreqRatio(const int key) const { return m_nphFreqRatios.at(key); } + //! Updates the current freq ratio of the key, given the current freq of the active NotePlayHandle + void updateNphFreq(const int key, const float freq); + // Similar functions for NotePlayHandle panning + const float nphKeyPanning(const int key) const { return m_nphPanning.at(key); } + void updateNphPanning(const int key, const float panning); + + //! Returns the random value for the current trigger + const float rand() const { return m_rand; } + + //! Sets initial values for the controls, based on any `set_ccN` opcodes in the header + void initializeMidiCCValues(const SfzControlsConfig& controlsConfig); + + //! Creates a std::map with an element for each switch key defined in the sfz file, indexed by key, with the value being the key press counter of the last time that key was pressed (which is initially 0, or std::nullopt) + void initializeSwitchKeysMap(const SfzControlsConfig& controlsConfig); + +private: + //! Stores a number for every switch key, tracking which order the keys were played in + //! The first key played gets a 1, the second key gets a 2, etc, overwriting when keys are played multiple times. + //! By finding the maximum number in a range, you can find the last played key + //! This uses a std::map instead of a 128-element array for speed, since most keys are not switch keys. + //! The index is the key number, and the value is the key press counter when it was last pressed, or std::nullopt if it has never been pressed. + std::map> m_lastPlayedSwitchKeys = {}; + //! Stores the midi key index of the last played switch key, for easy access. + std::optional m_lastPlayedSwitchKey = std::nullopt; + //! Consequently, we also have to track how many switch keys have been played so far + unsigned long m_switchKeyPressCounter = 0; + + //! Keeps track of which switch keys on the piano are currently being pressed + std::map m_pressedSwitchKeys = {}; // TODO this is not currently used, but may be once `sw_down` opcode is implemented. + + //! Keeps track of the current random value, updated every trigger. Some regions use lorand/hirand opcodes to decide randomy whether to activate or not, and this is the random value they will compare to. + float m_rand = 0.0f; + + //! Stores the current value of all the midi CC knobs/controllers + //! Technically, floats should probably be used to allow for HDCC (high definition CC's) as used in ARIA, but for now dividing by 127 to get a float between 0 and 1 works fine. + std::array m_ccValues = {}; + + //! Stores the current frequencies of active NotePlayHandles (one per key, so overlaping NPH's may not work correctly) as a ratio compared to the freq of the key + std::array m_nphFreqRatios = {}; + //! Stores the current panning of active NotePlayHandles, per key. + std::array m_nphPanning = {}; +}; + + +} // namespace lmms + + +#endif // LMMS_SFZ_GLOBAL_STATE_H diff --git a/plugins/SfzPlayer/SfzOpcodeState.cpp b/plugins/SfzPlayer/SfzOpcodeState.cpp new file mode 100644 index 00000000000..0d663e28b99 --- /dev/null +++ b/plugins/SfzPlayer/SfzOpcodeState.cpp @@ -0,0 +1,182 @@ +/* + * SfzOpcodeState.cpp - Representation of a -like object in an SFZ file, which stores all the configurations regarding how and when a sample should be played + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzOpcodeState.h" +#include + +namespace lmms +{ + + +SfzOpcodeState::SfzOpcodeState() +{ + // Certain arrays need to be initialized here in the constructor, since doing it in the header file is difficult + m_hicc.fill(127); + + // There are some special exceptions with some opcodes which need to be handled separately + // For example, when modulation the `pan` with `pan_oncc10`, according to the SFZ website, the curve type should default to bipolar [-1 to 1], instead of [0 to 1] + // Actually nevermind, it seems like some SFZ's assume it is the default 0-1 curve? Whatever, it's fine + /*if (m_pan.value_curvecc.at(10) == std::nullopt) + { + m_pan.value_curvecc.at(10) = CurveType::Bipolar; + }*/ + // Also, apparantly `amplitude` modulation type defaults to multiplication, not addition + if (m_amplitude.modulationType == std::nullopt) + { + m_amplitude.modulationType = ModulatableOpcode::ModulationType::Mult; + } +} + + + +bool SfzOpcodeState::setOpcodeByStrings(const QString& name, const QString& value) +{ + // These lists hold pointers to all of the opcodes to make them easier to iterate over + std::vector opcodeList = { + &m_sampleFile, + &m_default_path, + + &m_trigger, + + &m_lokey, + &m_hikey, + + &m_sw_lokey, + &m_sw_hikey, + &m_sw_last, + &m_sw_default, + &m_sw_label, + + &m_lovel, + &m_hivel, + + &m_seq_length, + &m_seq_position, + + &m_lorand, + &m_hirand, + + &m_offset, + + &m_loop_mode, + + &m_delay, + &m_delay_random, + + &m_tune, + &m_pitch_keycenter, + &m_pitch_keytrack, + &m_pitch_veltrack, + + &m_fil_type, + &m_cutoff, + &m_resonance, + &m_fil_veltrack, + + &m_amplitude, + &m_amp_veltrack, + + &m_volume, + &m_pan + }; + + std::vector envelopeGeneratorList = { + &m_ampeg, + &m_pitcheg + }; + + std::vector lfoGeneratorList = { + &m_amplfo, + &m_pitchlfo + }; + + bool parsed = false; + bool successful = true; + + for (auto* opcode : opcodeList) + { + //if (parsed) { break; } // Do not break even if the opcode has already been parsed, since some opcodes like "key" are effectively aliases for multiple opcodes (lokey, hikey, and pitch_keytrack) + opcode->parseFromString(name, value, &parsed, &successful); + } + for (auto* envelopeGenerator : envelopeGeneratorList) + { + envelopeGenerator->parseEnvelopeGeneratorOpcode(name, value, &parsed, &successful); + } + for (auto* lfoGenerator : lfoGeneratorList) + { + lfoGenerator->parseLfoGeneratorOpcode(name, value, &parsed, &successful); + } + + + // A few of the per-CC opcodes are handled separately here for now, just to make the code simpler + if (name.startsWith("locc")) + { + int ccNumber = ccNumberFromOpcode(name); // TODO have some kind of error handling for invalid cc numbers + m_locc.at(ccNumber) = value.toInt(&successful); + parsed = true; + } + else if (name.startsWith("hicc")) + { + int ccNumber = ccNumberFromOpcode(name); + m_hicc.at(ccNumber) = value.toInt(&successful); + parsed = true; + } + else if (name.startsWith("set_cc")) + { + int ccNumber = ccNumberFromOpcode(name); + m_set_cc.at(ccNumber) = value.toInt(&successful); + parsed = true; + } + else if (name.startsWith("set_hdcc")) + { + int ccNumber = ccNumberFromOpcode(name); + m_set_cc.at(ccNumber) = value.toFloat(&successful) * 127; + parsed = true; + } + else if (name.startsWith("label_cc")) + { + int ccNumber = ccNumberFromOpcode(name); + m_label_cc.at(ccNumber) = value; + parsed = true; + successful = true; + } + + + if (!successful) + { + qDebug() << "[SFZ Parser] Unable to convert value from string:" << name << "=" << value; + return false; + } + else if (!parsed) + { + qDebug() << "[SFZ Parser] Unknown opcode:" << name; + return false; + } + return true; +} + + + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzOpcodeState.h b/plugins/SfzPlayer/SfzOpcodeState.h new file mode 100644 index 00000000000..7a08d9829b4 --- /dev/null +++ b/plugins/SfzPlayer/SfzOpcodeState.h @@ -0,0 +1,177 @@ +/* + * SfzOpcodeState.h - Representation of a -like object in an SFZ file, which stores all the configurations regarding how and when a sample should be played + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_OPCODE_STATE_H +#define LMMS_SFZ_OPCODE_STATE_H + +#include "SfzOpcodes.h" +#include +#include + +namespace lmms +{ + +class SfzOpcodeState +{ +public: + SfzOpcodeState(); // We need a constructor to initialize things like m_hicc, also to handle special opcode exceptions/defaults + + //! Given an opcode assignment such as `lokey=45`, passing "m_lokey" and "45" to this function + //! will take those values and figure out if "lokey" is a valid opcode, and whether "45" is a valid value for it. + //! If so, it will set the internal member variable corresponding to it, and return true. + //! If it was unsucessful, it will print an error message and return false. + bool setOpcodeByStrings(const QString& name, const QString& value); + + + /***********************************************************************/ + // SFZ OPCODE DEFINITIONS + /***********************************************************************/ + // These are initailized with {opcode_name, default_value}, or if the opcode has multiple aliases, {{name1, name2, ...}, default_value} + + // + // File Paths + // + StringOpcode m_sampleFile {"sample", std::nullopt}; + StringOpcode m_default_path {"default_path", std::nullopt}; + + // + // Trigger Type + // + Opcode m_trigger {"trigger", TriggerType::Attack}; + + // + // Key Conditions + // + // Adding "key" as an alias here, since setting key=something automatically sets all lokey, hikey, and pitch_keycenter to the same value + KeyOpcode m_lokey {{"lokey", "key"}, 0}; + KeyOpcode m_hikey {{"hikey", "key"}, 127}; + + // + // Key Switches + // + KeyOpcode m_sw_lokey {"sw_lokey", 0}; + KeyOpcode m_sw_hikey {"sw_hikey", 127}; + KeyOpcode m_sw_last {"sw_last", std::nullopt}; // The SFZ opcode spec says this should default to 0, but I don't think that's right? + KeyOpcode m_sw_default {"sw_default", std::nullopt}; + StringOpcode m_sw_label {"sw_label", std::nullopt}; + + // + // Velocity Conditions + // + Opcode m_lovel {"lovel", 0}; + Opcode m_hivel {"hivel", 127}; + + // + // Round Robin Conditions + // + Opcode m_seq_length {"seq_length", 1}; + Opcode m_seq_position {"seq_position", 1}; + + // + // Random Conditions + // + FloatOpcode m_lorand {"lorand", 0.0f}; + FloatOpcode m_hirand {"hirand", 1.0f}; + + // + // Sample playback + // + Opcode m_offset {"offset", 0}; // sample play offset in frames + Opcode m_loop_mode {"loop_mode", LoopMode::NoLoop}; + + // + // Delay + // + ModulatableOpcode m_delay {"delay", 0.0f}; // In seconds + FloatOpcode m_delay_random {"delay_random", 0.0f}; + + // + // Pitch + // + Opcode m_tune {"tune", 0}; // in cents + KeyOpcode m_pitch_keycenter {{"pitch_keycenter", "key"}, 60}; // "key" as an alias since it automatically sets "lokey, hikey, and pitch_keycenter to the same value + Opcode m_pitch_keytrack {"pitch_keytrack", 100}; // in cents + Opcode m_pitch_veltrack {"pitch_veltrack", 0}; // in cents + + // + // Filter + // + Opcode m_fil_type {"fil_type", FilterType::Lowpass2Pole}; + FloatOpcode m_cutoff {"cutoff", std::nullopt}; + FloatOpcode m_resonance {"resonance", 0.0f}; + Opcode m_fil_veltrack {"fil_veltrack", 0}; // in cents + + // + // Amplitude + // + ModulatableOpcode m_amplitude {"amplitude", 100.0f}; // In percent + // Overall amplitute velocity modulation + FloatOpcode m_amp_veltrack {"amp_veltrack", 100.0f}; + + // + // Amplitude Envelope Generator (ampeg) + // + EnvelopeOpcodes m_ampeg {"ampeg"}; + + // + // Amplitude LFO + // + LfoOpcodes m_amplfo {"amplfo"}; + + // + // Pitch Envelope Generator (pitcheg) + // + EnvelopeOpcodes m_pitcheg {"pitcheg"}; + + // + // Pitch LFO + // + LfoOpcodes m_pitchlfo {"pitchlfo"}; + + // + // Misc Volume + // + // Gain is the same as volume. Some opcodes use the word volume, some use gain, both are decibals + // It seems that "gain" by itself isn't a real opcode, but gain_ccN and gain_onccN are... just to make the logic eaiser, I have gain as a normal alias. + ModulatableOpcode m_volume {{"volume", "gain", "group_volume"}, 0.0f}; // In decibals + ModulatableOpcode m_pan {"pan", 0.0f}; + + // + // Midi CC + // + // These are handled separately from the other opcodes (for now) because they are more like arrays of opcodes + std::array m_locc = {}; + std::array m_hicc = {}; // NOTE this needs to be initialized to contain all 127 in the constructor, since it's hard to fill an array directly within a header file + //! Default midi CC values + std::array m_set_cc = {}; + //! Human readable names for the controls + std::array, NumMidiCCs> m_label_cc = {}; + + friend class SfzRegion; +}; + +} // namespace lmms + + +#endif // LMMS_SFZ_OPCODE_STATE_H diff --git a/plugins/SfzPlayer/SfzOpcodes.cpp b/plugins/SfzPlayer/SfzOpcodes.cpp new file mode 100644 index 00000000000..ef6587636e0 --- /dev/null +++ b/plugins/SfzPlayer/SfzOpcodes.cpp @@ -0,0 +1,367 @@ +/* + * SfzOpcodes.cpp - Template specializations for parsing different opcode types + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzOpcodes.h" + +#include +#include +#include + +namespace lmms +{ + +template<> +void FloatOpcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) +{ + // Check if the name matches one of this opcode's aliases + if (std::find(m_opcodeNames.begin(), m_opcodeNames.end(), opcodeName) == m_opcodeNames.end()) { return; } + m_value = opcodeValue.toFloat(successful); + *parsed = true; +} + +template<> +void KeyOpcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) +{ + if (std::find(m_opcodeNames.begin(), m_opcodeNames.end(), opcodeName) == m_opcodeNames.end()) { return; } + m_value = opcodeValue.toInt(successful); + // Integer opcodes can also define keys, which can be specified either by a key number or by a string like e4 or c#5 + if (!*successful) { m_value = stringToKeyNum(opcodeValue, successful); } + *parsed = true; +} + +template<> +void StringOpcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) +{ + if (std::find(m_opcodeNames.begin(), m_opcodeNames.end(), opcodeName) == m_opcodeNames.end()) { return; } + m_value = opcodeValue; + *parsed = true; + *successful = true; +} + +// Modulatable Opcodes are a type of float opcode which can be adjusted based on different midi CC knob values +// They have both an initial value defined by their: opcodename=value +// but also modulation defined by: opcodename_oncc123=modulation_amount +// or opcodename_cc123=modulation_amount +// or opcodenamecc123=modulation_amount +// which will make the value be increased proportional to modulation_amount when the midi CC knob 123 is turned up +void ModulatableOpcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) +{ + for (QString alias : m_opcodeNames) + { + if (opcodeName == alias) + { + m_value = opcodeValue.toFloat(successful); + *parsed = true; + } + else if (opcodeName.startsWith(alias + "_oncc") || opcodeName.startsWith(alias + "_cc") || opcodeName.startsWith(alias + "cc")) + { + value_oncc.at(ccNumberFromOpcode(opcodeName)) = opcodeValue.toFloat(successful); + *parsed = true; + } + else if (opcodeName.startsWith(alias + "_curvecc")) + { + value_curvecc.at(ccNumberFromOpcode(opcodeName)) = opcodeValue.toInt(successful); + *parsed = true; + } + else if (opcodeName.startsWith(alias + "_mod")) + { + *parsed = true; + if (opcodeValue == "add") { modulationType = ModulationType::Add; } + else if (opcodeValue == "mult") { modulationType = ModulationType::Mult; } + else { qDebug() << "[SFZ Parser] Warning: Unknown Modulation Type:" << opcodeValue; *successful = false; } + } + } +} + + +// The modulation of Modulatable opcodes can change whenever a midi CC knob changes. There are 128 of them, +// which is a bit much to keep checking every single frame when generating audio. +// Instead, the current modulation value is only calculated and cached each time a midi CC event is sent, which saves a lot of compute. +void ModulatableOpcode::updateCachedModulation(const std::array& ccValues) +{ + // Reset the modulation amount and recompute it + // Depending on the modulation type, the initial modulation should be 0 for addition (add nothing) or 1 for multiplication (multiply by identity). + switch (modulationType.value_or(ModulationType::Add)) + { + case ModulationType::Add: + cachedModulation = 0.0f; + break; + case ModulationType::Mult: + cachedModulation = 1.0f; + break; + } + + // TODO this may be optimized if we only loop through the CC's which are actually used by the region + for (int i = 0; i < NumMidiCCs; ++i) + { + if (value_oncc.at(i) == 0.0f) { continue; } // Skip unused CC's. This helps with the multiply modulation type, since multiplying by the default 0 values would not be great. + float scaledModulationValue = 0.0f; + switch (value_curvecc.at(i).value_or(0)) + { + case CurveType::Default: + scaledModulationValue = value_oncc.at(i) * (ccValues.at(i) / 127.0f); + break; + case CurveType::Bipolar: + scaledModulationValue = value_oncc.at(i) * (2.0f * ccValues.at(i) / 127.0f - 1.0f); + break; + case CurveType::Inverted: + scaledModulationValue = value_oncc.at(i) * (1.0f - ccValues.at(i) / 127.0f); + break; + case CurveType::BipolarInverted: + scaledModulationValue = value_oncc.at(i) * (1.0f - 2.0f * ccValues.at(i) / 127.0f); + break; + default: + // Unknown/custom curve type. These have not yet been implemented, so default to Default + scaledModulationValue = value_oncc.at(i) * ccValues.at(i) / 127.0f; + break; + } + // Depending on the modulation type, add or multiply the modulation value. + switch (modulationType.value_or(ModulationType::Add)) + { + case ModulationType::Add: + cachedModulation += scaledModulationValue; + break; + case ModulationType::Mult: + cachedModulation *= scaledModulationValue / 100; // Divide by 100, since most SFZ's seem to use percents + break; + } + } +} + + + + +// Envelope Generator Opcodes +void EnvelopeOpcodes::parseEnvelopeGeneratorOpcode(const QString& opcode, const QString& value, bool* parsed, bool* successful) +{ + // Pass the opcode name/value to all of the bundled opcodes. + // If it matches one of them, that's great. If not, they will return and nothing will happen. + // TODO is this error handling with "successful" correct? + delay.parseFromString(opcode, value, parsed, successful); + attack.parseFromString(opcode, value, parsed, successful); + hold.parseFromString(opcode, value, parsed, successful); + decay.parseFromString(opcode, value, parsed, successful); + sustain.parseFromString(opcode, value, parsed, successful); + release.parseFromString(opcode, value, parsed, successful); + depth.parseFromString(opcode, value, parsed, successful); + + vel2delay.parseFromString(opcode, value, parsed, successful); + vel2attack.parseFromString(opcode, value, parsed, successful); + vel2hold.parseFromString(opcode, value, parsed, successful); + vel2decay.parseFromString(opcode, value, parsed, successful); + vel2sustain.parseFromString(opcode, value, parsed, successful); + vel2release.parseFromString(opcode, value, parsed, successful); + vel2depth.parseFromString(opcode, value, parsed, successful); +} + + +// LFO Opcodes +void LfoOpcodes::parseLfoGeneratorOpcode(const QString& opcode, const QString& value, bool* parsed, bool* successful) +{ + delay.parseFromString(opcode, value, parsed, successful); + fade.parseFromString(opcode, value, parsed, successful); + freq.parseFromString(opcode, value, parsed, successful); + depth.parseFromString(opcode, value, parsed, successful); +} + + + +// +// Specialized Enum-like Opcodes +// + +template<> +void Opcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) +{ + if (std::find(m_opcodeNames.begin(), m_opcodeNames.end(), opcodeName) == m_opcodeNames.end()) { return; } + *parsed = true; + + if (opcodeValue == "attack") { m_value = TriggerType::Attack; } + else if (opcodeValue == "release") { m_value = TriggerType::Release; } + //else if (opcodeValue == "first") { m_value = TriggerType::First; } // TODO To be implemented + //else if (opcodeValue == "legato") { m_value = TriggerType::Legato; } // TODO To be implemented + //else if (opcodeValue == "release_key") { m_value = TriggerType::ReleaseKey; } // TODO To be implemented + else + { + qDebug() << "[SFZ Parser] Unknown trigger:" << opcodeValue; + return; + } + *successful = true; +} + + +template<> +void Opcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) +{ + if (std::find(m_opcodeNames.begin(), m_opcodeNames.end(), opcodeName) == m_opcodeNames.end()) { return; } + *parsed = true; + + if (opcodeValue == "no_loop") { m_value = LoopMode::NoLoop; } + else if (opcodeValue == "one_shot") { m_value = LoopMode::OneShot; } + //else if (opcodeValue == "loop_continuous") { m_value = LoopMode::LoopContinuous; } // Not implemented yet, since we don't currently have a way to get loop point data from sample files + //else if (opcodeValue == "loop_sustain") { m_value = LoopMode::LoopSustain; } + else + { + qDebug() << "[SFZ Parser] Unknown loop_mode:" << opcodeValue; + return; + } + *successful = true; +} + + +template<> +void Opcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) +{ + if (std::find(m_opcodeNames.begin(), m_opcodeNames.end(), opcodeName) == m_opcodeNames.end()) { return; } + *parsed = true; + + if (opcodeValue == "lpf_1p") { m_value = FilterType::Lowpass1Pole; } + else if (opcodeValue == "lpf_2p") { m_value = FilterType::Lowpass2Pole; } + else if (opcodeValue == "hpf_1p") { m_value = FilterType::Highpass1Pole; } + else if (opcodeValue == "hpf_2p") { m_value = FilterType::Highpass2Pole; } + else if (opcodeValue == "bpf_2p") { m_value = FilterType::Bandpass2Pole; } + else if (opcodeValue == "brf_2p") { m_value = FilterType::Bandstop2Pole; } + else + { + qDebug() << "[SFZ Parser] Unknown filter type:" << opcodeValue; + return; + } + *successful = true; +} + + + + + + + +// +// Helper Functions +// + +int ccNumberFromOpcode(const QString& opcode) +{ + // Match for `ccN` where N is a number + QRegularExpression re("cc\\d+"); + QRegularExpressionMatch match = re.match(opcode); + if (!match.hasMatch()) { qDebug() << "[SFZ Parser] Unable to find CC number in opcode:" << opcode; return 0; } + // Get the number from that string + QString numberPart = match.captured(0).split("cc")[1]; + bool successfulToInt = false; + int ccNumber = numberPart.toInt(&successfulToInt); + if (!successfulToInt) { qDebug() << "[SFZ Parser] Unable to convert CC number to int:" << opcode; return 0; } + // Normal midi lets you choose between 0-127 for the CC number. According to the SFZ format website, ARIA and SFZ 2 allow for extended CC numbers. Most have not been implemented here, so to keep things safe, we cap it and give a warning. + if (ccNumber < 0 || ccNumber > NumMidiCCs) { qDebug() << "[SFZ Parser] Midi CC number" << ccNumber << "is out of range 0-127. This is not yet implmented."; return 0;} + return ccNumber; +} + + +int stringToKeyNum(QString keyString, bool* successful) +{ + keyString = keyString.toLower(); + // Match for a number at the end (potentially including a "-" at the start) + QRegularExpression octaveRE("(-)?\\d+$"); + QRegularExpressionMatch octaveMatch = octaveRE.match(keyString); + if (!octaveMatch.hasMatch()) { qDebug() << "[SFZ Parser] Unable to parse octave from key string:" << keyString; return -1; } + int octave = octaveMatch.captured(0).toInt(successful); + // Match for the letters at the start (including #) + QRegularExpression keyRE("^[a-zA-Z#]+"); + QRegularExpressionMatch keyMatch = keyRE.match(keyString); + if (!keyMatch.hasMatch()) { qDebug() << "[SFZ Parser] Unable to parse key from key string:" << keyString; return -1; } + QString key = keyMatch.captured(0); + + int keyOffset = 0; + + if (key == "c") { keyOffset = 0; } // C is 0 since that's where the octaves change + else if (key == "c#" || key == "db") { keyOffset = 1; } + else if (key == "d") { keyOffset = 2; } + else if (key == "d#" || key == "eb") { keyOffset = 3; } + else if (key == "e") { keyOffset = 4; } + else if (key == "f") { keyOffset = 5; } + else if (key == "f#" || key == "gb") { keyOffset = 6; } + else if (key == "g") { keyOffset = 7; } + else if (key == "g#" || key == "ab") { keyOffset = 8; } + else if (key == "a") { keyOffset = 9; } + else if (key == "a#" || key == "bb") { keyOffset = 10; } + else if (key == "b") { keyOffset = 11; } + else + { + *successful = false; + qDebug() << "[SFZ Parser] Unable to parse key string, Invalid key:" << keyString; + return -1; + } + *successful = true; + // For some reason, C1 is midi key 24, so eveything is offset by 24 + return 24 + keyOffset + 12 * (octave - 1); +} + + +QString keyNumToString(int keyNum) +{ + QString octave = QString::number(std::floor(static_cast(keyNum - 24) / 12) + 1); + QString key = ""; + switch (keyNum % 12) + { + case 0: + key = "C"; + break; + case 1: + key = "C#"; + break; + case 2: + key = "D"; + break; + case 3: + key = "D#"; + break; + case 4: + key = "E"; + break; + case 5: + key = "F"; + break; + case 6: + key = "F#"; + break; + case 7: + key = "G"; + break; + case 8: + key = "G#"; + break; + case 9: + key = "A"; + break; + case 10: + key = "A#"; + break; + case 11: + key = "B"; + break; + } + return key + octave; +} + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzOpcodes.h b/plugins/SfzPlayer/SfzOpcodes.h new file mode 100644 index 00000000000..3177d146027 --- /dev/null +++ b/plugins/SfzPlayer/SfzOpcodes.h @@ -0,0 +1,267 @@ +/* + * SfzOpcodeState.h - Defining all supported opcodes, along with helper classes/structs/functions for parsing and storing data + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_OPCODES_H +#define LMMS_SFZ_OPCODES_H + +#include +#include +#include +#include +#include + +namespace lmms +{ + + +//! Normal MIDI CC's range from 0 to 127. More advanced SFZ's go beyond that, but for now we cap it at 128. This should be extended in the future. +constexpr const int NumMidiCCs = 128; + + +/* Helper Functions */ + +//! Helper function for converting strings like "A5" or "B#2" into integers representing keys on the midi keyboard. +int stringToKeyNum(QString keyString, bool* successful); +//! Helper function for converting integers like 24 or 45 representing midi keys back into strings representing the key and octave on the keyboard (essentially the inverse of `stringToKeyNum`) +QString keyNumToString(int keyNum); +//! Helper function for taking an opcode name such as "set_cc45" and extracting the number "45" from it. +//! If the string does not contain a substring in the form "ccN" where N is a number, it will return print an error and return 0. +//! Likewise, if the number is outside the range 0-127, it will print an error and return 0. +int ccNumberFromOpcode(const QString& opcode); + + + +/* Helper structs for opcodes */ + +//! Due to issues with having different template object pointers in an array, this base class which all opcodes derive from makes things easier +struct BaseOpcode +{ + //! Given a string like "pitch_keytrack=1200" which has been split into name/value as "pitch_keytrack", "1200", this function updates the internal value if the name matches. + virtual void parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) = 0; +}; +//! A base struct for all opcodes, with the name(s) and the value +template +struct Opcode : BaseOpcode +{ + // Using a vector here, since some opcodes have multiple aliases + std::vector m_opcodeNames; + //! Due to issues with the compiler not recognizing implicit conversion from Opcode> directly to "type", all opcodes use std::optional on the inside. + std::optional m_value; + + Opcode(QString name, std::optional defaultValue) : m_opcodeNames({name}), m_value(defaultValue) {} + Opcode(std::vector names, std::optional defaultValue) : m_opcodeNames(names), m_value(defaultValue) {} + // Redefining operators to make opcodes easy to work with + Opcode& operator =(const T value) { m_value = value; return *this; } + operator T() const { return m_value.value(); } + // This is not ideal, but here's a wrapper function which does the same thing as std::optional's value_or + const T value_or(const T alternative) const { return m_value.value_or(alternative); } + // Comparing the opcode to std::nullopt had issues, since the compiler did not want to do two implicit conversions(?) so here the comparision is expressly defined. + bool operator==(std::nullopt_t other) const { return m_value == std::nullopt; } + + void parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) override; +}; + +//! Float/decimal opcodes (such as amplitude, panning, etc) +using FloatOpcode = Opcode; + +//! Integer opcodes (such as lovel, seq_length, seq_position, offset, etc) +using IntOpcode = Opcode; + +//! Key opcodes (such as lokey, hikey, pitch_keycenter, etc) +using KeyOpcode = Opcode; + +//! String opcodes (such as sample file path) +using StringOpcode = Opcode; + +//! Many opcodes can be modulated by MIDI CC knobs, so each of them needs an array to store the modulation weights for each one. +struct ModulatableOpcode : FloatOpcode +{ + std::array value_oncc = {}; + //! Additionally, each modulation can be mapped with a curve, such as to go from 0 to 1, -1 to 1, something nonlinear, etc, so here's a helper enum for that + std::array, NumMidiCCs> value_curvecc = {}; // This uses std::optional because some opcode curves are meant to default to certain values, (pan_curvecc7 defaults to bipolar), so we need a way to tell if it was set or not. + //! Also, I never knew this, but apparantly some opcodes can have different modulation types set with opcode_mod=add or opcode_mod=mult, for whether to add the modulation or multiply by it https://sfzformat.com/opcodes/_mod/ + enum class ModulationType + { + Add, + Mult + }; + std::optional modulationType; // Once again using std::optional since some opcodes have different defaults, and we need to know if it was intentionally set or not so that we don't override it. + //! Store the current total midi CC modulation amounts for the different targets, just so that we don't + // have to recalculate them every buffer, instead only when a trigger occurs. + float cachedModulation = 0.0f; + + ModulatableOpcode(QString name = "", float defaultValue = 0.0f) : FloatOpcode(name, defaultValue) {}; + ModulatableOpcode(std::vector names = {}, float defaultValue = 0.0f) : FloatOpcode(names, defaultValue) {}; + //! Redefine the value/conversion function to return the sum of the base opcode value and whatever the modulation is currently. Or, if the modultion type if multiplication, multiply the two. + operator float() const + { + return (modulationType.value_or(ModulationType::Add) == ModulationType::Add) + ? m_value.value() + cachedModulation + : m_value.value() * cachedModulation; + } + //! Helper function for parsing these kinds of opcodes, where you have both `opcode` and `opcode_onccN` where N is the midi cc number, so + // that the code isn't duplicated for every modulatable parameter. + void parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful) override; + //! Helper function to update the cached CC modulation amount every time a midi Control Change trigger occurs + void updateCachedModulation(const std::array& ccValues); +}; +// Helper class for curve types. This may have to be reworked when custom headers are added +enum CurveType +{ + Default = 0, // 0 to 1 + Bipolar = 1, // -1 to 1 + Inverted = 2, // 1 to 0 + BipolarInverted = 3, // 1 to -1 + //Concave = 4, // TODO not implemented yet + //XfinPowerCurve = 5, // TODO not implemented yet + //XfoutPowerCurve = 6, // TODO not implemented yet +}; + +// Things like amplitude, pitch, and filter freq envelopes and lfo's all have very similar parameters, so it makes sense to put +// them all together in handy definitions +struct EnvelopeOpcodes +{ + // Envelope parameters (these can be modulated by midi CCs) + ModulatableOpcode delay; + ModulatableOpcode attack; + ModulatableOpcode hold; + ModulatableOpcode decay; + ModulatableOpcode sustain; + ModulatableOpcode release; + // Depth is only used for pitcheg and fileg, not ampeg, but we have it here anyway + ModulatableOpcode depth; + // Velocity modulation amount (TODO: not used yet) + FloatOpcode vel2delay; + FloatOpcode vel2attack; + FloatOpcode vel2hold; + FloatOpcode vel2decay; + FloatOpcode vel2sustain; + FloatOpcode vel2release; + FloatOpcode vel2depth; + + // Initialize opcodes with correct names and default values + EnvelopeOpcodes(QString name) + : delay(name + "_delay", 0.0f) + , attack(name + "_attack", 0.0f) + , hold(name + "_hold", 0.0f) + , decay(name + "_decay", 0.0f) + , sustain(name + "_sustain", 100.0f) + , release(name + "_release", 0.0f) + , depth(name + "_depth", 0.0f) + , vel2delay(name + "_vel2delay", 0.0f) + , vel2attack(name + "_vel2attack", 0.0f) + , vel2hold(name + "_vel2hold", 0.0f) + , vel2decay(name + "_vel2decay", 0.0f) + , vel2sustain(name + "_vel2sustain", 0.0f) + , vel2release(name + "_vel2release", 0.0f) + , vel2depth(name + "_vel2depth", 0.0f) + {} + //! Helper function to update all of the cached modulations for the individual opcodes + void updateCachedModulation(const std::array& ccValues) + { + delay.updateCachedModulation(ccValues); + attack.updateCachedModulation(ccValues); + hold.updateCachedModulation(ccValues); + decay.updateCachedModulation(ccValues); + sustain.updateCachedModulation(ccValues); + release.updateCachedModulation(ccValues); + depth.updateCachedModulation(ccValues); + } + //! Helper function for parsing all these envelope generator opcodes + void parseEnvelopeGeneratorOpcode(const QString& opcode, const QString& value, bool* parsed, bool* successful); +}; + +// Simple SFZ1 LFO Opcodes, for amplfo, pitchlfo, and fillfo +struct LfoOpcodes +{ + ModulatableOpcode delay; + ModulatableOpcode fade; + ModulatableOpcode freq; + ModulatableOpcode depth; + + // Initialize opcodes with correct names and default values + LfoOpcodes(QString name) + : delay(name + "_delay", 0.0f) + , fade(name + "_fade", 0.0f) + , freq(name + "_freq", 0.0f) + , depth(name + "_depth", 0.0f) + {} + //! Helper function to update all of the cached modulations for the individual opcodes + void updateCachedModulation(const std::array& ccValues) + { + delay.updateCachedModulation(ccValues); + fade.updateCachedModulation(ccValues); + freq.updateCachedModulation(ccValues); + depth.updateCachedModulation(ccValues); + } + //! Helper function for parsing these lfo generator opcodes + void parseLfoGeneratorOpcode(const QString& opcode, const QString& value, bool* parsed, bool* successful); +}; + + + + +// Specific Template specializations for Enum-like opcodes + + +enum class TriggerType +{ + Attack, + Release, + //First, // TODO To be implemented + //Legato, // TODO To be implemented + //ReleaseKey // TODO To be implemented +}; +template<> void Opcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful); + + + +enum class LoopMode +{ + NoLoop, + OneShot, + //LoopContinuous, // To be implemented + //LoopSustain +}; +template<> void Opcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful); + + + +enum class FilterType +{ + Lowpass1Pole, + Lowpass2Pole, + Highpass1Pole, + Highpass2Pole, + Bandpass2Pole, + Bandstop2Pole +}; +template<> void Opcode::parseFromString(const QString& opcodeName, const QString& opcodeValue, bool* parsed, bool* successful); + + + +} // namespace lmms + + +#endif // LMMS_SFZ_OPCODES_H diff --git a/plugins/SfzPlayer/SfzParser.cpp b/plugins/SfzPlayer/SfzParser.cpp new file mode 100644 index 00000000000..94009c717ae --- /dev/null +++ b/plugins/SfzPlayer/SfzParser.cpp @@ -0,0 +1,368 @@ +/* + * SfzParser.cpp - Helper class for parsing .sfz files into objects which can be used by the SFZ player + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzParser.h" +#include "SfzOpcodeState.h" +#include +#include +#include + +namespace lmms +{ + + +bool SfzParser::parseSfzFile(const QString& filePath, std::vector& outputRegions, SfzControlsConfig& controlsConfig) +{ + // Clear the vector of regions just in case anything is inside it from before + outputRegions.clear(); + + // Open the .sfz file + QDir parentDirectory = QFileInfo(filePath).absoluteDir(); + QFile file(filePath); + + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + qDebug() << "[SFZ Parser] Error, could not read file:" << filePath; + return false; + } + + QString fileContents = file.readAll(); + + // Before parsing the headers and opcodes, we need to hande #include and #define statements + // This amounts to recursively loading and copy/pasting the contents of the other files where the #include is, and find/replacing the #define words with their values + std::map defineMap = {}; // Just a temporary helper variable so that the recursion branches have a persistant object to keep track of #defines + fileContents = recursiveHandleIncludeAndDefineStatements(parentDirectory, fileContents, defineMap); + + qDebug().noquote() << "[SFZ Parser] DEBUG: Preprocessed SFZ file contents:\n" << fileContents; // testing + + // Now that all the includes/defines are handled, loop through the whole contents and split it up into segments so that it can be parsed + // For example, if you have a sfz file like: + /* + + sample=test.wav + key=70 + */ + // Then the parsed segments would be "", "sample=test.wav", and "key=70" + // In this example they were all on separate lines, but they don't have to be: + /* + ampeg_release=0.3 lokey=45 hikey=49 + */ + // This would still be parsed into segments as "", "ampeg_release=0.3", "lokey=45", and "hikey=49" + // According to the SFZ format website, there must never be a space on either side of the = for an opcode assignment (so something like "lokey = 45" would be invalid). That makes things simpler. + // However, there can be spaces in the value assigned to the opcode. For example, if your sample file name has spaces: + /* + sample=Grand Piano G4 MP.wav key=49 + */ + // This would be parsed as "", "sample=Grand Piano G4 MP.wav", and "key=49" + // Essentially, we need a way to be able to split up the file on whitespace, while leaving intact any opcode assignments which have spaces in their right hand side. + std::vector parsedSegments; + + // We start by splitting on newline + for (QString line : fileContents.split("\n")) + { + // Remove comments from end of line + line = line.split("//")[0]; + + line.replace(">", "> "); // Real quick, make sure there is whitespace between header keywords and anything else after them. One .sfz file I found did `curve_index=11` in it, with no space between, which makes parsing complicated, so to fix it we just insert an extra space. + + // We can start by splitting on whitespace, but then we will have to go back and group together any chunks which belong to the same opcode assignment, if for example the sample file included spaces + // For example, if we had the line: + // sample=My Favorite Sample.flac key=99 + // Then it would initially be split into "", "sample=My", "Favorite", "Sample.flac", and "key=99" + // However, we notice that "Favorite" and "Sample.flac" are not valid opcode assignments since they don't have an "=", and they're not headers, since you don't have those + // So they must belong to the previous opcode assignment, "sample=My" + // If we connect them together, we get "", "sample=My Favorite Sample.flac", and "key=99", just as we wanted! + std::vector lineSegments; + for (QString segment : line.split(QRegularExpression("\\s"), Qt::SkipEmptyParts)) // Note: Technically by skipping empty parts, double-spaces within names will be lost. Is this okay? I'm not sure. + { + if (segment.contains("=") || (segment.front() == '<' && segment.back() == '>')) + { + // If this is an opcode assignment or a
, go ahead and add it to the list as it is + lineSegments.push_back(segment); + } + else + { + // If it's not, then it must belong to the previous segment, so let's concatinate it (with a space, to account for the space taken from the split) + if (lineSegments.size() > 0) + { + lineSegments.back() += " " + segment; + } + else + { + qDebug() << "[SFZ Parser] Warning: Encountered non-header, non-opcode assignment at start of line:" << line; + } + } + } + // Add the segments from the current line to the overall list + parsedSegments.insert(parsedSegments.end(), lineSegments.begin(), lineSegments.end()); + } + + + + // Now that all the segments are collected, we can go through them all and construct the SfzRegions + + // Create a base opcode state list to keep track of which defaults are + SfzOpcodeState globalState; + // Regions can be further organized within groups, which can define default opcodes for all the regions inside them + // Keep track of the current group and region states. These will be re-initialized to whatever the global/group settings are, once we encounter a new group or region header + // (Also, ARIA supports an additional header, which is essentially the same as a group, but one level up, so you can group your groups under masters. Not a whole lot of SFZ's use it, but some do.) + // (The heirarchy of headers essentially goes: , , , ) + SfzOpcodeState currentMasterState = globalState; + SfzOpcodeState currentGroupState = globalState; + SfzOpcodeState currentRegionState = globalState; + + // Track whether we are in global, group, region, or other header + Header currentHeader = Header::None; + for (QString segment : parsedSegments) + { + // Track whether we are entering a new header region + if (segment.front() == '<' && segment.back() == '>') + { + // If we were previously in , propagate down the current defaults to any future , , or headers + if (currentHeader == Header::Global) { currentMasterState = currentGroupState = currentRegionState = globalState; } + // If we were previously in a , do the same for and + if (currentHeader == Header::Master) { currentGroupState = currentRegionState = currentMasterState; } + // If we were previously in a , do the same for + if (currentHeader == Header::Group) { currentRegionState = currentGroupState; } + // If we were previously in a , wrap it up and add it to the output vector + if (currentHeader == Header::Region) { outputRegions.emplace_back(currentRegionState); } + + if (segment == "") + { + currentHeader = Header::Global; + // If we are entering a new global header, reset the current global settings + // TODO is this correct? + globalState = SfzOpcodeState(); + // (also real quick, because the header sets the `default_path` opcode but might come before any header, we have to also set it here just in case) + globalState.m_default_path = controlsConfig.m_default_path; + } + else if (segment == "") + { + currentHeader = Header::Master; + // Reset the current master settings to the global defaults + currentMasterState = globalState; + } + else if (segment == "") + { + currentHeader = Header::Group; + // Reset the current group settings to the master defaults + currentGroupState = currentMasterState; + } + else if (segment == "") + { + currentHeader = Header::Region; + // Reset the current region settings to the group defaults + currentRegionState = currentGroupState; + } + else if (segment == "") + { + currentHeader = Header::Control; + } + else if (segment == "") + { + currentHeader = Header::Curve; + } + else if (segment == "") + { + currentHeader = Header::Effect; + } + else + { + qDebug() << "[SFZ Parser] Error, unknown header type:" << segment; + return false; + } + continue; // If the header is recognized, move to the next line and start parsing things + // TODO handle more header types + } + + // If this line/segment isn't a new header, it must be an opcode assignment + auto opcodeNameAndValue = segment.split("="); + if (opcodeNameAndValue.size() != 2) { qDebug() << "[SFZ Parser] Syntax error, could not parse opcode assignment:" << segment; return false; } + // Depending on the current header/zone, opcode assignments are handled differently: + switch (currentHeader) + { + case Header::Global: + { + // If we are in the global header, update the opcodes of the global state, which will propagate down to any groups or regions (or master, if the sfz uses any) after it + globalState.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + break; + } + case Header::Master: + { + // If we are in the master header, update the opcodes of the master state, which will similarly propagate down to any groups or regions after it + currentMasterState.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + break; + } + case Header::Group: + { + // If we are in a group, update the opcodes of the current group state, which will propagate to any regions below it + currentGroupState.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + break; + } + case Header::Region: + { + // If we are within a region, update the opcodes of the current region state + currentRegionState.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + break; + } + case Header::Control: + { + controlsConfig.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + // For some reason, one of the things in the controls header is `default_path`, which is supposed to propagate down to the regions below. This doesn't feel very control-related, but it is what it is. + // To do this, we also force-set the `default_path` for the current , , and , to make sure it reaches all future regions (until another header changes it) + if (opcodeNameAndValue[0] == "default_path") + { + globalState.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + currentMasterState.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + currentGroupState.setOpcodeByStrings(opcodeNameAndValue[0], opcodeNameAndValue[1]); + } + break; + } + case Header::Curve: + { + qDebug() << "[SFZ Parser] Warning: The header has not been implemented yet. Encountered opcode assignment:" << segment; + break; + } + case Header::Effect: + { + qDebug() << "[SFZ Parser] Warning: The header has not been implemented yet. Encountered opcode assignment:" << segment; + break; + } + default: + { + qDebug() << "[SFZ Parser] Error: Encountered line within invalid header" << segment; + return false; + } + } + // For the GUI, it's nice to keep track of which midi CC's are being used, and only display those (instead of all 128) + // This is a bit hacky, but just checking if the opcode has "ccN" inside it and parsing that number works fine + QRegularExpression re("cc\\d+"); + QRegularExpressionMatch match = re.match(opcodeNameAndValue[0]); + if (match.hasMatch()) + { + int ccNumber = match.captured(0).split("cc")[1].toInt(); + if (ccNumber >= 0 && ccNumber <= NumMidiCCs) { controlsConfig.m_activeMidiCCs.at(ccNumber) = true; } + } + } + // Check one last time in case the file ended with a region and didn't get added + if (currentHeader == Header::Region) { outputRegions.emplace_back(currentRegionState); } + + + // Just so that the GUI doesn't have to loop over all the regions to find the switch keys, let's also + // make a list of them in the controlsConfig object for easy access + for (const auto& region : outputRegions) + { + if (region.m_sw_last != std::nullopt) + { + SfzControlsConfig::SwitchKeyInfo info; + info.sw_label = region.m_sw_label.value_or(""); + info.sw_lokey = region.m_sw_lokey; + info.sw_hikey = region.m_sw_hikey; + info.sw_default = region.m_sw_default.m_value; // Directly accessing m_value, since casting to std::optional is difficult; it always wants to cast to int or something, and crashes when it's std::nullopt? + controlsConfig.m_switchKeyInfo.insert({region.m_sw_last, info}); + } + } + + // Now that all the opcodes have been parsed into regions and added to the output vector, we are done! + // The samples themselves still need to be loaded, but that's a job for later + return true; +} + + + + + +QString SfzParser::recursiveHandleIncludeAndDefineStatements(const QDir& parentDirectory, QString fileContents, std::map& defineMap) +{ + // Reconstruct the file line by line as we parse the defines and includes + QStringList reconstructedSegments; + + for (QString line : fileContents.split("\n")) + { + if (line.startsWith("#define")) + { + // Split on whitespace + const auto segments = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + // A define statement should have 3 parts, the #define, the $variable name, and the value + if (segments.size() != 3) + { + qDebug() << "[SFZ Parser] Ill-formed define statment:" << line; + continue; + } + const QString variableName = segments[1]; + const QString variableValue = segments[2]; + defineMap[variableName] = variableValue; + // A define variable should probably start with a $ + if (variableName.front() != '$') { qDebug() << "[SFZ Parser] Warning: #define variable name does not start with $:" << line; } + } + else if (line.startsWith("#include ")) + { + // Replace any of the defined variables before parsing the include path, since some SFZ files use $variables in them + for (const auto& [variableName, variableValue] : defineMap) + { + line.replace(variableName, variableValue); + } + + const auto segments = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + // An include statement should have two parts, the #include and the path + if (line.split("#include ").size() != 2) + { + qDebug() << "[SFZ Parser] Ill-formed include statment:" << line; + continue; + } + + QString relativePath = line.split("#include ")[1]; + relativePath.replace("\"", ""); // Remove " " from start and end + const QString absolutePath = parentDirectory.absoluteFilePath(relativePath); + + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + qDebug() << "[SFZ Parser] Could not read included file:" << absolutePath << "from include statement:" << line; + continue; + } + + QString includedFileContents = file.readAll(); + + // Resolve any includes and defines in this new file too before pasting it in + includedFileContents = recursiveHandleIncludeAndDefineStatements(parentDirectory, includedFileContents, defineMap); + reconstructedSegments.push_back(includedFileContents); + } + else + { + // Replace any of the defined variables before adding the line to the reconstructed file + for (const auto& [variableName, variableValue] : defineMap) + { + line.replace(variableName, variableValue); + } + reconstructedSegments.push_back(line); + } + } + + return reconstructedSegments.join("\n"); +} + + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzParser.h b/plugins/SfzPlayer/SfzParser.h new file mode 100644 index 00000000000..7744bafd69b --- /dev/null +++ b/plugins/SfzPlayer/SfzParser.h @@ -0,0 +1,70 @@ +/* + * SfzParser.h - Helper class for parsing .sfz files into objects which can be used by the SFZ player + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_PARSER_H +#define LMMS_SFZ_PARSER_H + +#include "SfzRegion.h" +#include "SfzControlsConfig.h" +#include +#include +#include + +namespace lmms +{ + +class SfzParser +{ +public: + //! Loads the .sfz file at `filePath`, parses it for all the sfz headers and opcode assignments, and populates the `outputRegions` vector + //! with new SfzRegion objects based on the configurations in the .sfz file. Additionally, it sets up `controlsConfig` with misc info about + //! switch keys and midi CC's, which is useful for the GUI. + static bool parseSfzFile(const QString& filePath, std::vector& outputRegions, SfzControlsConfig& controlsConfig); + +private: + //! Helper function to parse any #include or #define statements from a string, recursively so that the included files have their includes and defines handled too + //! The defineMap parameter should be empty {} when initially called; it internally keeps track of what $keywords are defined to be what as the recursion goes down each path + //! The defineMap is passes as a reference so that it persists between recursion branches. This doesn't feel quite right, but some SFZ's use it to, for instance, #define the sample file extensions (.flac, .wav, etc) in a separate #include file, and expect that #define to apply to the remainder of the file. + static QString recursiveHandleIncludeAndDefineStatements(const QDir& parentDirectory, QString fileContents, std::map& defineMap); + + //! All the possible header types in an sfz file + enum class Header + { + Control, + Global, + Master, + Group, + Region, + Curve, // TODO Not implemented yet + Effect, // TODO Not implemented yet + None + }; +}; + + + +} // namespace lmms + + +#endif // LMMS_SFZ_PARSER_H diff --git a/plugins/SfzPlayer/SfzPlayer.cpp b/plugins/SfzPlayer/SfzPlayer.cpp new file mode 100644 index 00000000000..d89b52a6aa8 --- /dev/null +++ b/plugins/SfzPlayer/SfzPlayer.cpp @@ -0,0 +1,446 @@ +/* + * SfzPlayer.cpp - Simple SFZ instrument player + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzPlayer.h" +#include "SfzParser.h" + +#include +#include + +#include "Engine.h" +#include "InstrumentPlayHandle.h" +#include "InstrumentTrack.h" +#include "PathUtil.h" +#include "ConfigManager.h" +#include "SfzPlayerView.h" +#include "Song.h" +#include "ThreadPool.h" +#include "embed.h" +#include "interpolation.h" +#include "plugin_export.h" + +#include "GuiApplication.h" +#include "MainWindow.h" + +namespace lmms +{ + +extern "C" { +Plugin::Descriptor PLUGIN_EXPORT sfzplayer_plugin_descriptor = { + LMMS_STRINGIFY(PLUGIN_NAME), + "SFZ Player", + QT_TRANSLATE_NOOP("PluginBrowser", "Load .sfz Instrument Files"), + "Keratin <3", + 0x0100, + Plugin::Type::Instrument, + new PluginPixmapLoader("logo"), + "sfz", + nullptr, +}; +PLUGIN_EXPORT Plugin* lmms_plugin_main(Model* m, void*) +{ + return new SfzPlayer(static_cast(m)); +} +} // end extern + + +SfzPlayer::SfzPlayer(InstrumentTrack* instrumentTrack) + : Instrument(instrumentTrack, &sfzplayer_plugin_descriptor, nullptr, Flag::IsSingleStreamed) + , m_samplePool(SfzSamplePool::instance()) + , m_parentTrack(instrumentTrack) + , m_preloadAllSamplesModel(false, this, tr("Preload All Samples")) +{ + auto iph = new InstrumentPlayHandle(this, instrumentTrack); + Engine::audioEngine()->addPlayHandle(iph); + + // When a user loads a new SFZ file, the main thread prepares the new data to be swapped out by the audio thread, but the main thread + // needs to delete the old data afterwards, so here we connect some kind of run-loop so that the main thread gets a chance to check after the audio thread acts + connect(lmms::gui::getGUI()->mainWindow(), SIGNAL(periodicUpdate()), this, SLOT(mainThreadUpdateAfterDataSwap())); // I really don't like this. Would it be better to use our own QTimer, not LMMS's gui's? + + // If the user enables preloading all samples, load all the samples immediately + connect(&m_preloadAllSamplesModel, &BoolModel::dataChanged, [&](){ if (m_preloadAllSamplesModel.value()) { preloadAllSamples(); }}); + + emit dataChanged(); +} + +SfzPlayer::~SfzPlayer() +{ + // Make sure to end the sample loading thread before the plugin exits, or else the program might forcefully terminate + if (m_sampleLoadingTask.valid()) { m_sampleLoadingTask.get(); } + + // Deleting the region objects will also destroy their shared_ptrs to the sample objects, which will delete them if no other SfzPlayers are also using them. + if (m_regionManager != nullptr) { delete m_regionManager; } // these may be nullptr at first when no SFZ file has been loaded previously + if (m_tempRegionManager != nullptr) { delete m_tempRegionManager; } +} + + + +bool SfzPlayer::handleMidiEvent(const MidiEvent& event, const TimePos& time, f_cnt_t offset) +{ + if (event.type() == MidiNoteOn) + { + processTrigger(SfzTrigger::noteOnEvent(offset, event.key(), event.velocity())); + // Store the velocity of this event so that we can apply it to the corresponding NoteOff event (see comment below) + m_previousNoteOnVelocity.at(event.key()) = event.velocity(); + return true; + } + else if (event.type() == MidiNoteOff) + { + // TODO: Currently, LMMS by default has NoteOff events have 0 velocity. SFZ requires that the NoteOff velocity match its corresponding NoteOn velocity + // To account for this, we track the last pressed velocity of each note in an array and use that + const int previousVelocity = m_previousNoteOnVelocity.at(event.key()); + processTrigger(SfzTrigger::noteOffEvent(offset, event.key(), previousVelocity)); + return true; + } + else if (event.type() == MidiControlChange) + { + processTrigger(SfzTrigger::controlChangeEvent(offset, event.controllerNumber(), event.controllerValue())); + return true; + } + return false; +} + +void SfzPlayer::playNote(NotePlayHandle* handle, SampleFrame* workingBuffer) +{ + // TODO: Currently this instrument only uses midi events as input, and a single audio buffer as output. + // It might be possible to split things up to use individual NotePlayHandles, but that is for another time. + + // In order for pitch bending and microtuning to work, the exact freq of the NotePlayHandle needs to be conveyed to the voices + m_sfzGlobalState.updateNphFreq(handle->midiKey(), handle->frequency()); + // Same thing for note panning in the piano roll + m_sfzGlobalState.updateNphPanning(handle->midiKey(), handle->getPanning()); +} + +void SfzPlayer::deleteNotePluginData(NotePlayHandle* handle) +{ +} + + +void SfzPlayer::processTrigger(const SfzTrigger& trigger) +{ + if (m_regionManager == nullptr) { return; } // Before any SFZ file is loaded, m_regionManager is nullptr, so there's no regions to trigger. + + if (m_currentlyLoadingSamples) { return; } // There are some issues with setStatusInfo being called from multiple threads at once, so just to be safe, don't handle any note events while the samples thread is running. + + // Notify the global state to update which switch keys are active, update midi CC values, etc + m_sfzGlobalState.processTrigger(trigger); + + bool preloadSamples = m_preloadAllSamplesModel.value(); // Cache this variable so that it isn't acessed every iteration + + for (auto* region : m_regionManager->findPotentialMatchingRegions(trigger)) + { + // If the trigger conditions are met, spawn a new sound + if (region->triggerConditionsMet(m_sfzGlobalState, trigger)) + { + // If samples are loaded when notes are pressed, check if this region has its sample loaded yet. If not, quickly load it before spawning the voice. + if (!preloadSamples && !region->sample()) + { + setStatusInfo(QString("Loading sample %1").arg(QFileInfo(region->m_sampleFile.value_or("N/A")).fileName())); + bool sampleInPool = false; + bool successfulLoadSample = region->initializeSample(QFileInfo(m_sfzFilePath).absoluteDir(), *m_samplePool, &sampleInPool); + if (successfulLoadSample) + { + setStatusInfo((sampleInPool ? QString("Loaded sample %1 (already cached)") : QString("Loaded sample %1")).arg(QFileInfo(region->m_sampleFile.value_or("N/A")).fileName())); + } + else + { + setStatusInfo(QString("An error occured when loading sample %1").arg(QFileInfo(region->m_sampleFile.value_or("N/A")).fileName())); + continue; + } + } + // Loop through array to find open position + bool foundOpenPosition = false; + for (size_t i = 0; i <= m_voices.size(); ++i) + { + auto& regionPlayState = m_voices[i]; + if (!regionPlayState.active()) + { + regionPlayState = SfzRegionPlayState(region, trigger, &m_sfzGlobalState); + // If this new index is above the current max active index, update it + m_maxActiveIndex = std::max(m_maxActiveIndex, i); + foundOpenPosition = true; + break; + } + } + // For fun, display the last played sample on the GUI + if (foundOpenPosition) + { + setStatusInfo("Last Played Sample: " + QFileInfo(region->m_sampleFile.value_or("N/A")).fileName()); + } + else + { + setStatusInfo("Warning: Too many active voices. Could not find vacant position buffer. "); + } + } + } + + // Loop through all the active sounds to check if any need to be deactivated/released by the trigger + for (size_t i = 0; i <= m_maxActiveIndex; ++i) + { + auto& regionPlayState = m_voices[i]; + + if (regionPlayState.active()) + { + regionPlayState.processTrigger(trigger); + // If this was the max active index and the trigger caused it to deactivate, figure out what the next active index is + if (!regionPlayState.active() && i == m_maxActiveIndex) { recalculateMaxActiveIndex(); } + } + } +} + + + + + +void SfzPlayer::play(SampleFrame* workingBuffer) +{ + const f_cnt_t frames = Engine::audioEngine()->framesPerPeriod(); + + // Render audio from each of the active voices + for (size_t i = 0; i <= m_maxActiveIndex; ++i) + { + auto& regionPlayState = m_voices[i]; + if (!regionPlayState.active()) { continue; } + + regionPlayState.play(workingBuffer, frames); + // If the play state deactivated during playback, and this was the max active index, figure out what the new max active index is + if (!regionPlayState.active() && i == m_maxActiveIndex) { recalculateMaxActiveIndex(); } + } + + + // In the event that the user tries to load a new sfz file while the current one is playing, the region and sample data will be + // loaded into temporary member variables, and it's the job of the audio thread to swap them into place when the data is ready + audioThreadHandleNewSfzData(); + // Increment the buffer counter so that the main thread knows when it's safe to delete the old objects + m_bufferCounter++; +} + + + +void SfzPlayer::recalculateMaxActiveIndex() +{ + // Loop backward from the old max active index to find the next play state which is active + while (m_maxActiveIndex > 0) + { + if (m_voices[m_maxActiveIndex].active()) { return; } + else { m_maxActiveIndex--; } + } +} + + + +void SfzPlayer::loadFile(const QString& filePath) +{ + loadSfzFile(filePath, true); // Passing true to reset the InstrumentTrack's midi CC knobs to the SFZ file's defaults + // Set the instrument name to the filename + m_parentTrack->setName(PathUtil::cleanName(filePath)); +} + + +void SfzPlayer::loadSfzFile(const QString& filePath, const bool resetCCKnobs) +{ + // If the sample loading thread is already running, don't let the user load another file. + if (m_currentlyLoadingSamples) + { + qDebug() << "[SFZ Player] Requested to load new SFZ file while still loading samples from previous SFZ file. Ignoring."; + return; + } + // Or, if the new sample/region data was just swapped into place a couple buffers ago, return just to be safe + if (m_justSwappedData && m_bufferCounter <= m_bufferCounterWhenDataReady + 2) + { + qDebug() << "[SFZ Player] Requested to load new SFZ file while new region/sample data from previous SFZ file may not yet have been swapped into place. Ignoring."; + return; + } + // Set this variable so that after the audio thread swaps the data, the main thread remembers whether to reset the CC knobs to the SFZ file defaults or just to the current InstrumentTrack CC knob values + m_resetCCKnobs = resetCCKnobs; + + + // Reset the note counts, midi cc values, etc + m_sfzGlobalState = SfzGlobalState(); + // And any info about control labels, default values, etc + m_controlsConfig = SfzControlsConfig(); + + // Temporary vector to store SfzRegion objects as they are parsed + std::vector regions; + // Parse all the headers of the .sfz (accounting for and defaults) and populate the regions vector with the new SfzRegions + // The header is also parsed into a separate object for easy access by the gui + bool successfulParseFile = SfzParser::parseSfzFile(filePath, regions, m_controlsConfig); + + if (!successfulParseFile) { qDebug() << "[SFZ Player] An error occurred when parsing the SFZ file."; return; } + + // Set the initial cc values based on any `set_ccN` opcodes in the header + m_sfzGlobalState.initializeMidiCCValues(m_controlsConfig); + // Setup a map for keeping track of which switch keys may be pressed. This is more efficient than having array with 128 element, since that takes much longer to loop over, and most keys are not switch keys + m_sfzGlobalState.initializeSwitchKeysMap(m_controlsConfig); + + m_sfzFilePath = filePath; + + // Hand off the vector of regions to SfzRegionManager, which will sort them out to optimize trigger selection + // Don't immediately set m_regionManager, since the audio thread may still be accessing it; instead set the temporary object + m_tempRegionManager = new SfzRegionManager(regions); + + // Set the flag to let the audio thread know it can swap the data + m_bufferCounterWhenDataReady = m_bufferCounter; // Save the current frame counter so the main thread knows when enough buffers have passed that it can delete the old data + m_newSfzDataReady = true; +} + + +void SfzPlayer::preloadAllSamples() +{ + // (For some reason, when loading the settings of the "Preload All Samples" toggle, it sets the value, which triggers this function, so we need to check to make sure the regions have actually been initialized before continuing) + if (m_regionManager == nullptr) { return; } + // To prevent the main gui thread from freezing while all the samples are loaded, start up a separate thread to handle loading everything + m_currentlyLoadingSamples = true; + if (m_sampleLoadingTask.valid()) { m_sampleLoadingTask.get(); } // Reset the previous thread if it is active + m_sampleLoadingTask = ThreadPool::instance().enqueue(&SfzPlayer::sampleLoadingThreadFunction, this); +} + +void SfzPlayer::sampleLoadingThreadFunction() +{ + // The samples are stored with relative paths with respect to the sfz file, so first find the parent directory: + QDir parentDirectory = QFileInfo(m_sfzFilePath).absoluteDir(); + + int i = 0; // Count the number of regions + int samplesLoadedFromDisk = 0; // Count the number of samples which had to be loaded from disk + int samplesAlreadyInPool = 0; // Count the number of samples which had previously been loaded into the pool and didn't need to be loaded from disk + int samplesFailedToLoad = 0; // Count the number of samples which couldn't be loaded (invalid path, etc) + for (auto* region : m_regionManager->allRegions()) + { + // Update the GUI info text to notify the user as samples are loaded + setStatusInfo(QString("Loading sample %1/%2 %3").arg(i+1).arg(m_regionManager->allRegions().size()).arg(QFileInfo(region->m_sampleFile.value_or("N/A")).fileName())); + // Initialize the sample into the temporary pool, so that it doesn't disturb the audio thread which may still be using the previous samples. + bool sampleInPool = false; + bool successfulLoadSample = region->initializeSample(parentDirectory, *m_samplePool, &sampleInPool); + if (successfulLoadSample) + { + if (sampleInPool) { samplesAlreadyInPool++; } + else { samplesLoadedFromDisk++; } + } + else + { + samplesFailedToLoad++; + setStatusInfo(QString("An error occured when loading sample %1").arg(QFileInfo(region->m_sampleFile.value_or("N/A")).fileName())); + } + i++; + } + if (samplesFailedToLoad == 0) + { + setStatusInfo(QString("Initialized %1 regions.\nLoaded %2 samples from disk.\nRetrieved %3 from sample pool.").arg(m_regionManager->allRegions().size()).arg(samplesLoadedFromDisk).arg(samplesAlreadyInPool)); + } + else + { + setStatusInfo(QString("Initialized %1 regions.\nLoaded %2 samples from disk.\nRetrieved %3 from sample pool.\nWARNING: Failed to load %4 samples, see logs for details.").arg(m_regionManager->allRegions().size()).arg(samplesLoadedFromDisk).arg(samplesAlreadyInPool).arg(samplesFailedToLoad)); + } + m_currentlyLoadingSamples = false; // TODO this doesn't seem thread safe, since there would be a brief moment in time where this is false but the thread is still active? +} + +void SfzPlayer::audioThreadHandleNewSfzData() +{ + if (m_newSfzDataReady) + { + // Swap the temporary regions with the real ones + std::swap(m_regionManager, m_tempRegionManager); + // And reset the active voices, since they have pointers to old region objects + std::fill_n(m_voices.begin(), m_maxActiveIndex + 1, SfzRegionPlayState()); + m_newSfzDataReady = false; + m_justSwappedData = true; + } +} + +void SfzPlayer::mainThreadUpdateAfterDataSwap() +{ + if (m_justSwappedData && m_bufferCounter > m_bufferCounterWhenDataReady + 2) + { + m_justSwappedData = false; + // Now that the audio thread has swapped the data, delete the old sample pool and region manager pointers + // Deleting the region objects will also delete the sample object shared_ptrs, so if no other SfzPlayers are using them, the samples willl be cleaned up. + if (m_tempRegionManager != nullptr) { delete m_tempRegionManager; m_tempRegionManager = nullptr; } // these may be nullptr at first when no SFZ file has been loaded previously + // Also set the midi CC knobs to match the defaults, or whatever the current InstrumentTrack midi CC knobs are + for (int i = 0; i < NumMidiCCs; ++i) + { + if (m_resetCCKnobs) + { + // Reset the instrument track's midi CC knobs to the defaults of the SFZ + m_parentTrack->midiCCModel(i)->setInitValue(m_sfzGlobalState.midiCCValue(i)); + } + // Sync the internal knobs to the LMMS knobs. TODO why does `setInitValue` not trigger this? + processTrigger(SfzTrigger::controlChangeEvent(0, i, m_parentTrack->midiCCModel(i)->value())); // TODO there may be a cleaner way to do this + } + // So that the GUI shows an accurate sample count, refresh the sample pool so that old pointers to samples are removed + m_samplePool->clearExpiredWeakPtrs(); + // Update the GUI + emit fileLoaded(); + + // Now load the samples + // The SfzParser generates all the SfzRegion objects, but it doesn't load any of the samples + // The sample filenames are stored in the regions as from the `sample` opcode, so we just need to load the files into memory to use them + // If the user had enabled loading all the samples at once up front, do that. Otherwise, they will be loaded whenever they are needed, in SfzPlayer::processTrigger() + if (m_preloadAllSamplesModel.value()) + { + // This is fine to do while the audio thread is running, since the sample pointers in the region objects are atomic + preloadAllSamples(); + } + } +} + + +void SfzPlayer::saveSettings(QDomDocument& document, QDomElement& element) +{ + element.setAttribute("sfzfile", m_sfzFilePath); + m_preloadAllSamplesModel.saveSettings(document, element, "preload_all_samples"); +} + +void SfzPlayer::loadSettings(const QDomElement& element) +{ + m_sfzFilePath = element.attribute("sfzfile"); + m_preloadAllSamplesModel.loadSettings(element, "preload_all_samples"); + if (!m_sfzFilePath.isEmpty()) + { + loadSfzFile(m_sfzFilePath, false); // Passing false to leave the user-set midi CC knobs alone, since they were loaded by the InstrumentTrack and shouldn't be reset to the SFZ's defaults. + } // TODO add error handling, if path doesn't exist +} + +QString SfzPlayer::nodeName() const +{ + return sfzplayer_plugin_descriptor.name; +} + +gui::PluginView* SfzPlayer::instantiateView(QWidget* parent) +{ + return new gui::SfzPlayerView(this, parent); +} + + + +void SfzPlayer::setStatusInfo(const QString& text) +{ + // Print to console + qDebug().noquote() << "[SFZ Player]" << text; + // And send to GUI + // (Instead of actually sending a Qt signal, which seemed to cause off crashes, simply set a member variable and let the GUI read it when it wants) + m_statusText = text; +} + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzPlayer.h b/plugins/SfzPlayer/SfzPlayer.h new file mode 100644 index 00000000000..787a788ce36 --- /dev/null +++ b/plugins/SfzPlayer/SfzPlayer.h @@ -0,0 +1,155 @@ +/* + * SfzPlayer.h - Simple SFZ instrument player + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZPLAYER_H +#define LMMS_SFZPLAYER_H + +#include "AutomatableModel.h" +#include "ComboBoxModel.h" +#include "Instrument.h" +#include "Note.h" +#include "Sample.h" +#include "SfzPlayerView.h" +#include "SfzParser.h" +#include "SfzRegion.h" +#include "SfzRegionPlayState.h" +#include "SfzGlobalState.h" +#include "SfzControlsConfig.h" +#include "SfzRegionManager.h" + +#include + +namespace lmms { + +class SfzPlayer : public Instrument +{ + Q_OBJECT + +public: + SfzPlayer(InstrumentTrack* instrumentTrack); + ~SfzPlayer(); + + void playNote(NotePlayHandle* handle, SampleFrame* workingBuffer) override; + void deleteNotePluginData(NotePlayHandle* handle) override; + + void play(SampleFrame* workingBuffer) override; + bool handleMidiEvent(const MidiEvent& event, const TimePos& time = TimePos(), f_cnt_t offset = 0) override; + + void saveSettings(QDomDocument& document, QDomElement& element) override; + void loadSettings(const QDomElement& element) override; + + void loadFile(const QString& filePath) override; + //! This extra function for loading files is needed so that we can choose only to reset the CC knobs when the user is loading a new file, not a preset/project + void loadSfzFile(const QString& filePath, const bool resetCCKnobs); + + QString nodeName() const override; + gui::PluginView* instantiateView(QWidget* parent) override; + +signals: + void fileLoaded(); + +private: + void processTrigger(const SfzTrigger& trigger); + + //! Holds a list of all the SfzRegion objects, which have the configurations for each of the samples/regions: what keys/velocities/etc trigger it, the volume, filter, envelopes, etc + //! This object helps map triggers to lists of potentially matching regions, so that we don't have to loop over all the regions, checking every single one whether all conditions are matched before spawning a voice. + SfzRegionManager* m_regionManager = nullptr; + + static constexpr int MAX_ACTIVE_SOUNDS = 128; + //! Array to store all active (and inactive) sound play-states across all regions + std::array m_voices; + //! Maximum active index in the play state array + //! By always spawning new sounds at the lowest open index and keeping track of the maximum index which contains an actice sound, + //! you only need to loop through the first n elements and ignore the rest since you know they are inactive (Thanks to Lost Robot for the idea) + size_t m_maxActiveIndex = 0; + //! Helper function to figure out what the maximum active index is, in the event the maximum index deactivated + void recalculateMaxActiveIndex(); + + //! Holds information about the total number of notes active, last switch keys pressed, etc + SfzGlobalState m_sfzGlobalState; + + //! Holds information about midi CC default values, labels, which CC's are actually used, etc + SfzControlsConfig m_controlsConfig; + + //! Shared pointer of the current SfzSamplePool, shared across all instances of SfzPlayer. Holding this shared_ptr keeps the sample pool alive, since after the last SfzPlayer is deleted, the sample pool is deleted too. + std::shared_ptr m_samplePool; + + //! Unfortunately, LMMS by default has the velocity of NoteOff events be 0. However, SFZ expects them to match the velocity of the corresponding NoteOn event. + //! To account for this, we store the velocity of the last NoteOn event for each key, and use that value when hanlding NoteOff events. + std::array m_previousNoteOnVelocity = {}; + + InstrumentTrack* m_parentTrack; + + //! The path to the currently loaded SFZ file + QString m_sfzFilePath = ""; + + //! Whether to load all the samples at once, or as the notes are played + BoolModel m_preloadAllSamplesModel; + + //! Helper function for printing debug info/passing status info to the GUI + void setStatusInfo(const QString& text); + QString m_statusText = ""; + + + //! Handle for helper thread task for loading sample files so that the main thread isn't blocked. This is done using ThreadPool::instance().enqueue(), which returns std::future, which is stored here + std::future m_sampleLoadingTask; + //! The function which the sample loading thread uses to load all the samples + void sampleLoadingThreadFunction(); + + //! Unfortunately, dealing with multiple threads gets complicated. + //! There is the possibility that the audio thread could access the region/sample data while the main thread is loading a new SFZ file and the samples + //! We need some way to swap out the current regions with the new data without breaking real-time safety. + //! To do this, essentially we have temporary buffers which the main thread works on while loading the files. When it's done, it sets a flag + //! which tells the audio thread that it can swap out the temporary pointer for the real pointer, and continue processing the audio. + + //! When a new SFZ file is being loaded and the regions need to be swapped out, the main thread sets this flag to let the + //! audio thread know to move the data from the temporary pointer variable into the real region manager pointer variable. The audio thread will set this back to false when it has finished swapping + std::atomic m_newSfzDataReady = false; + std::atomic m_justSwappedData = false; // And another flag so the main thread knows if it should be waiting for enough buffers to pass before loaidng another sfz file + //! Also have a flag for whether the sample loading thread is active or not, so that we don't accidentally try to start up a new sample loading thread while samples are already being loaded. + std::atomic m_currentlyLoadingSamples = false; + //! Temporary variable for the region data, which the audio thread will swap with the current pointer when m_newSfzDataReady is true + SfzRegionManager* m_tempRegionManager = nullptr; + //! Counts the number of buffers which have been processed. This is used for determining how long it has been since the audio thread has swapped out the region data + //! when loading a new SFZ file (Thanks to Lost Robot for the idea) + std::atomic m_bufferCounter = 0; + //! The main thread will also save the buffer counter when m_newSfzDataReady was set so that it will know if enough buffers have passed that the audio thread can be guaranteed to have swapped the data already. + //! If the user tries to load another SFZ file within one or two buffers of a previous SFZ file being loaded, it will refuse, because the audio thread may still be swapping the data from the temporary objects + size_t m_bufferCounterWhenDataReady = 0; + //! Also another flag just for the main thread so that it can remember whether the midi CC knobs should be reset to the SFZ file's defaults, since it has to wait until the audio thread has finished the swap. + bool m_resetCCKnobs = false; + + //! A helper function for the audio thread to handle swapping the data + void audioThreadHandleNewSfzData(); + //! A helper function for the main thread to delete the old data and update things like the CC knobs after the audio thread has swapped the data. +private slots: + void mainThreadUpdateAfterDataSwap(); + void preloadAllSamples(); + + friend class gui::SfzPlayerView; +}; + +} // namespace lmms + +#endif // LMMS_SFZPLAYER_H diff --git a/plugins/SfzPlayer/SfzPlayerView.cpp b/plugins/SfzPlayer/SfzPlayerView.cpp new file mode 100644 index 00000000000..f81fdbb2220 --- /dev/null +++ b/plugins/SfzPlayer/SfzPlayerView.cpp @@ -0,0 +1,231 @@ +/* + * SfzPlayerView.cpp - GUI for SfzPlayer + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzPlayerView.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "Clipboard.h" +#include "ComboBox.h" +#include "DataFile.h" +#include "InstrumentView.h" +#include "Knob.h" +#include "LcdSpinBox.h" +#include "LedCheckBox.h" +#include "PixmapButton.h" +#include "SfzPlayer.h" +#include "SfzSamplePool.h" +#include "StringPairDrag.h" +#include "Track.h" +#include "embed.h" +#include "ConfigManager.h" +#include "FileDialog.h" +#include "PathUtil.h" +#include "embed.h" +#include "MidiEvent.h" +#include "InstrumentTrack.h" +#include "GuiApplication.h" +#include "MainWindow.h" + +namespace lmms { + +namespace gui { + +SfzPlayerView::SfzPlayerView(SfzPlayer* instrument, QWidget* parent) + : InstrumentView(instrument, parent) + , m_instrument(instrument) + , m_statusLabel(new QLabel(this)) + , m_generalInfoLabel(new QLabel(this)) + , m_switchKeysLabel(new QLabel(this)) + , m_infoLabelsWidget(new QWidget(this)) + , m_controlsWidget(new QWidget(this)) + , m_knobLayout(new QGridLayout(m_controlsWidget)) +{ + setAcceptDrops(true); + setAutoFillBackground(true); + + setMaximumSize(QSize(10000, 10000)); + setMinimumSize(QSize(500, 250)); + + auto layout1 = new QVBoxLayout(this); + + auto openFileButton = new QPushButton(embed::getIconPixmap("folder"), tr("Open SFZ File"), this); + connect(openFileButton, &PixmapButton::clicked, this, &SfzPlayerView::openFile); + layout1->addWidget(openFileButton); + + layout1->addWidget(new QLabel("This SFZ Player is in beta! Not all opcodes are supported yet.", this)); + + layout1->addWidget(m_statusLabel); + + layout1->addWidget(m_infoLabelsWidget); + + LedCheckBox* preloadAllSamplesCheck = new LedCheckBox("Preload All Samples", this, tr("Preload All Samples"), LedCheckBox::LedColor::Green); + preloadAllSamplesCheck->setModel(&m_instrument->m_preloadAllSamplesModel); + layout1->addWidget(preloadAllSamplesCheck); + + auto layout2 = new QHBoxLayout(m_infoLabelsWidget); + layout2->setContentsMargins(0, 0, 0, 0); + layout2->addWidget(m_generalInfoLabel); + layout2->addWidget(m_switchKeysLabel); + + layout1->addWidget(m_controlsWidget); + + + // Whenever a new SFZ file is loaded, set the default CC values and update the info text + connect(m_instrument, &SfzPlayer::fileLoaded, [this](){ onFileLoaded(); }); // this lambda is so bad, but it doesn't work as a slot for some reason + + // Instead of sending a signal from the plugin every time a note is pressed (that seemed to cause odd crashes), simply update things periodically + connect(getGUI()->mainWindow(), SIGNAL(periodicUpdate()), this, SLOT(periodicUpdate())); + + onFileLoaded(); + + update(); +} + + +void SfzPlayerView::onFileLoaded() +{ + // Remove any old knobs + delete m_controlsWidget; + m_controlsWidget = new QWidget(this); + m_knobLayout = new QGridLayout(m_controlsWidget); + layout()->addWidget(m_controlsWidget); + + // Initialize new knobs + int activeControlCount = 0; + for (int i = 0; i < NumMidiCCs; ++i) + { + // Only add a knob if the control is actually used + if (m_instrument->m_controlsConfig.m_activeMidiCCs.at(i)) + { + const QString& controlLabel = m_instrument->m_controlsConfig.m_label_cc.at(i).value_or(tr("CC %1").arg(i)); + auto ccKnob = new Knob(KnobType::Bright26, controlLabel, m_controlsWidget); + ccKnob->setModel(m_instrument->m_parentTrack->midiCCModel(i)); + m_knobLayout->addWidget(ccKnob, activeControlCount / 8, activeControlCount % 8); + activeControlCount++; + } + } + // If we are using midi CC's make sure to enable them in the instrument track + if (activeControlCount > 0) + { + m_instrument->m_parentTrack->midiCCEnableModel()->setValue(true, true); + } + + // Update the switch key list + QStringList switchKeyLabels; + for (const auto& [key, info] : m_instrument->m_controlsConfig.m_switchKeyInfo) + { + QString label = QString("- %1 %2 [range: %3 - %4]") + .arg(keyNumToString(key)) + .arg(info.sw_label) + .arg(keyNumToString(info.sw_lokey)) + .arg(keyNumToString(info.sw_hikey)); + if (info.sw_default != std::nullopt) + { + label += QString(" default: %1").arg(keyNumToString(info.sw_default.value())); + } + switchKeyLabels.push_back(label); + } + if (switchKeyLabels.size() > 0) + { + m_switchKeysLabel->setText(tr("Switch Keys:\n") + switchKeyLabels.join("\n")); + } + else + { + m_switchKeysLabel->setText(""); + } +} + +void SfzPlayerView::periodicUpdate() +{ + m_statusLabel->setText(m_instrument->m_statusText); + + // Update general info every frame, since when loading other SfzPlayers, the number of loaded samples changes. There might be a better way to do this. + if (m_instrument->m_regionManager != nullptr) + { + m_generalInfoLabel->setText( + QString("File: %1\nRegions: %2\nSamples (across all instances): %3 (%4 MB)") + .arg(QFileInfo(m_instrument->m_sfzFilePath).fileName()) + .arg(m_instrument->m_regionManager->allRegions().size()) + .arg(SfzSamplePool::instance()->sampleCount()) + .arg(QString::number(SfzSamplePool::instance()->sampleMemoryUsage() / 1000000.0f, 'g', 4)) + ); + } +} + + +void SfzPlayerView::openFile() +{ + auto openFileDialog = FileDialog(nullptr, QObject::tr("Open SFZ File")); + auto dir = ConfigManager::inst()->userSamplesDir(); + openFileDialog.setDirectory(dir); + if (openFileDialog.exec() == QDialog::Accepted) + { + if (openFileDialog.selectedFiles().isEmpty()) { return; } + m_instrument->loadFile(openFileDialog.selectedFiles()[0]); + } +} + + +void SfzPlayerView::dragEnterEvent(QDragEnterEvent* dee) +{ + QString value = StringPairDrag::decodeValue(dee); + if (value.endsWith(".sfz")) + { + dee->accept(); + return; + } + dee->ignore(); +} + +void SfzPlayerView::dropEvent(QDropEvent* de) +{ + QString value = StringPairDrag::decodeValue(de); + if (value.endsWith(".sfz")) + { + de->accept(); + m_instrument->loadFile(value); + return; + } + de->ignore(); +} + +void SfzPlayerView::resizeEvent(QResizeEvent* re) +{ +} + +void SfzPlayerView::paintEvent(QPaintEvent* pe) +{ + //QPainter brush(this); + //brush.fillRect(rect(), QColor(19, 19, 20)); +} + +} // namespace gui +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzPlayerView.h b/plugins/SfzPlayer/SfzPlayerView.h new file mode 100644 index 00000000000..d5ae7929a6b --- /dev/null +++ b/plugins/SfzPlayer/SfzPlayerView.h @@ -0,0 +1,80 @@ +/* + * SfzPlayerView.h - GUI for SfzPlayer + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_GUI_SFZPLAYER_VIEW_H +#define LMMS_GUI_SFZPLAYER_VIEW_H + +#include "InstrumentView.h" + +class QGridLayout; +class QLabel; + +namespace lmms { + +class SfzPlayer; + +namespace gui { + + +//! This class is planned to be completely redone! +//! In it's current form, it's simply a temporary GUI which shows the keyswitch info, the parameters, and a button to load a file. +//! In the future, it would be great to have a much prettier and useful GUI +class SfzPlayerView : public InstrumentView +{ + Q_OBJECT + +public: + SfzPlayerView(SfzPlayer* instrument, QWidget* parent); + +public slots: + void onFileLoaded(); + void periodicUpdate(); + void openFile(); + +protected: + void dragEnterEvent(QDragEnterEvent* dee) override; + void dropEvent(QDropEvent* de) override; + + void paintEvent(QPaintEvent* pe) override; + void resizeEvent(QResizeEvent* event) override; + +private: + bool isResizable() const override { return true; } + + SfzPlayer* m_instrument; + + QLabel* m_statusLabel; + QLabel* m_generalInfoLabel; + QLabel* m_samplePoolLabel; + QLabel* m_switchKeysLabel; + QWidget* m_infoLabelsWidget; + QWidget* m_controlsWidget; + QGridLayout* m_knobLayout; +}; + +} // namespace gui + +} // namespace lmms + +#endif // LMMS_GUI_SFZPLAYER_VIEW_H diff --git a/plugins/SfzPlayer/SfzRegion.cpp b/plugins/SfzPlayer/SfzRegion.cpp new file mode 100644 index 00000000000..3a9a0c169ad --- /dev/null +++ b/plugins/SfzPlayer/SfzRegion.cpp @@ -0,0 +1,162 @@ +/* + * SfzRegion.cpp - Wrapper class for SfzOpcodeState which handles the actual sample, along with helper functions for triggers + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzRegion.h" + +#include "Engine.h" +#include "AudioEngine.h" +#include "PathUtil.h" + +#include + +namespace lmms +{ + + +SfzRegion::SfzRegion(SfzOpcodeState opcodeState) + : SfzOpcodeState(opcodeState) +{ + recalculateTotalCCModulation(SfzGlobalState()); // The region objects don't currently have direct access to the global state, so pass in a blank object just to reset the CC modulations to their defaults. + + // Cache which loccN/hiccN opcodes are defined in this region, so that we only have to loop through and check those upon a trigger, not all 128 + for (int i = 0; i < NumMidiCCs; ++i) + { + if (m_locc.at(i) != 0 || m_hicc.at(i) != 127) { m_lohiccDefinedCCNumbers.push_back(i); } + } +} + + +SfzRegion::SfzRegion(const SfzRegion& other) + : SfzOpcodeState(other) +{ + // Since atomic variables cannot be copied, reinitialize it + m_samplePointer.store(other.m_samplePointer.load()); + // Copy all the other member variables as normal + m_basicWaveShape = other.m_basicWaveShape; + m_roundRobinCount = other.m_roundRobinCount; + m_lohiccDefinedCCNumbers = other.m_lohiccDefinedCCNumbers; +} + + +bool SfzRegion::triggerConditionsMet(const SfzGlobalState& globalState, const SfzTrigger& trigger) +{ + if (trigger.type() == SfzTrigger::Type::ControlChange) { return false; } // TODO. It is possible for midi CC's to trigger regions, such as using the sustain pedal or on_locc/on_hicc + + // Make sure the trigger type matches + if (trigger.type() == SfzTrigger::Type::NoteOn && m_trigger != TriggerType::Attack) { return false; } + if (trigger.type() == SfzTrigger::Type::NoteOff && m_trigger != TriggerType::Release) { return false; } + + // Assuming the trigger has key/vel info (i.e., it's a noteOn/noteOff, not a midi CC event), make sure all the key/vel selectors match + if (trigger.type() == SfzTrigger::Type::NoteOn || trigger.type() == SfzTrigger::Type::NoteOff) + { + const int triggerKey = trigger.key().value(); + const int triggerVelocity = trigger.velocity().value(); + + // Ensure the key was pressed between the `lokey` and `hikey` opcodes + // TODO this can be removed now that SfzRegionManager handles lookup tables for regions by key + if (triggerKey > m_hikey || triggerKey < m_lokey) { return false; } + + // And had velocity between `lovel` and `hivel` opcodes + if (triggerVelocity > m_hivel || triggerVelocity < m_lovel) { return false; } + + // If a keyswitch range was defined, ensure the last pressed valid switch key in that range matches the specified keyswitch for this region + // TODO add unit tests + if (m_sw_last != std::nullopt && globalState.lastSwitchKeyPressedInRange(m_sw_lokey, m_sw_hikey, m_sw_default.m_value /*Accessing m_value due to issues with implicit cast to std::optional*/) != m_sw_last) { return false; } + } + + // If the region uses lorand/hirand, the current random value stored in the global state (updated every trigger) is compared with the range + // Note: the upper bound is inclusive, so lorand=0.2 hirand=0.4 will be triggered by any rand value >0.2 or <=0.4 + if (globalState.rand() > m_hirand || globalState.rand() <= m_lorand) { return false; } + + // If midi CC ranges are defined, make sure the current CC values are within range + // Only loop over the CC's which have lo/hiccN defined, instead of checking all 128 every time + for (const int i : m_lohiccDefinedCCNumbers) + { + const int ccValue = globalState.midiCCValue(i); + if (ccValue > m_hicc.at(i) || ccValue < m_locc.at(i)) { return false; } + } + + // If all conditions up until now have passed, that means we're ready to play sound. However, if round-robin is set up, we only do it if it's our turn. + m_roundRobinCount++; // TODO it would be nice if this function could be const and we didn't have to update this here. idk. + if (m_roundRobinCount % m_seq_length != m_seq_position - 1 /*Minus 1 because the opcode is 1-indexed*/) { return false; } // Not our turn + + // If all the contitions passed, return true and spawn a sound + return true; +} + + +void SfzRegion::recalculateTotalCCModulation(const SfzGlobalState& globalState) +{ + m_amplitude.updateCachedModulation(globalState.midiCCValues()); + m_volume.updateCachedModulation(globalState.midiCCValues()); + m_pan.updateCachedModulation(globalState.midiCCValues()); + m_delay.updateCachedModulation(globalState.midiCCValues()); + + m_ampeg.updateCachedModulation(globalState.midiCCValues()); + m_pitcheg.updateCachedModulation(globalState.midiCCValues()); + + m_amplfo.updateCachedModulation(globalState.midiCCValues()); + m_pitchlfo.updateCachedModulation(globalState.midiCCValues()); +} + + +bool SfzRegion::initializeSample(const QDir& parentDirectory, SfzSamplePool& samplePool, bool* sampleInPool) +{ + if (m_sampleFile == std::nullopt) + { + // It's weird for a region not to have a sample defined. That's literally all regions do, play samples, right? + qDebug() << "[SFZ Player] Warning: `sample` opcode not assigned"; + return false; + } + + // There are some special sample keywords for simple wave shapes, such as *sine, *triangle, *silence, etc + // If one of them is used, we don't load a sample file, instead leave m_sample as nullptr and set m_basicWaveShape + // to let the region know that we are doing a procedurally generated wave + if (m_sampleFile == "*sine") { m_basicWaveShape = SfzBasicWaves::Shape::Sine; } + else if (m_sampleFile == "*saw") { m_basicWaveShape = SfzBasicWaves::Shape::Saw; } + else if (m_sampleFile == "*square") { m_basicWaveShape = SfzBasicWaves::Shape::Square; } + else if (m_sampleFile == "*triangle") { m_basicWaveShape = SfzBasicWaves::Shape::Triangle; } + else if (m_sampleFile == "*tri") { m_basicWaveShape = SfzBasicWaves::Shape::Triangle; } + else if (m_sampleFile == "*noise") { m_basicWaveShape = SfzBasicWaves::Shape::Noise; } + else if (m_sampleFile == "*silence") { m_basicWaveShape = SfzBasicWaves::Shape::Silence; } + else + { + // If it's not a basic wave, load the real sample file. + QDir defaultDirectory = QDir(parentDirectory.absoluteFilePath(m_default_path.value_or(""))); // TODO + QString path = defaultDirectory.absoluteFilePath(m_sampleFile); // TODO + // Format the path properly before checking if it exists; this can cause issues when mixing ".." and "\" and "/" and everything + path = PathUtil::toAbsolute(path); + // Check if the file exists before calling the LMMS function to load it, since that will spawn a popup window if it can't load it, which could be hundreds of popups if its a large sfz + if (!QFile::exists(path)) { return false; } + // The sample pool handles making sure the same sample isn't loaded twice, which would waste memory + m_sample = samplePool.loadSample(path, sampleInPool); // sampleInPool is passed so that we can tell the SfzPlayer if it actually needed to load it from disk or whether it was previously loaded and could be retrieved. + m_samplePointer = m_sample.get(); // Unfortunately, not all the builds support std::atomic, so we use both a shared_ptr (m_sample) and an atomic raw pointer (m_samplePointer). + + return m_samplePointer != nullptr; + } + return true; +} + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzRegion.h b/plugins/SfzPlayer/SfzRegion.h new file mode 100644 index 00000000000..80578b3d1d1 --- /dev/null +++ b/plugins/SfzPlayer/SfzRegion.h @@ -0,0 +1,93 @@ +/* + * SfzRegion.h - Wrapper class for SfzOpcodeState which handles the actual sample, along with helper functions for triggers + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_REGION_H +#define LMMS_SFZ_REGION_H + +#include "SfzOpcodeState.h" +#include "SfzGlobalState.h" +#include "SfzTrigger.h" +#include "SfzRegionPlayState.h" +#include "SfzSampleBuffer.h" +#include "SfzSamplePool.h" +#include "SfzBasicWaves.h" + +#include + +namespace lmms +{ + +class SfzRegion : public SfzOpcodeState +{ +public: + SfzRegion(SfzOpcodeState opcodeState); + SfzRegion(const SfzRegion& other); // A custom copy constructor is needed because atomic member variables have no default copy/move constructor + + //! Returns true if the trigger event matches all of the requirements defined by the opcodes of this region + //! For example, if lokey=24 and hikey=29, and the trigger is a NoteOn event on key 26, then it will return true + //! If the trigger does not fall in the key range or velocity range or any other conditions are not met (including global state conditions, + //! such as which switch key was last pressed) this will return false. + //! TODO this method also increments the round robin counter, if applicable. Ideally I feel like this method should be const, but I'm not sure how to best organize it. + bool triggerConditionsMet(const SfzGlobalState& globalState, const SfzTrigger& trigger); + + //! Load the sample file given by the `sample` opcode into m_sample. + //! The sample path is treated as relative to the path to the sfz file, so the parent directory is also needed + //! Sets `sampleInPool` to true if it was able to find the sample previously loaded in the sample pool, or false if it needed to load it from disk. + //! Returns true if successful + bool initializeSample(const QDir& parentDirectory, SfzSamplePool& samplePool, bool* sampleInPool); + + //! Returns a non-owning raw pointer to the sample object for this region + const SfzSampleBuffer* sample() const { return m_samplePointer; } + //! Returns the type of basic wave for this region, if it specified instead of a real sample. + //! If a sample was specified, this returns SfzBasicWaves::Shape::Silence by default. + const SfzBasicWaves::Shape basicWaveShape() const { return m_basicWaveShape; } + +private: + //! Shared pointer to sample object to be played. The sample file path is defined in the `sample` opcode, but the data needs to be loaded first + //! The actual sample objects are stored in a shared pool, SfzSamplePool, so that if multiple of the same sample are loaded, they don't waste memory. + //! This is an atomic variable, since the user may have enabled the option to load samples as they play the notes, which means the sample pointer needs to be changed while the instrument is playing, which could potentially have issues with the different threads acessing the same pointer at once. + std::atomic m_samplePointer = nullptr; + std::shared_ptr m_sample; // Unfortunately, not all the builds support std::atomic, 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. + //! However, if a basic wave keyword such as *sine, *saw, *triangle, etc is used, handle it separately (see SfzBasicWaves.h/.cpp) + SfzBasicWaves::Shape m_basicWaveShape = SfzBasicWaves::Shape::Silence; + + + //! In order to do round robin, the region needs to keep track of how many notes it has played in its lifetime. Or rather, the number of notes it *would* have played if it weren't restricted to only play a note when the round-robin counter hit the right numbers. + int m_roundRobinCount = 0; + + //! When checking whether all the current CC values fall between loccN and hiccN, it's useful to only check the ones where lo/hiccN is actually defined, not all 128 + //! This vector stores a list of which CC numbers have lo/hiccN opcodes in this region + std::vector m_lohiccDefinedCCNumbers; + + //! Helper function to update the total modulation of all midi CC controllers on each modulatable parameter. + void recalculateTotalCCModulation(const SfzGlobalState& globalState); + + friend class SfzRegionPlayState; // TODO this was just to make it easy but...? +}; + + +} // namespace lmms + + +#endif // LMMS_SFZ_REGION_H diff --git a/plugins/SfzPlayer/SfzRegionManager.cpp b/plugins/SfzPlayer/SfzRegionManager.cpp new file mode 100644 index 00000000000..5eaff15f62e --- /dev/null +++ b/plugins/SfzPlayer/SfzRegionManager.cpp @@ -0,0 +1,83 @@ +/* + * SfzRegionManager.cpp - Helper class for optimizing region selection based on trigger + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzRegionManager.h" + +#include + +namespace lmms +{ + + +SfzRegionManager::SfzRegionManager(std::vector& regions) + : m_regions(regions) // TODO should this use std::move? +{ + qDebug() << "[SFZ Player] Constructing key/region lookup tables..."; + // For each key, figure out what list of regions could include it, to make a kind of look-up table + for (int key = 0; key < 128; ++key) + { + for (auto& region : m_regions) + { + if (key >= region.m_lokey && key <= region.m_hikey) + { + // Additionally organize by trigger type + switch (region.m_trigger) + { + case TriggerType::Attack: + m_noteOnRegions.at(key).push_back(®ion); + break; + case TriggerType::Release: + m_noteOffRegions.at(key).push_back(®ion); + break; + } + } + } + qDebug() << "[SFZ Player] Key" << key << ":" << m_noteOnRegions.at(key).size() << "NoteOn regions," << m_noteOffRegions.at(key).size() << "NoteOff regions"; + } + + // Unfortunately, it's more difficult to make lookup tables for midi CC events. Instead, just use a big vector with all the regions by default + // TODO or is it? there's probably a way. But currently we actually don't support cc events triggering regions, so it's not as important at the moment. + for (auto& region : m_regions) + { + m_allRegions.push_back(®ion); + } +} + + + +const std::vector& SfzRegionManager::findPotentialMatchingRegions(const SfzTrigger& trigger) const +{ + switch (trigger.type()) + { + case SfzTrigger::Type::NoteOn: + return m_noteOnRegions.at(trigger.key().value()); + case SfzTrigger::Type::NoteOff: + return m_noteOffRegions.at(trigger.key().value()); + case SfzTrigger::Type::ControlChange: + return m_allRegions; // TODO is it possible to somehow make a lookup table for CC events? + } + return m_allRegions; // This was added to prevent a warning, but I don't think it's necessary, since the switch covers all cases. +} + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzRegionManager.h b/plugins/SfzPlayer/SfzRegionManager.h new file mode 100644 index 00000000000..893dda01339 --- /dev/null +++ b/plugins/SfzPlayer/SfzRegionManager.h @@ -0,0 +1,67 @@ +/* + * SfzRegionManager.h - Helper class for optimizing region selection based on trigger + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_REGION_MANAGER_H +#define LMMS_SFZ_REGION_MANAGER_H + +#include "SfzRegion.h" + + +namespace lmms +{ + +class SfzRegionManager +{ +public: + //! The point of this class is to speed up processing triggers (NoteOn/NoteOff/etc events) + //! Normally, if your SFZ file has tens of thousands of regions, you have to loop over every single one each time a key + //! is pressed to check whether all the conditions are met and it should spawn a voice or not (is the key within lokey/hikey range, + //! is the velocity within lovel/hivel range, has the right switch key been pressed, are the midi CC's in the right ranges, etc) + //! This is not optimal. + //! Instead, because there are only 128 keys, we can construct a mapping, where each key is paired with a list of regions where lokey/hikey matches it + //! Doing this for both NoteOn and NoteOff events means having 256 lists of pointers to regions, which is not bad. + //! That way, whenever a note is pressed, you can simply look up in the table a list of all the regions which might match. + //! This is usually 10-100x fewer regions than the total, which is much faster to loop over. + SfzRegionManager() = default; + SfzRegionManager(std::vector& regions); + + //! Based on the trigger type, key, etc, returns a list of regions which might match the trigger. + //! This is meant to intelligently narrow down the number of regions where we manually have to loop over and check + const std::vector& findPotentialMatchingRegions(const SfzTrigger& trigger) const; + + //! Returns a vector of pointers for all the regions, in case any code needs to loop over all of them. + const std::vector& allRegions() const { return m_allRegions; } + +private: + //! Stores the actual region objects + std::vector m_regions; + + std::vector m_allRegions; + std::array, 128> m_noteOnRegions; + std::array, 128> m_noteOffRegions; +}; + +} // namespace lmms + +#endif // LMMS_SFZ_REGION_MANAGER_H diff --git a/plugins/SfzPlayer/SfzRegionPlayState.cpp b/plugins/SfzPlayer/SfzRegionPlayState.cpp new file mode 100644 index 00000000000..04b7343e477 --- /dev/null +++ b/plugins/SfzPlayer/SfzRegionPlayState.cpp @@ -0,0 +1,366 @@ +/* + * SfzRegionPlayState.cpp - Handles generating audio for a single voice based on the configuration of its parent SfzRegion + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzRegionPlayState.h" + +#include "SfzRegion.h" + +#include "AudioEngine.h" +#include "Engine.h" +#include "lmms_math.h" +#include "MicroTimer.h" + +#include + +namespace lmms +{ + +SfzRegionPlayState::SfzRegionPlayState(SfzRegion* region, const SfzTrigger& trigger, const SfzGlobalState* globalState) + : m_active(true) + , m_lmmsSampleRate(Engine::audioEngine()->outputSampleRate()) + , m_filter(m_lmmsSampleRate) + , m_trigger(trigger) + , m_region(region) + , m_globalState(globalState) + , m_sampleObject(region->sample()) +{ + // Make sure the parent region's modulations are up to date + m_region->recalculateTotalCCModulation(*m_globalState); + // Calculate the base pitch and amplitude so that we don't need to every buffer + precomputeBaseValues(); + // Delay the start of the playback by the trigger offset + m_frameCount -= trigger.frameOffset(); + // And by the delay opcode in seconds + m_frameCount -= region->m_delay * m_lmmsSampleRate; + // And any random delay amount + m_frameCount -= fastRand(1.0f) * region->m_delay_random * m_lmmsSampleRate; + + // Set initial sample start frame offset + m_sampleFrame += m_region->m_offset; + + // Setup the filter + switch (m_region->m_fil_type) + { + // This is not correct! I'm using the default filters from BasicFilters, but I don't believe they necessarily match the 1 pole vs 2 pole specifications for sfz. + // For example, the default lowpass is a biquad, which I believe has 2 poles. For now I'm using it for both lowpass types, but it should probably be changed in the future. + case FilterType::Lowpass1Pole: + m_filter.setFilterType(BasicFilters<2>::FilterType::LowPass); + break; + case FilterType::Lowpass2Pole: + m_filter.setFilterType(BasicFilters<2>::FilterType::LowPass); + break; + case FilterType::Highpass1Pole: + m_filter.setFilterType(BasicFilters<2>::FilterType::HiPass); + break; + case FilterType::Highpass2Pole: + m_filter.setFilterType(BasicFilters<2>::FilterType::HiPass); + break; + case FilterType::Bandpass2Pole: + m_filter.setFilterType(BasicFilters<2>::FilterType::BandPass_CSG); // I am not well versed in the difference between BandPass_CSG and BandPass_CZPG. It seems to be something about how it uses Q/resonance? I'm not sure. + break; + case FilterType::Bandstop2Pole: + m_filter.setFilterType(BasicFilters<2>::FilterType::Notch); + break; + } +} + + + +void SfzRegionPlayState::precomputeBaseValues() +{ + // Helper variable + const float normalizedVelocity = m_trigger.velocity().value() / 127.0f; + + // Compute the base pitch + // The pitch env/lfo will be applied on top of this, as those are calcualted per frame + + // Calculate pitch difference relative to original sample + const float semitoneDifference = m_trigger.key().value() - m_region->m_pitch_keycenter; + // The base pitch depends on 1. the key offset, 2. the fine `tune` adjustment, 3. the velocity, if pitch_veltrack is nonzero + // These are all in cents, so divide by 100 to get semitones + const float pitch = semitoneDifference * m_region->m_pitch_keytrack / 100.0f + + m_region->m_tune / 100.0f + + normalizedVelocity * m_region->m_pitch_veltrack / 100.0f; + m_baseFreqRatio = std::exp2(pitch / 12.0f); + + // Sample rate of sample + const float sampleSampleRate = m_sampleObject != nullptr + ? m_sampleObject->sampleRate() + : m_lmmsSampleRate; // If we are using a basic wave instead of a sample, set it to LMMS's sample rate + // Play the sample faster/slower to match the correct sample rate + m_baseFreqRatio *= sampleSampleRate / m_lmmsSampleRate; + + + // Compute the base amplitude + + // Amplitude opcode + const float amplitude = m_region->m_amplitude / 100.0f; // Amplitude is stored as a percent + + // Amplitude due to velocity + // If amp_keytrack is 100, then 0 velocity = 0 amp, and 127 velocity = 1.0f amp (as expected) + // If amp_keytrack is -100, it's the reverse. If amp_keytrack is 0, the volume is not affected by the velocity. + // Essentially this means lerping between y = x, y = 1, and y = 1 - x, if you think of y being the amp and x being vel/127 + const float ampVelocity = m_region->m_amp_veltrack > 0 + ? (normalizedVelocity) * (m_region->m_amp_veltrack / 100) + 1.0f * (1.0f - m_region->m_amp_veltrack / 100) + : (1.0f - normalizedVelocity) * (m_region->m_amp_veltrack / -100) + 1.0f * (1.0f - m_region->m_amp_veltrack / -100); + + // Amplitude due to volume/gain + const float ampVolume = dbfsToAmp(m_region->m_volume); + + // Panning + const float pan = m_region->m_pan / 100; + const float rightPanAmp = std::min(1.0f, 1.0f + pan); + const float leftPanAmp = std::min(1.0f, 1.0f - pan); + + m_baseAmplitudeLeft = amplitude * ampVelocity * ampVolume * leftPanAmp; + m_baseAmplitudeRight = amplitude * ampVelocity * ampVolume * rightPanAmp; +} + + +// Helper function for envelope shapes +float SfzRegionPlayState::envelopeGenerator(const f_cnt_t delay, const f_cnt_t attack, const f_cnt_t hold, const f_cnt_t decay, const float sustain, const f_cnt_t release) const +{ + // If the note hasn't started yet, don't do anything + if (m_frameCount < 0) { return 0.0f; } + // There is the possibility that the note may have released during attack, decay, etc, so we may need to multiply the release and the normal env values together to get a smooth output + float normalValue = 1.0f; + float releaseValue = 1.0f; + // Calculate the normal envelope value + if (static_cast(m_frameCount) < delay) + { + normalValue = 0.f; + } + else if (static_cast(m_frameCount) < delay + attack) + { + normalValue = static_cast(m_frameCount - delay) / attack; + } + else if (static_cast(m_frameCount) < delay + attack + hold) + { + normalValue = 1.0f; + } + else if (static_cast(m_frameCount) < delay + attack + hold + decay) + { + // Follow an exponential curve from 0 to -90 dB, but stop at the sustain value + normalValue = std::max(sustain, dbfsToAmp(-90 * static_cast(m_frameCount - (delay + attack + hold)) / decay)); + } + else + { + normalValue = sustain; + } + // If the note has already been released, find the release amplitude + if (m_released && m_frameCount > m_releaseFrame) + { + // According to https://sfzformat.com/tutorials/envelope_generators/, release and decay follow exponential curves (linear in dB) which go from 0 to -90 dB + releaseValue = dbfsToAmp(-90 * static_cast(m_frameCount - m_releaseFrame) / release); + } + return releaseValue * normalValue; +} + + + +// Helper function for LFOs +float SfzRegionPlayState::lfoGenerator(const f_cnt_t delay, const f_cnt_t fade, const float freq) const +{ + // If the note hasn't started yet, don't do anything + if (m_frameCount < 0) { return 0.0f; } + + if (static_cast(m_frameCount) < delay) { return 0.0f; } + + float lfoValue = std::sin(static_cast(m_frameCount - delay) / m_lmmsSampleRate * freq * 2 * std::numbers::pi); + + // Make the lfo ramp up to its max amplitude during the fade frames + if (static_cast(m_frameCount) < delay + fade) + { + return static_cast(m_frameCount - delay) / fade * lfoValue; + } + else + { + return lfoValue; + } +} + + + + + +bool SfzRegionPlayState::play(SampleFrame* buffer, const f_cnt_t frames) +{ + // If the sound is not active (note was released, off_by triggered, etc) then don't render any audio + if (!m_active) { return false; } + + // If the initial m_frameCount is negative, that means the note hasn't started yet + if (m_frameCount < -static_cast(frames)) { m_frameCount += frames; return false; } // If the note doesn't start in this buffer, don't play anything + + // If no sample has been loaded yet (and no basic wave was specified), don't play anything, and end the note because no sound will ever come. + // This caused an odd bug where spamming keys which had NoteOff samples while the samples were being loaded would make the voices last forever (playing silence, but filling up the active voice array). + if (m_sampleObject == nullptr && m_region->basicWaveShape() == SfzBasicWaves::Shape::Silence) { m_active = false; return false; } + + // Helper variable + const float normalizedVelocity = m_trigger.velocity().value() / 127.0f; + + // Pitch difference due to pitchbending/microtuning. This is provided by the NotePlayHandle every buffer. The NPH might not have been the one which created this voice (SFZ is a midi based format and doesn't really work well with the NPH system of lmms), so we just get the freq of the last NPH on the same key as the event which triggered this voice. + const float notePlayHandleFreqRatio = m_globalState->nphKeyFreqRatio(m_trigger.key().value()); // TODO can this ever be 0.0f, like if the midi event is processed before the note play handle and it doesn't get set in time? I don't think so but I'm not 100% sure. + // Panning from the NotePlayHandle + const float notePlayHandlePanning = m_globalState->nphKeyPanning(m_trigger.key().value()); + const float nphPanningLeftAmp = std::min(1.0f, 1.0f - notePlayHandlePanning / 100.0f); + const float nphPanningRightAmp = std::min(1.0f, 1.0f + notePlayHandlePanning / 100.0f); + + // Amplitude Envelope Parameters + const f_cnt_t ampegDelayFrames = m_region->m_ampeg.delay * m_lmmsSampleRate; + const f_cnt_t ampegAttackFrames = m_region->m_ampeg.attack * m_lmmsSampleRate; + const f_cnt_t ampegHoldFrames = m_region->m_ampeg.hold * m_lmmsSampleRate; + const f_cnt_t ampegDecayFrames = m_region->m_ampeg.decay * m_lmmsSampleRate; + const float ampegSustain = m_region->m_ampeg.sustain / 100.0f; // Sustain is stored in percent, so divide by 100 to get ratio + const f_cnt_t ampegReleaseFrames = m_region->m_ampeg.release * m_lmmsSampleRate; + + // Amplitude LFO parameters + const f_cnt_t amplfoDelayFrames = m_region->m_amplfo.delay * m_lmmsSampleRate; + const f_cnt_t amplfoFadeFrames = m_region->m_amplfo.fade * m_lmmsSampleRate; + const float amplfoFreq = m_region->m_amplfo.freq; + const float amplfoDepth = m_region->m_amplfo.depth; + + // Pitch Envelope Parameters + const f_cnt_t pitchegDelayFrames = m_region->m_pitcheg.delay * m_lmmsSampleRate; + const f_cnt_t pitchegAttackFrames = m_region->m_pitcheg.attack * m_lmmsSampleRate; + const f_cnt_t pitchegHoldFrames = m_region->m_pitcheg.hold * m_lmmsSampleRate; + const f_cnt_t pitchegDecayFrames = m_region->m_pitcheg.decay * m_lmmsSampleRate; + const float pitchegSustain = m_region->m_pitcheg.sustain / 100.0f; // Sustain is stored in percent, so divide by 100 to get ratio + const f_cnt_t pitchegReleaseFrames = m_region->m_pitcheg.release * m_lmmsSampleRate; + const float pitchegDepth = m_region->m_pitcheg.depth; + + // Pitch LFO parameters + const f_cnt_t pitchlfoDelayFrames = m_region->m_pitchlfo.delay * m_lmmsSampleRate; + const f_cnt_t pitchlfoFadeFrames = m_region->m_pitchlfo.fade * m_lmmsSampleRate; + const float pitchlfoFreq = m_region->m_pitchlfo.freq; + const float pitchlfoDepth = m_region->m_pitchlfo.depth; + + + // Filter + const bool filterEnabled = m_region->m_cutoff != std::nullopt; + // For performance, only update the cutoff frequency/resonance once per buffer + if (filterEnabled) + { + // The cutoff frequency is in hertz, but things like fil_veltrack are defined in cents, so we have to convert them + const float filterCutoffPitchOffset = normalizedVelocity * m_region->m_fil_veltrack; + const float filterCutoff = m_region->m_cutoff * std::exp2(filterCutoffPitchOffset / 1200.0f); + // SFZ has the resonance given in decibals, which is not the same as the resonance passed to the filter, so we have to convert it + const float q = std::sqrt(2.0f) * dbfsToAmp(m_region->m_resonance); // TODO is this equation correct? I'm sort of basing it off https://www.musicdsp.org/en/latest/Filters/180-cool-sounding-lowpass-with-decibel-measured-resonance.html but I'm not sure. + m_filter.calcFilterCoeffs(filterCutoff, q); + } + + // Now render the audio + for (f_cnt_t f = 0; f < frames; ++f) + { + // Compute the current envelope/lfo values + const float ampeg = envelopeGenerator(ampegDelayFrames, ampegAttackFrames, ampegHoldFrames, ampegDecayFrames, ampegSustain, ampegReleaseFrames); + const float amplfo = amplfoDepth != 0.0f // Only compute the amplitude lfo if the depth is nonzero + ? dbfsToAmp(lfoGenerator(amplfoDelayFrames, amplfoFadeFrames, amplfoFreq) * amplfoDepth) // amplfo depth is in decibels, so convert to amplitude + : 1.0f; + const float pitcheg = pitchegDepth != 0.0f // Only compute the pitch envelope if the depth is nonzero + ? envelopeGenerator(pitchegDelayFrames, pitchegAttackFrames, pitchegHoldFrames, pitchegDecayFrames, pitchegSustain, pitchegReleaseFrames) * pitchegDepth + : 0.0f; + const float pitchlfo = pitchlfoDepth != 0.0f // Only compute the pitch lfo if the depth is nonzero + ? lfoGenerator(pitchlfoDelayFrames, pitchlfoFadeFrames, pitchlfoFreq) * pitchlfoDepth + : 0.0f; + const float pitchmodFreqRatio = (pitcheg + pitchlfo != 0) // Only calculate the pitch per-frame if the pitch envelope/lfo is actually active + ? std::exp2((pitcheg + pitchlfo) / 1200) + : 1.0f; + + // If a sample file was loaded, use the buffer to get the audio data + // Otherwise, if a basic wave shape is being used (like *sine, *saw, *silence, etc) use a function to generate the shape + float sampleLeftValue = 0.0f; + float sampleRightValue = 0.0f; + if (m_sampleObject != nullptr) // TODO: should this check be outside of the loop? + { + sampleLeftValue = m_sampleObject->at(m_sampleFrame, 0); + sampleRightValue = m_sampleObject->at(m_sampleFrame, 1); + } + else + { + sampleLeftValue = SfzBasicWaves::generate(m_region->basicWaveShape(), m_lmmsSampleRate, m_sampleFrame); + sampleRightValue = SfzBasicWaves::generate(m_region->basicWaveShape(), m_lmmsSampleRate, m_sampleFrame); + } + + if (filterEnabled) // TODO does this if statement make it faster? + { + buffer[f][0] += m_filter.update(sampleLeftValue * m_baseAmplitudeLeft * nphPanningLeftAmp * ampeg * amplfo, 0); + buffer[f][1] += m_filter.update(sampleRightValue * m_baseAmplitudeRight * nphPanningRightAmp * ampeg * amplfo, 1); + } + else + { + buffer[f][0] += sampleLeftValue * m_baseAmplitudeLeft * nphPanningLeftAmp * ampeg * amplfo; + buffer[f][1] += sampleRightValue * m_baseAmplitudeRight * nphPanningRightAmp * ampeg * amplfo; + } + // Increment the frame count. If we are using a sample, make sure to stop at the end, but if we are using a basic wave like *sine or *saw, there's no need + if (m_frameCount >= 0) // Do not start playing the sample until we reach frame 0 (start of note), otherwise the sample frame will start moving even though it's not outputting any audio, causing the note to start partway through the sample (often with a discontinuity) + { + const double frameIncrement = m_baseFreqRatio * notePlayHandleFreqRatio * pitchmodFreqRatio; // Apply the pitch modulation by speeding up/slowing down the playback + m_sampleFrame = m_sampleObject != nullptr + ? std::min(static_cast(m_sampleObject->size()), m_sampleFrame + frameIncrement) + : m_sampleFrame + frameIncrement; + } + m_frameCount++; + } + + + // Deactive the voice if it has been released and the release has finished + if (m_released && static_cast(m_frameCount - m_releaseFrame) > ampegReleaseFrames) + { + m_active = false; + } + + // If the end of the sample is reached and the region's loop mode does not loop, deactivate this voice. + if ((m_region->m_loop_mode == LoopMode::OneShot || m_region->m_loop_mode == LoopMode::NoLoop) && m_sampleObject != nullptr && m_sampleFrame >= m_sampleObject->size()) + { + m_active = false; // TODO should this forcefully decative or just release? + } + + return true; +} + + +void SfzRegionPlayState::processTrigger(const SfzTrigger& trigger) +{ + if (m_released) { return; } // If we already released, don't do anything + + if (trigger.type() == SfzTrigger::Type::NoteOff) + { + if (m_region->m_loop_mode == LoopMode::OneShot) { return; } // If one_shot looping is enabled, the whole sample will play regardless of if the note is released + + if (trigger.key() == m_trigger.key()) + { + m_released = true; + m_releaseFrame = m_frameCount; + } + } + // Since the base pitch and amplitude are precomputed in the constructor, they need to be re-computed if any of the CC modulations may have changed + // For simplicity, if any CC trigger occurs, recompute everything + if (trigger.type() == SfzTrigger::Type::ControlChange) + { + m_region->recalculateTotalCCModulation(*m_globalState); + precomputeBaseValues(); + } +} + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzRegionPlayState.h b/plugins/SfzPlayer/SfzRegionPlayState.h new file mode 100644 index 00000000000..5587ca8d5a2 --- /dev/null +++ b/plugins/SfzPlayer/SfzRegionPlayState.h @@ -0,0 +1,123 @@ +/* + * SfzRegionPlayState.h - Handles generating audio for a single voice based on the configuration of its parent SfzRegion + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + + +#ifndef LMMS_SFZ_REGION_PLAY_STATE_H +#define LMMS_SFZ_REGION_PLAY_STATE_H + +#include "SampleFrame.h" +#include "SfzTrigger.h" +#include "SfzGlobalState.h" +#include "SfzOpcodeState.h" +#include "SfzSampleBuffer.h" + +#include "BasicFilters.h" + +#include + +namespace lmms +{ + +class SfzRegion; + + +class SfzRegionPlayState +{ +public: + // I wish `region` could be const, but it's faster to have the voice update the region's cached modulation values than looping over all the regions elsewhere in the plugin, so for now it will have to be non-const. + SfzRegionPlayState(SfzRegion* region, const SfzTrigger& trigger, const SfzGlobalState* globalState); + SfzRegionPlayState() = default; // The default constructor is needed to initialize arrays of the object + + //! Helper function to calculate the base pitch and amplitude so that it doesn't have to be done per-buffer. + //! This is done once during the constructor, but it must also be done whenever a CC knob + //! changes, since that could affect the base pitch/amp of the playback. + void precomputeBaseValues(); + + //! Generates the next buffer of audio from this sound. If m_active is false, this function does nothing. + //! Returns true if any sound was generated, false if the buffer is left untouched + bool play(SampleFrame* buffer, const f_cnt_t frames); + + //! Handle incoming event to decide whether to deactivate/release + //! Also handles updating the parent region's precomputed modulation values whenever a midi CC event occurs + void processTrigger(const SfzTrigger& trigger); + + //! Returns whether this voice is done playing or not. If m_active is false, this voice object is avaiable to be overwritten and used as a new voice. + bool active() const { return m_active; } + + //! Helper function for calculating the envelope value at the current frame + float envelopeGenerator(const f_cnt_t delay, const f_cnt_t attack, const f_cnt_t hold, const f_cnt_t decay, const float sustain, const f_cnt_t release) const; + + //! Helper function for calculating the lfo value at the current frame + float lfoGenerator(const f_cnt_t delay, const f_cnt_t fade, const float freq) const; + + +private: + //! Stores whether this object still represents a sound which exists. + //! This will be true when the sound is active, but will become false once the release has ended or it has been forcefully deactivated. + bool m_active = false; + //! Stores whether the note has been released yet + bool m_released = false; + + //! Stores the current sample rate for convenience + float m_lmmsSampleRate = 0.0f; + + //! Cache the rate the sample should be played (based on pitch and sample rate, compared to lmms's sample rate) + // rather than computing it per buffer/frame. This does not include the effect of the pitch env/lfo, since those are computed every frame + float m_baseFreqRatio = 0.0f; + //! Similarly cache the base amplitude. The amplitude envelope/lfo will be applied on top of this. + float m_baseAmplitudeLeft = 1.0f; + float m_baseAmplitudeRight = 1.0f; + + //! The number of frames since the start of the sound. This may be negative if the region has delay or the note starts partway through a buffer. + int m_frameCount = 0; + //! The frame at which the note was released, relative to the start of the note + int m_releaseFrame = 0; + + //! Stores the current frame index being played in the region's sample. This is a decimal, since interpolation is done to change pitch/sample rate. Double is needed, since float did not provide enough precision and led to the pitch drifting up and down ever so slightly during playback. + double m_sampleFrame = 0.0; + + //! Some SFZ's utilize filters (e.g, a lowpass which changes cutoff depending on velocity), so each voice needs to have a filter object + // TODO this assumes 2 channels--is that okay? + BasicFilters<2> m_filter = {44100}; // This just initializes it to something so this class can be default-initialized in an array, but this will be reinitialized later. + + //! The trigger event which caused this sound + SfzTrigger m_trigger; + + //! The region this sound originated from + SfzRegion* m_region = nullptr; + + //! A pointer to the global sfz player state. This variable is needed since pitchbending/microtuning requires the frequency + //! of the key to be updated (potentially every frame), so we need some way to check the current frequency of the key this + //! voice originated from every buffer + const SfzGlobalState* m_globalState = nullptr; + + //! The sample object of the parent region + const SfzSampleBuffer* m_sampleObject = nullptr; +}; + + +} // namespace lmms + + +#endif // LMMS_SFZ_REGION_PLAY_STATE_H diff --git a/plugins/SfzPlayer/SfzSampleBuffer.cpp b/plugins/SfzPlayer/SfzSampleBuffer.cpp new file mode 100644 index 00000000000..20abc43e8a6 --- /dev/null +++ b/plugins/SfzPlayer/SfzSampleBuffer.cpp @@ -0,0 +1,74 @@ +/* + * SfzSampleBuffer.cpp - Custom sample data class + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzSampleBuffer.h" +#include "interpolation.h" + +namespace lmms +{ + + +SfzSampleBuffer::SfzSampleBuffer(const SampleFrame* data, const f_cnt_t size, const float sampleRate) + : m_data(new float[size * NUM_CHANNELS]) + , m_size(size) + , m_sampleRate(sampleRate) +{ + for (f_cnt_t f = 0; f < size; ++f) + { + for (size_t channel = 0; channel < NUM_CHANNELS; ++channel) + { + m_data[f * NUM_CHANNELS + channel] = data[f][channel]; + } + } +} + +SfzSampleBuffer::~SfzSampleBuffer() +{ + delete[] m_data; +} + + +float SfzSampleBuffer::at(const float index, const size_t channel) const +{ + if (index < 0 || index >= m_size) { return 0.0f; } + + const f_cnt_t indexFloor = static_cast(index); + + float frac = index - indexFloor; + + f_cnt_t i0 = indexFloor == 0 ? 0 : indexFloor - 1; + f_cnt_t i1 = indexFloor; + f_cnt_t i2 = std::min(indexFloor + 1, m_size - 1); + f_cnt_t i3 = std::min(indexFloor + 2, m_size - 1); + + float v0 = m_data[NUM_CHANNELS * i0 + channel]; + float v1 = m_data[NUM_CHANNELS * i1 + channel]; + float v2 = m_data[NUM_CHANNELS * i2 + channel]; + float v3 = m_data[NUM_CHANNELS * i3 + channel]; + + return hermiteInterpolate(v0, v1, v2, v3, frac); +} + + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzSampleBuffer.h b/plugins/SfzPlayer/SfzSampleBuffer.h new file mode 100644 index 00000000000..cb3bbf9f4b7 --- /dev/null +++ b/plugins/SfzPlayer/SfzSampleBuffer.h @@ -0,0 +1,66 @@ +/* + * SfzSampleBuffer.h - Custom sample data class + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_SAMPLE_BUFFER_H +#define LMMS_SFZ_SAMPLE_BUFFER_H + +#include "SampleFrame.h" +#include + +namespace lmms +{ + +class SampleFrame; + +class SfzSampleBuffer +{ +public: + SfzSampleBuffer() = default; + SfzSampleBuffer(const SampleFrame* data, const f_cnt_t size, const float sampleRate); + ~SfzSampleBuffer(); + + //! Returns a hermite-interpolated value for the data at the given sample index and channel. + //! The interpolation is so that pitch shifting and resampling is as easy as possible + //! If index is out of range, this function returns 0.0f + float at(const float index, const size_t channel) const; + + //! Returns the number of frames in the sample. + f_cnt_t size() const { return m_size; } + + //! Returns the sample rate of the audio + float sampleRate() const { return m_sampleRate; } + + static constexpr const size_t NUM_CHANNELS = 2; + +private: + //! The raw sample data is stored as a raw pointer to an interleaved array of floats in (number_of_samples)x(channels) format. + float* m_data = nullptr; + + f_cnt_t m_size = 0; + float m_sampleRate = 0.0f; +}; + +} // namespace lmms + +#endif // LMMS_SFZ_SAMPLE_BUFFER_H diff --git a/plugins/SfzPlayer/SfzSamplePool.cpp b/plugins/SfzPlayer/SfzSamplePool.cpp new file mode 100644 index 00000000000..31565a475eb --- /dev/null +++ b/plugins/SfzPlayer/SfzSamplePool.cpp @@ -0,0 +1,99 @@ +/* + * SfzSamplePool.cpp - Helper class for handling loading of sample files + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzSamplePool.h" +#include "SampleBuffer.h" + +namespace lmms +{ + +// Default initialize the static shared pool pointer with no parameters, essentially like passing nullptr. s_instance.expired() will be true at the start. +std::weak_ptr SfzSamplePool::s_instance = std::weak_ptr(); + +const std::shared_ptr SfzSamplePool::instance() +{ + if (s_instance.expired()) + { + // Return the shared_ptr to the SfzPlayer, but keep a weak_ptr copy so that we can make more shared_ptr copies to anyone who asks later, but it won't prevent it from being automatically deleted when all the SfzPlayers are destroyed. + std::shared_ptr inst = std::make_shared(); + s_instance = std::weak_ptr(inst); + return inst; + } + else + { + return std::shared_ptr(s_instance); + } +} + + +const int SfzSamplePool::sampleMemoryUsage() const +{ + int total = 0; + for (const auto& [key, value] : m_samplePool) + { + if (!value.expired()) + { + const int sizeOfSampleData = value.lock()->size() * SfzSampleBuffer::NUM_CHANNELS * sizeof(float); + const int sizeOfSampleBufferObject = sizeof(SfzSampleBuffer); + total += sizeOfSampleData + sizeOfSampleBufferObject; + } + } + return total; +} + + +const std::shared_ptr SfzSamplePool::loadSample(const QString& path, bool* sampleInPool) +{ + // If the sample has already been loaded before, return a shared pointer to it + if (m_samplePool.contains(path) && !m_samplePool.at(path).expired()) + { + *sampleInPool = true; + return m_samplePool.at(path).lock(); + } + else if (auto buffer = SampleBuffer::fromFile(path)) + { + // Otherwise, load the sample file and create a shared_ptr. Store a weak_ptr in the sample pool so that it can be freely deleted when no more SfzPlayers are using it. + *sampleInPool = false; + auto sampleSharedPtr = std::make_shared(buffer->data(), buffer->size(), buffer->sampleRate()); + m_samplePool.insert({path, std::weak_ptr(sampleSharedPtr)}); + return sampleSharedPtr; + } + else + { + *sampleInPool = false; + return nullptr; + } +} + +void SfzSamplePool::clearExpiredWeakPtrs() +{ + std::erase_if(m_samplePool, [](const auto& entry){ + const auto& [key, value] = entry; + return value.expired(); + }); +} + + +} // namespace lmms + diff --git a/plugins/SfzPlayer/SfzSamplePool.h b/plugins/SfzPlayer/SfzSamplePool.h new file mode 100644 index 00000000000..686148c2793 --- /dev/null +++ b/plugins/SfzPlayer/SfzSamplePool.h @@ -0,0 +1,68 @@ +/* + * SfzSamplePool.h - Helper class for handling loading of sample files + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_SAMPLE_POOL_H +#define LMMS_SFZ_SAMPLE_POOL_H + +#include "SfzSampleBuffer.h" + +#include +#include + +namespace lmms +{ + +class SfzSamplePool +{ +public: + //! Returns a shared_ptr to the global sample pool instance, shared across all SfzPlayers. + //! If no sample pool has been created yet, or the previous one was deleted because all active SfzPlayer were closed, it will initialize a new one + static const std::shared_ptr instance(); + + //! Attempts to load the sample from the given path. It checks if it has already been loaded into the sample pool, otherwise it will load it from disk. + //! Sets `sampleInPool` to true if the sample was already loaded, false if not. + //! Returns a std::shard_ptr to the sample object if successful. Returns nullptr if it could not load the sample from disk. + const std::shared_ptr loadSample(const QString& path, bool* sampleInPool); + + //! Returns the number of samples currently loaded in the pool + const int sampleCount() const { return m_samplePool.size(); } + + //! Returns the total number of bytes used by the sample objects. Technically this might not be the total memory used, depending on padding or extra minor objects I missed, but it should be the bulk of the data. + const int sampleMemoryUsage() const; + + //! Removes any samples pointers from the buffer which no longer exist. Because the pool stores them as weak_ptrs, they may have been deleted when a previous SfzPlayer was deleted, but not removed from the pool. This method goes through and cleans it up so that only active samples remain. + void clearExpiredWeakPtrs(); + +private: + //! Stores a list of std::weak_ptrs to the SfzSampleBuffer objects for each loaded sample. Weak pointers are used so that the pool can be shared among multiple instances of the SfzPlayer, each one's regions holding a std::shared_ptr, which when all destroyed, allow the object to be automatically deleted. + std::map> m_samplePool; + + //! Global pointer to the sample pool instance shared across all SfzPlayers. + //! This is a weak_ptr, but the individual SfzPlayers will use shared_ptrs of it, so that when the last SfzPlayer is deleted, the sample pool will also be deleted. + static std::weak_ptr s_instance; +}; + +} // namespace lmms + +#endif // LMMS_SFZ_SAMPLE_POOL_H diff --git a/plugins/SfzPlayer/SfzTrigger.cpp b/plugins/SfzPlayer/SfzTrigger.cpp new file mode 100644 index 00000000000..0383e56cf6b --- /dev/null +++ b/plugins/SfzPlayer/SfzTrigger.cpp @@ -0,0 +1,60 @@ +/* + * SfzTrigger.cpp - Custom class for representing MIDI events relevant to the SFZ player + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "SfzTrigger.h" + +namespace lmms +{ + +const SfzTrigger SfzTrigger::noteOnEvent(const int frameOffset, const int key, const int velocity) +{ + SfzTrigger trigger = SfzTrigger(); + trigger.m_type = Type::NoteOn; + trigger.m_key = key; + trigger.m_velocity = velocity; + trigger.m_frameOffset = frameOffset; + return trigger; +} + +const SfzTrigger SfzTrigger::noteOffEvent(const int frameOffset, const int key, const int velocity) +{ + SfzTrigger trigger = SfzTrigger(); + trigger.m_type = Type::NoteOff; + trigger.m_key = key; + trigger.m_velocity = velocity; + trigger.m_frameOffset = frameOffset; + return trigger; +} + +const SfzTrigger SfzTrigger::controlChangeEvent(const int frameOffset, const int controlNumber, const int value) +{ + SfzTrigger trigger = SfzTrigger(); + trigger.m_type = Type::ControlChange; + trigger.m_controlChangeNumber = controlNumber; + trigger.m_controlChangeValue = value; + trigger.m_frameOffset = frameOffset; // Frame offset is not currently for CC events, since making them sample-exact would be inconvenient + return trigger; +} + +} // namespace lmms diff --git a/plugins/SfzPlayer/SfzTrigger.h b/plugins/SfzPlayer/SfzTrigger.h new file mode 100644 index 00000000000..95c976e329a --- /dev/null +++ b/plugins/SfzPlayer/SfzTrigger.h @@ -0,0 +1,71 @@ +/* + * SfzTrigger.h - Custom class for representing MIDI events relevant to the SFZ player + * + * Copyright (c) 2026 Keratin + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_SFZ_TRIGGER_H +#define LMMS_SFZ_TRIGGER_H + +#include + +namespace lmms +{ + + +class SfzTrigger +{ +public: + //! Helper functions for creating different types of events + static const SfzTrigger noteOnEvent(const int frameOffset, const int key, const int vel); + static const SfzTrigger noteOffEvent(const int frameOffset, const int key, const int vel); + static const SfzTrigger controlChangeEvent(const int frameOffset, const int controlNumber, const int value); + + enum class Type + { + NoteOn, + NoteOff, + ControlChange, + }; + + const auto& type() const { return m_type; } + const auto& key() const { return m_key; } + const auto& velocity() const { return m_velocity; } + const auto& controlChangeNumber() const { return m_controlChangeNumber; } + const auto& controlChangeValue() const { return m_controlChangeValue; } + const int frameOffset() const { return m_frameOffset; } + +private: + Type m_type; + // TODO should these be optionals or not? + std::optional m_key = std::nullopt; + std::optional m_velocity = std::nullopt; + std::optional m_controlChangeNumber = std::nullopt; + std::optional m_controlChangeValue = std::nullopt; + //! The event probably occurred partway through a buffer, so this variable keeps track of when exactly it occurred relative to the start of the buffer + int m_frameOffset = 0; +}; + + +} // namespace lmms + + +#endif // LMMS_SFZ_TRIGGER_H diff --git a/plugins/SfzPlayer/logo.svg b/plugins/SfzPlayer/logo.svg new file mode 100644 index 00000000000..56a53be261b --- /dev/null +++ b/plugins/SfzPlayer/logo.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/src/gui/FileBrowser.cpp b/src/gui/FileBrowser.cpp index fd379a7c9af..cb70010c38b 100644 --- a/src/gui/FileBrowser.cpp +++ b/src/gui/FileBrowser.cpp @@ -717,6 +717,7 @@ QList FileBrowserTreeWidget::getContextActions(FileItem* file, bool so case FileItem::FileType::SoundFont: case FileItem::FileType::Patch: case FileItem::FileType::VstPlugin: + case FileItem::FileType::SFZ: fileCanBeInstrument = true; case FileItem::FileType::Project: @@ -827,7 +828,7 @@ void FileBrowserTreeWidget::previewFileItem(FileItem* file) const bool isPlugin = file->handling() == FileItem::FileHandling::LoadByPlugin; newPPH = new PresetPreviewPlayHandle(fileName, isPlugin); } - else if (file->type() != FileItem::FileType::VstPlugin && file->isTrack()) + else if (file->type() != FileItem::FileType::VstPlugin && file->type() != FileItem::FileType::SFZ && file->isTrack()) { DataFile dataFile(fileName); if (dataFile.validate(ext)) @@ -897,6 +898,10 @@ void FileBrowserTreeWidget::mouseMoveEvent( QMouseEvent * me ) new StringPairDrag( "soundfontfile", f->fullName(), embed::getIconPixmap( "soundfont_file" ), this ); break; + case FileItem::FileType::SFZ: + new StringPairDrag("sfzfile", f->fullName(), + embed::getIconPixmap("soundfont_file"), this); + break; case FileItem::FileType::Patch: new StringPairDrag( "patchfile", f->fullName(), embed::getIconPixmap( "sample_file" ), this ); @@ -1212,6 +1217,7 @@ void FileItem::initPixmaps() setIcon(0, s_presetFilePixmap); break; case FileType::SoundFont: + case FileType::SFZ: setIcon(0, s_soundfontFilePixmap); break; case FileType::VstPlugin: @@ -1258,6 +1264,10 @@ void FileItem::determineFileType() { m_type = FileType::SoundFont; } + else if (ext == "sfz") + { + m_type = FileType::SFZ; + } else if( ext == "pat" ) { m_type = FileType::Patch; @@ -1326,6 +1336,7 @@ QString FileItem::defaultFilters() const auto projectFilters = QStringList{"*.mmp", "*.mpt", "*.mmpz"}; const auto presetFilters = QStringList{"*.xpf", "*.xml", "*.xiz", "*.lv2"}; const auto soundFontFilters = QStringList{"*.sf2", "*.sf3"}; + const auto sfzFilters = QStringList{"*.sfz"}; const auto patchFilters = QStringList{"*.pat"}; const auto midiFilters = QStringList{"*.mid", "*.midi", "*.rmi"}; @@ -1340,7 +1351,7 @@ QString FileItem::defaultFilters() audioFilters.append("*.mp3"); #endif - const auto extensions = projectFilters + presetFilters + soundFontFilters + patchFilters + midiFilters + const auto extensions = projectFilters + presetFilters + soundFontFilters + sfzFilters + patchFilters + midiFilters + vstPluginFilters + audioFilters; return extensions.join(" "); diff --git a/src/gui/editors/TrackContainerView.cpp b/src/gui/editors/TrackContainerView.cpp index 1747e671d51..b413c9f8764 100644 --- a/src/gui/editors/TrackContainerView.cpp +++ b/src/gui/editors/TrackContainerView.cpp @@ -369,7 +369,7 @@ void TrackContainerView::dragEnterEvent( QDragEnterEvent * _dee ) { StringPairDrag::processDragEnterEvent( _dee, QString( "presetfile,pluginpresetfile,samplefile,instrument," - "importedproject,soundfontfile,patchfile,vstpluginfile,projectfile," + "importedproject,soundfontfile,sfzfile,patchfile,vstpluginfile,projectfile," "track_%1,track_%2" ). arg( static_cast(Track::Type::Instrument) ). arg( static_cast(Track::Type::Sample) ) ); @@ -401,7 +401,7 @@ void TrackContainerView::dropEvent( QDropEvent * _de ) } else if( type == "samplefile" || type == "pluginpresetfile" || type == "soundfontfile" || type == "vstpluginfile" - || type == "patchfile" ) + || type == "sfzfile" || type == "patchfile" ) { auto it = dynamic_cast(Track::create(Track::Type::Instrument, m_tc)); PluginFactory::PluginInfoAndKey piakn =