diff --git a/benchmarks/BM_multiplyAddFixedGain.cpp b/benchmarks/BM_multiplyAddFixedGain.cpp index b484389d..919516b2 100644 --- a/benchmarks/BM_multiplyAddFixedGain.cpp +++ b/benchmarks/BM_multiplyAddFixedGain.cpp @@ -14,58 +14,64 @@ class MultiplyAddFixedGain : public benchmark::Fixture { public: - void SetUp(const ::benchmark::State& state) { - std::random_device rd { }; - std::mt19937 gen { rd() }; - std::uniform_real_distribution dist { 0, 1 }; - input = std::vector(state.range(0)); - output = std::vector(state.range(0)); - gain = dist(gen); - std::fill(output.begin(), output.end(), 1.0f ); - std::generate(input.begin(), input.end(), [&]() { return dist(gen); }); - } + void SetUp(const ::benchmark::State& state) + { + std::random_device rd {}; + std::mt19937 gen { rd() }; + std::uniform_real_distribution dist { 0, 1 }; + input = std::vector(state.range(0)); + output = std::vector(state.range(0)); + gain = dist(gen); + std::fill(output.begin(), output.end(), 1.0f); + std::generate(input.begin(), input.end(), [&]() { return dist(gen); }); + } - void TearDown(const ::benchmark::State& state [[maybe_unused]]) { + void TearDown(const ::benchmark::State& state [[maybe_unused]]) + { + } - } - - float gain = {}; - std::vector input; - std::vector output; + float gain = {}; + std::vector input; + std::vector output; }; -BENCHMARK_DEFINE_F(MultiplyAddFixedGain, Straight)(benchmark::State& state) { - for (auto _ : state) - { +BENCHMARK_DEFINE_F(MultiplyAddFixedGain, Straight) +(benchmark::State& state) +{ + for (auto _ : state) { for (int i = 0; i < state.range(0); ++i) output[i] += gain * input[i]; } } -BENCHMARK_DEFINE_F(MultiplyAddFixedGain, Scalar)(benchmark::State& state) { - for (auto _ : state) - { +BENCHMARK_DEFINE_F(MultiplyAddFixedGain, Scalar) +(benchmark::State& state) +{ + for (auto _ : state) { sfz::multiplyAdd(gain, input, absl::MakeSpan(output)); } } -BENCHMARK_DEFINE_F(MultiplyAddFixedGain, SIMD)(benchmark::State& state) { - for (auto _ : state) - { +BENCHMARK_DEFINE_F(MultiplyAddFixedGain, SIMD) +(benchmark::State& state) +{ + for (auto _ : state) { sfz::multiplyAdd(gain, input, absl::MakeSpan(output)); } } -BENCHMARK_DEFINE_F(MultiplyAddFixedGain, Scalar_Unaligned)(benchmark::State& state) { - for (auto _ : state) - { +BENCHMARK_DEFINE_F(MultiplyAddFixedGain, Scalar_Unaligned) +(benchmark::State& state) +{ + for (auto _ : state) { sfz::multiplyAdd(gain, absl::MakeSpan(input).subspan(1), absl::MakeSpan(output).subspan(1)); } } -BENCHMARK_DEFINE_F(MultiplyAddFixedGain, SIMD_Unaligned)(benchmark::State& state) { - for (auto _ : state) - { +BENCHMARK_DEFINE_F(MultiplyAddFixedGain, SIMD_Unaligned) +(benchmark::State& state) +{ + for (auto _ : state) { sfz::multiplyAdd(gain, absl::MakeSpan(input).subspan(1), absl::MakeSpan(output).subspan(1)); } } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 57c71312..3580130c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,6 +15,9 @@ set (SFIZZ_SOURCES sfizz/FloatEnvelopes.cpp sfizz/Logger.cpp sfizz/SfzFilter.cpp + sfizz/Effects.cpp + sfizz/effects/Nothing.cpp + sfizz/effects/Lofi.cpp ) include (SfizzSIMDSourceFiles) diff --git a/src/sfizz/AudioSpan.h b/src/sfizz/AudioSpan.h index 312c1e1b..f1bd0f91 100644 --- a/src/sfizz/AudioSpan.h +++ b/src/sfizz/AudioSpan.h @@ -209,6 +209,22 @@ public: return {}; } + /** + * @brief Convert implicitly to a pointer of channels + */ + operator const float* const*() const noexcept + { + return spans.data(); + } + + /** + * @brief Convert implicitly to a pointer of channels + */ + operator float* const*() noexcept + { + return spans.data(); + } + /** * @brief Get a Span corresponding to a specific channel * diff --git a/src/sfizz/Config.h b/src/sfizz/Config.h index 0b629ccd..b2a6f8a2 100644 --- a/src/sfizz/Config.h +++ b/src/sfizz/Config.h @@ -69,6 +69,10 @@ namespace config { */ const absl::string_view midnamManufacturer { "The Sfizz authors" }; const absl::string_view midnamModel { "Sfizz" }; + /** + Limit of how many "fxN" buses are accepted (in SFZv2, maximum is 4) + */ + constexpr int maxEffectBuses { 256 }; } // namespace config // Enable or disable SIMD accelerators by default diff --git a/src/sfizz/Effects.cpp b/src/sfizz/Effects.cpp new file mode 100644 index 00000000..512f2b5e --- /dev/null +++ b/src/sfizz/Effects.cpp @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: BSD-2-Clause + +// This code is part of the sfizz library and is licensed under a BSD 2-clause +// license. You should have receive a LICENSE.md file along with the code. +// If not, contact the sfizz maintainers at https://github.com/sfztools/sfizz + +#include "Effects.h" +#include "AudioSpan.h" +#include "Opcode.h" +#include "SIMDHelpers.h" +#include "Config.h" +#include "effects/Nothing.h" +#include "effects/Lofi.h" +#include + +namespace sfz { + +void EffectFactory::registerStandardEffectTypes() +{ + // TODO + registerEffectType("lofi", fx::Lofi::makeInstance); +} + +void EffectFactory::registerEffectType(absl::string_view name, Effect::MakeInstance& make) +{ + FactoryEntry ent; + ent.name = std::string(name); + ent.make = &make; + _entries.push_back(std::move(ent)); +} + +std::unique_ptr EffectFactory::makeEffect(absl::Span members) +{ + const Opcode* opcode = nullptr; + + for (auto it = members.rbegin(); it != members.rend() && !opcode; ++it) { + if (it->lettersOnlyHash == hash("type")) + opcode = &*it; + } + + if (!opcode) { + DBG("The effect does not specify a type"); + return std::make_unique(); + } + + const absl::string_view type = opcode->value; + + const auto it = absl::c_find_if(_entries, [&](auto&& entry) { return entry.name == type; }); + if (it == _entries.end()) { + DBG("Unsupported effect type: " << type); + return std::make_unique(); + } + + auto fx = it->make(members); + if (!fx) { + DBG("Could not instantiate effect of type: " << type); + return std::make_unique(); + } + + return fx; +} + +/// +EffectBus::EffectBus() +{ +} + +EffectBus::~EffectBus() +{ +} + +void EffectBus::addEffect(std::unique_ptr fx) +{ + _effects.emplace_back(std::move(fx)); +} + +void EffectBus::clearInputs(unsigned nframes) +{ + AudioSpan(_inputs).first(nframes).fill(0.0f); + AudioSpan(_outputs).first(nframes).fill(0.0f); +} + +void EffectBus::addToInputs(const float* const addInput[], float addGain, unsigned nframes) +{ + if (addGain == 0) + return; + + for (unsigned c = 0; c < EffectChannels; ++c) { + absl::Span addIn { addInput[c], nframes }; + sfz::multiplyAdd(addGain, addIn, _inputs.getSpan(c)); + } +} + +void EffectBus::setSampleRate(double sampleRate) +{ + for (const auto& effectPtr : _effects) + effectPtr->setSampleRate(sampleRate); +} + +void EffectBus::clear() +{ + for (const auto& effectPtr : _effects) + effectPtr->clear(); +} + +void EffectBus::process(unsigned nframes) +{ + size_t numEffects = _effects.size(); + + if (numEffects > 0 && hasNonZeroOutput()) { + _effects[0]->process( + AudioSpan(_inputs), AudioSpan(_outputs), nframes); + for (size_t i = 1; i < numEffects; ++i) + _effects[i]->process( + AudioSpan(_outputs), AudioSpan(_outputs), nframes); + } else + fx::Nothing().process( + AudioSpan(_inputs), AudioSpan(_outputs), nframes); +} + +void EffectBus::mixOutputsTo(float* const mainOutput[], float* const mixOutput[], unsigned nframes) +{ + const float gainToMain = _gainToMain; + const float gainToMix = _gainToMix; + + for (unsigned c = 0; c < EffectChannels; ++c) { + auto fxOut = _outputs.getConstSpan(c); + sfz::multiplyAdd(gainToMain, fxOut, absl::Span(mainOutput[c], nframes)); + sfz::multiplyAdd(gainToMix, fxOut, absl::Span(mixOutput[c], nframes)); + } +} + +size_t EffectBus::numEffects() const noexcept +{ + return _effects.size(); +} +void EffectBus::setSamplesPerBlock(int samplesPerBlock) noexcept +{ + _inputs.resize(samplesPerBlock); + _outputs.resize(samplesPerBlock); + + for (const auto& effectPtr : _effects) + effectPtr->setSamplesPerBlock(samplesPerBlock); +} + +} // namespace sfz diff --git a/src/sfizz/Effects.h b/src/sfizz/Effects.h new file mode 100644 index 00000000..1c1edfeb --- /dev/null +++ b/src/sfizz/Effects.h @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: BSD-2-Clause + +// This code is part of the sfizz library and is licensed under a BSD 2-clause +// license. You should have receive a LICENSE.md file along with the code. +// If not, contact the sfizz maintainers at https://github.com/sfztools/sfizz + +#pragma once +#include "AudioBuffer.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include +#include +#include + +namespace sfz { +struct Opcode; + +enum { + // Number of channels processed by effects + EffectChannels = 2, +}; + +/** + @brief Abstract base of SFZ effects + */ +class Effect { +public: + virtual ~Effect() {} + + /** + @brief Initializes with the given sample rate. + */ + virtual void setSampleRate(double sampleRate) = 0; + + /** + * @brief Sets the maximum number of frames to render at a time. The actual + * value can be lower but should never be higher. + */ + virtual void setSamplesPerBlock(int samplesPerBlock) = 0; + + /** + @brief Reset the state to initial. + */ + virtual void clear() = 0; + + /** + @brief Computes a cycle of the effect in stereo. + */ + virtual void process(const float* const inputs[], float* const outputs[], unsigned nframes) = 0; + + /** + @brief Type of the factory function used to instantiate an effect given + the contents of the block + */ + typedef std::unique_ptr(MakeInstance)(absl::Span members); +}; + +/** + @brief SFZ effects factory + */ +class EffectFactory { +public: + /** + @brief Registers all available standard effects into the factory. + */ + void registerStandardEffectTypes(); + + /** + @brief Registers a user-defined effect into the factory. + */ + void registerEffectType(absl::string_view name, Effect::MakeInstance& make); + + /** + @brief Instantiates an effect given the contents of the block. + */ + std::unique_ptr makeEffect(absl::Span members); + +private: + struct FactoryEntry { + std::string name; + Effect::MakeInstance* make; + }; + + std::vector _entries; +}; + +/** + @brief Sequence of effects processed in series + */ +class EffectBus { +public: + EffectBus(); + ~EffectBus(); + + /** + @brief Adds an effect at the end of the bus. + */ + void addEffect(std::unique_ptr fx); + + /** + @brief Checks whether this bus can produce output. + */ + bool hasNonZeroOutput() const { return _gainToMain != 0 || _gainToMix != 0; } + + /** + @brief Sets the amount of effect output going to the main. + */ + void setGainToMain(float gain) { _gainToMain = gain; } + + /** + @brief Sets the amount of effect output going to the mix. + */ + void setGainToMix(float gain) { _gainToMix = gain; } + + /** + * @brief Returns the gain for the main out + * + * @return float + */ + float gainToMain() const { return _gainToMain; } + + /** + * @brief Returns the gain for the mix out + * + * @return float + */ + float gainToMix() const { return _gainToMix; } + + /** + @brief Resets the input buffers to zero. + */ + void clearInputs(unsigned nframes); + + /** + @brief Adds some audio into the input buffer. + */ + void addToInputs(const float* const addInput[], float addGain, unsigned nframes); + + /** + @brief Initializes all effects in the bus with the given sample rate. + */ + void setSampleRate(double sampleRate); + + /** + @brief Resets the state of all effects in the bus. + */ + void clear(); + + /** + @brief Computes a cycle of the effect bus. + */ + void process(unsigned nframes); + + /** + @brief Mixes the outputs into a pair of stereo signals: Main and Mix. + */ + void mixOutputsTo(float* const mainOutput[], float* const mixOutput[], unsigned nframes); + + /** + * @brief Sets the maximum number of frames to render at a time. The actual value can be lower + * but should never be higher. + * + */ + void setSamplesPerBlock(int samplesPerBlock) noexcept; + + /** + * @brief Return the number of effects in the bus + * + * @return size_t + */ + size_t numEffects() const noexcept; +private: + std::vector> _effects; + AudioBuffer _inputs { EffectChannels, config::defaultSamplesPerBlock }; + AudioBuffer _outputs { EffectChannels, config::defaultSamplesPerBlock }; + float _gainToMain = 0.0; + float _gainToMix = 0.0; +}; + +} // namespace sfz diff --git a/src/sfizz/Logger.cpp b/src/sfizz/Logger.cpp index 35706167..53bfa66b 100644 --- a/src/sfizz/Logger.cpp +++ b/src/sfizz/Logger.cpp @@ -83,7 +83,7 @@ sfz::Logger::~Logger() fs::path callbackLogPath{ fs::current_path() / callbackLogFilename.str() }; std::cout << "Logging " << callbackTimes.size() << " callback times to " << callbackLogPath.filename() << '\n'; std::ofstream callbackLogFile { callbackLogPath.string() }; - callbackLogFile << "Dispatch,RenderMethod,Data,Amplitude,Filters,Panning,NumVoices,NumSamples" << '\n'; + callbackLogFile << "Dispatch,RenderMethod,Data,Amplitude,Filters,Panning,Effects,NumVoices,NumSamples" << '\n'; for (auto& time: callbackTimes) callbackLogFile << time.breakdown.dispatch.count() << ',' << time.breakdown.renderMethod.count() << ',' @@ -91,6 +91,7 @@ sfz::Logger::~Logger() << time.breakdown.amplitude.count() << ',' << time.breakdown.filters.count() << ',' << time.breakdown.panning.count() << ',' + << time.breakdown.effects.count() << ',' << time.numVoices << ',' << time.numSamples << '\n'; } diff --git a/src/sfizz/Logger.h b/src/sfizz/Logger.h index 9861ba87..449c19d9 100644 --- a/src/sfizz/Logger.h +++ b/src/sfizz/Logger.h @@ -61,6 +61,7 @@ struct CallbackBreakdown Duration amplitude { 0 }; Duration filters { 0 }; Duration panning { 0 }; + Duration effects { 0 }; }; struct CallbackTime diff --git a/src/sfizz/Region.cpp b/src/sfizz/Region.cpp index e05708a0..94d0218e 100644 --- a/src/sfizz/Region.cpp +++ b/src/sfizz/Region.cpp @@ -733,6 +733,20 @@ bool sfz::Region::parseOpcode(const Opcode& opcode) setCCPairFromOpcode(opcode, amplitudeEG.ccSustain, Default::egOnCCPercentRange); break; + case hash("effect&"): + { + const auto effectNumber = opcode.parameters.back(); + if (!effectNumber || effectNumber < 1 || effectNumber > config::maxEffectBuses) + break; + auto value = readOpcode(opcode.value, { 0, 100 }); + if (!value) + break; + if (static_cast(effectNumber + 1) > gainToEffect.size()) + gainToEffect.resize(effectNumber + 1); + gainToEffect[effectNumber] = *value / 100; + break; + } + // Ignored opcodes case hash("hichan"): case hash("lochan"): @@ -1073,3 +1087,11 @@ void sfz::Region::offsetAllKeys(int offset) noexcept crossfadeKeyOutRange.setEnd(offsetAndClamp(end, offset, Default::keyRange)); } } + +float sfz::Region::getGainToEffectBus(unsigned number) const noexcept +{ + if (number >= gainToEffect.size()) + return 0.0; + + return gainToEffect[number]; +} diff --git a/src/sfizz/Region.h b/src/sfizz/Region.h index d398a63e..cf08657b 100644 --- a/src/sfizz/Region.h +++ b/src/sfizz/Region.h @@ -39,6 +39,9 @@ struct Region { : midiState(midiState), defaultPath(std::move(defaultPath)) { ccSwitched.set(); + + gainToEffect.reserve(5); // sufficient room for main and fx1-4 + gainToEffect.push_back(1.0); // contribute 100% into the main bus } Region(const Region&) = default; ~Region() = default; @@ -206,6 +209,12 @@ struct Region { bool hasKeyswitches() const noexcept { return keyswitchDown || keyswitchUp || keyswitch || previousNote; } + /** + * @brief Get the gain this region contributes into the input of the Nth + * effect bus + */ + float getGainToEffectBus(unsigned number) const noexcept; + // Sound source: sample playback std::string sample {}; // Sample float delay { Default::delay }; // delay @@ -297,6 +306,10 @@ struct Region { EGDescription filterEG; bool isStereo { false }; + + // Effects + std::vector gainToEffect; + private: const MidiState& midiState; bool keySwitched { true }; diff --git a/src/sfizz/Synth.cpp b/src/sfizz/Synth.cpp index d65baa2f..deb58a8c 100644 --- a/src/sfizz/Synth.cpp +++ b/src/sfizz/Synth.cpp @@ -21,12 +21,16 @@ using namespace std::literals; sfz::Synth::Synth() + : Synth(config::numVoices) { - resetVoices(this->numVoices); } sfz::Synth::Synth(int numVoices) { + effectFactory.registerStandardEffectTypes(); + + effectBuses.reserve(5); // sufficient room for main and fx1-4 + resetVoices(numVoices); } @@ -70,7 +74,7 @@ void sfz::Synth::callback(absl::string_view header, const std::vector& m numCurves++; break; case hash("effect"): - // TODO: implement effects + handleEffectOpcodes(members); break; default: std::cerr << "Unknown header: " << header << '\n'; @@ -118,6 +122,11 @@ void sfz::Synth::clear() for (auto& list: ccActivationLists) list.clear(); regions.clear(); + effectBuses.clear(); + effectBuses.emplace_back(new EffectBus); + effectBuses[0]->setGainToMain(1.0); + effectBuses[0]->setSamplesPerBlock(samplesPerBlock); + effectBuses[0]->setSampleRate(sampleRate); resources.filePool.clear(); resources.logger.clear(); numGroups = 0; @@ -185,6 +194,68 @@ void sfz::Synth::handleControlOpcodes(const std::vector& members) } } +void sfz::Synth::handleEffectOpcodes(const std::vector& members) +{ + absl::string_view busName = "main"; + + auto getOrCreateBus = [this](unsigned index) -> EffectBus& { + if (index + 1 > effectBuses.size()) + effectBuses.resize(index + 1); + EffectBusPtr& bus = effectBuses[index]; + if (!bus) { + bus.reset(new EffectBus); + bus->setSampleRate(sampleRate); + bus->setSamplesPerBlock(samplesPerBlock); + } + return *bus; + }; + + for (const Opcode& opcode : members) { + switch (opcode.lettersOnlyHash) { + case hash("bus"): + busName = opcode.value; + break; + + // note(jpc): gain opcodes are linear volumes in % units + + case hash("directtomain"): + if (auto valueOpt = readOpcode(opcode.value, { 0, 100 })) + getOrCreateBus(0).setGainToMain(*valueOpt / 100); + break; + + case hash("fx&tomain"): // fx&tomain + if (opcode.parameters.front() < 1 || opcode.parameters.front() > config::maxEffectBuses) + break; + if (auto valueOpt = readOpcode(opcode.value, { 0, 100 })) + getOrCreateBus(opcode.parameters.front()).setGainToMain(*valueOpt / 100); + break; + + case hash("fx&tomix"): // fx&tomix + if (opcode.parameters.front() < 1 || opcode.parameters.front() > config::maxEffectBuses) + break; + if (auto valueOpt = readOpcode(opcode.value, { 0, 100 })) + getOrCreateBus(opcode.parameters.front()).setGainToMix(*valueOpt / 100); + break; + } + } + + unsigned busIndex; + if (busName.empty() || busName == "main") + busIndex = 0; + else if (busName.size() > 2 && busName.substr(0, 2) == "fx" && absl::SimpleAtoi(busName.substr(2), &busIndex) && busIndex >= 1 && busIndex <= config::maxEffectBuses) { + // an effect bus fxN, with N usually in [1,4] + } else { + DBG("Unsupported effect bus: " << busName); + return; + } + + // create the effect and add it + EffectBus& bus = getOrCreateBus(busIndex); + auto fx = effectFactory.makeEffect(members); + fx->setSampleRate(sampleRate); + bus.addEffect(std::move(fx)); +} + void addEndpointsToVelocityCurve(sfz::Region& region) { if (region.velocityPoints.size() > 0) { @@ -385,8 +456,14 @@ void sfz::Synth::setSamplesPerBlock(int samplesPerBlock) noexcept this->samplesPerBlock = samplesPerBlock; this->tempBuffer.resize(samplesPerBlock); + this->tempMixNodeBuffer.resize(samplesPerBlock); for (auto& voice : voices) voice->setSamplesPerBlock(samplesPerBlock); + + for (auto& bus: effectBuses) { + if (bus) + bus->setSamplesPerBlock(samplesPerBlock); + } } void sfz::Synth::setSampleRate(float sampleRate) noexcept @@ -402,13 +479,17 @@ void sfz::Synth::setSampleRate(float sampleRate) noexcept resources.filterPool.setSampleRate(sampleRate); resources.eqPool.setSampleRate(sampleRate); + + for (auto& bus: effectBuses) { + if (bus) + bus->setSampleRate(sampleRate); + } } void sfz::Synth::renderBlock(AudioSpan buffer) noexcept { ScopedFTZ ftz; - if (freeWheeling) resources.filePool.waitForBackgroundLoading(); @@ -416,32 +497,77 @@ void sfz::Synth::renderBlock(AudioSpan buffer) noexcept if (!canEnterCallback) return; + size_t numFrames = buffer.getNumFrames(); + auto temp = AudioSpan(tempBuffer).first(numFrames); + auto tempMixNode = AudioSpan(tempMixNodeBuffer).first(numFrames); + CallbackBreakdown callbackBreakdown; + + { // Prepare the effect inputs. They are mixes of per-region outputs. + ScopedTiming logger { callbackBreakdown.effects }; + for (auto& bus: effectBuses) { + if (bus) + bus->clearInputs(numFrames); + } + } + int numActiveVoices { 0 }; { // Main render block ScopedTiming logger { callbackBreakdown.renderMethod }; buffer.fill(0.0f); + tempMixNode.fill(0.0f); resources.filePool.cleanupPromises(); - - auto tempSpan = AudioSpan(tempBuffer).first(buffer.getNumFrames()); for (auto& voice : voices) { - if (!voice->isFree()) { - numActiveVoices++; - voice->renderBlock(tempSpan); - buffer.add(tempSpan); - callbackBreakdown.data += voice->getLastDataDuration(); - callbackBreakdown.amplitude += voice->getLastAmplitudeDuration(); - callbackBreakdown.filters += voice->getLastFilterDuration(); - callbackBreakdown.panning += voice->getLastPanningDuration(); - } - } + if (voice->isFree()) + continue; - buffer.applyGain(db2mag(volume)); + const Region* region = voice->getRegion(); + + numActiveVoices++; + voice->renderBlock(temp); + + { // Add the output into the effects linked to this region + ScopedTiming logger { callbackBreakdown.effects, ScopedTiming::Operation::addToDuration }; + for (size_t i = 0, n = effectBuses.size(); i < n; ++i) { + if (auto& bus = effectBuses[i]) { + float addGain = region->getGainToEffectBus(i); + bus->addToInputs(temp, addGain, numFrames); + } + } + } + + callbackBreakdown.data += voice->getLastDataDuration(); + callbackBreakdown.amplitude += voice->getLastAmplitudeDuration(); + callbackBreakdown.filters += voice->getLastFilterDuration(); + callbackBreakdown.panning += voice->getLastPanningDuration(); + } } + { // Apply effect buses + // -- note(jpc) there is always a "main" bus which is initially empty. + // without any , the signal is just going to flow through it. + ScopedTiming logger { callbackBreakdown.effects, ScopedTiming::Operation::addToDuration }; + + for (auto& bus: effectBuses) { + if (bus) { + bus->process(numFrames); + bus->mixOutputsTo(buffer, tempMixNode, numFrames); + } + } + } + + // Add the Mix output (fxNtomix opcodes) + // -- note(jpc) the purpose of the Mix output is not known. + // perhaps it's designed as extension point for custom processing? + // as default behavior, it adds itself to the Main signal. + buffer.add(tempMixNode); + + // Apply the master volume + buffer.applyGain(db2mag(volume)); + callbackBreakdown.dispatch = dispatchDuration; - resources.logger.logCallbackTime(std::move(callbackBreakdown), numActiveVoices, buffer.getNumFrames()); + resources.logger.logCallbackTime(std::move(callbackBreakdown), numActiveVoices, numFrames); // Reset the dispatch counter dispatchDuration = Duration(0); @@ -683,6 +809,11 @@ const sfz::Region* sfz::Synth::getRegionView(int idx) const noexcept return (size_t)idx < regions.size() ? regions[idx].get() : nullptr; } +const sfz::EffectBus* sfz::Synth::getEffectBusView(int idx) const noexcept +{ + return (size_t)idx < effectBuses.size() ? effectBuses[idx].get() : nullptr; +} + const sfz::Voice* sfz::Synth::getVoiceView(int idx) const noexcept { return (size_t)idx < voices.size() ? voices[idx].get() : nullptr; diff --git a/src/sfizz/Synth.h b/src/sfizz/Synth.h index f2c6fd11..616cf60d 100644 --- a/src/sfizz/Synth.h +++ b/src/sfizz/Synth.h @@ -9,6 +9,7 @@ #include "Parser.h" #include "Voice.h" #include "Region.h" +#include "Effects.h" #include "LeakDetector.h" #include "MidiState.h" #include "AudioSpan.h" @@ -131,6 +132,14 @@ public: * @return const Region* */ const Voice* getVoiceView(int idx) const noexcept; + /** + * @brief Get a raw view into a specific voice. This is mostly used + * for testing. + * + * @param idx + * @return const Region* + */ + const EffectBus* getEffectBusView(int idx) const noexcept; /** * @brief Get a list of unknown opcodes. The lifetime of the * string views in the code are linked to the currently loaded @@ -400,6 +409,12 @@ private: * @param members the opcodes of the block */ void handleControlOpcodes(const std::vector& members); + /** + * @brief Helper function to dispatch opcodes + * + * @param members the opcodes of the block + */ + void handleEffectOpcodes(const std::vector& members); /** * @brief Helper function to merge all the currently active opcodes * as set by the successive callbacks and create a new region to store @@ -441,8 +456,14 @@ private: std::array noteActivationLists; std::array ccActivationLists; - // Internal temporary buffer + // Effect factory and buses + EffectFactory effectFactory; + typedef std::unique_ptr EffectBusPtr; + std::vector effectBuses; // 0 is "main", 1-N are "fx1"-"fxN" + + // Intermediate buffers AudioBuffer tempBuffer { 2, config::defaultSamplesPerBlock }; + AudioBuffer tempMixNodeBuffer { 2, config::defaultSamplesPerBlock }; int samplesPerBlock { config::defaultSamplesPerBlock }; float sampleRate { config::defaultSampleRate }; diff --git a/src/sfizz/effects/Lofi.cpp b/src/sfizz/effects/Lofi.cpp new file mode 100644 index 00000000..1fcc3304 --- /dev/null +++ b/src/sfizz/effects/Lofi.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: BSD-2-Clause + +// This code is part of the sfizz library and is licensed under a BSD 2-clause +// license. You should have receive a LICENSE.md file along with the code. +// If not, contact the sfizz maintainers at https://github.com/sfztools/sfizz + +/** + Note(jpc): implementation status + +- [x] bitred +- [ ] bitred_oncc +- [ ] bitred_smoothcc +- [ ] bitred_stepcc +- [ ] bitred_curvecc + +- [x] decim +- [ ] decim_oncc +- [ ] decim_smoothcc +- [ ] decim_stepcc +- [ ] decim_curvecc + +- [ ] egN_bitred +- [ ] egN_bitred_oncc +- [ ] lfoN_bitred +- [ ] lfoN_bitred_oncc +- [ ] lfoN_bitred_smoothcc +- [ ] lfoN_bitred_stepcc + +- [ ] egN_decim +- [ ] egN_decim_oncc +- [ ] lfoN_decim +- [ ] lfoN_decim_oncc +- [ ] lfoN_decim_smoothcc +- [ ] lfoN_decim_stepcc + + */ + +#include "Lofi.h" +#include "Opcode.h" +#include +#include +#include +#include + +namespace sfz { +namespace fx { + + void Lofi::setSampleRate(double sampleRate) + { + for (unsigned c = 0; c < EffectChannels; ++c) { + _bitred[c].init(sampleRate); + _decim[c].init(sampleRate); + } + } + + void Lofi::setSamplesPerBlock(int samplesPerBlock) + { + (void)samplesPerBlock; + } + + void Lofi::clear() + { + for (unsigned c = 0; c < EffectChannels; ++c) { + _bitred[c].clear(); + _decim[c].clear(); + } + } + + void Lofi::process(const float* const inputs[2], float* const outputs[2], unsigned nframes) + { + for (unsigned c = 0; c < EffectChannels; ++c) { + _bitred[c].setDepth(_bitred_depth); + _bitred[c].process(inputs[c], outputs[c], nframes); + + _decim[c].setDepth(_decim_depth); + _decim[c].process(outputs[c], outputs[c], nframes); + } + } + + std::unique_ptr Lofi::makeInstance(absl::Span members) + { + auto fx = std::make_unique(); + + for (const Opcode& opcode : members) { + switch (opcode.lettersOnlyHash) { + case hash("bitred"): + setValueFromOpcode(opcode, fx->_bitred_depth, { 0.0, 100.0 }); + break; + case hash("decim"): + setValueFromOpcode(opcode, fx->_decim_depth, { 0.0, 100.0 }); + break; + } + } + + return fx; + } + + /// + void Lofi::Bitred::init(double sampleRate) + { + (void)sampleRate; + + static constexpr double coefs2x[12] = { 0.036681502163648017, 0.13654762463195794, 0.27463175937945444, 0.42313861743656711, 0.56109869787919531, 0.67754004997416184, 0.76974183386322703, 0.83988962484963892, 0.89226081800387902, 0.9315419599631839, 0.96209454837808417, 0.98781637073289585 }; + fDownsampler2x.set_coefs(coefs2x); + } + + void Lofi::Bitred::clear() + { + fLastValue = 0.0; + fDownsampler2x.clear_buffers(); + } + + void Lofi::Bitred::setDepth(float depth) + { + fDepth = clamp(depth, 0.0f, 100.0f); + } + + void Lofi::Bitred::process(const float* in, float* out, uint32_t nframes) + { + if (fDepth == 0) { + if (in != out) + std::memcpy(out, in, nframes * sizeof(float)); + clear(); + return; + } + + float lastValue = fLastValue; + + const float steps = (1.0f + (100.0f - fDepth)) * 0.75f; + const float invSteps = 1.0f / steps; + + for (uint32_t i = 0; i < nframes; ++i) { + float x = in[i]; + + float y = std::copysign((int)(0.5f + std::fabs(x * steps)), x) * invSteps; + + float y2x[2]; + y2x[0] = (y != lastValue) ? (0.5f * (y + lastValue)) : y; + y2x[1] = y; + + lastValue = y; + + y = fDownsampler2x.process_sample(y2x); + out[i] = y; + } + + fLastValue = lastValue; + } + + /// + void Lofi::Decim::init(double sampleRate) + { + fSampleTime = 1.0 / sampleRate; + + static constexpr double coefs2x[12] = { 0.036681502163648017, 0.13654762463195794, 0.27463175937945444, 0.42313861743656711, 0.56109869787919531, 0.67754004997416184, 0.76974183386322703, 0.83988962484963892, 0.89226081800387902, 0.9315419599631839, 0.96209454837808417, 0.98781637073289585 }; + fDownsampler2x.set_coefs(coefs2x); + } + + void Lofi::Decim::clear() + { + fPhase = 0.0; + fLastValue = 0.0; + fDownsampler2x.clear_buffers(); + } + + void Lofi::Decim::setDepth(float depth) + { + fDepth = clamp(depth, 0.0f, 100.0f); + } + + void Lofi::Decim::process(const float* in, float* out, uint32_t nframes) + { + if (fDepth == 0) { + if (in != out) + std::memcpy(out, in, nframes * sizeof(float)); + clear(); + return; + } + + const float dt = [this]() { + // exponential curve fit + const float a = 1.289079e+00, b = 1.384141e-01, c = 1.313298e-04; + const float denom = std::pow(a, b * fDepth) * c - c; + return fSampleTime / denom; + }(); + + float phase = fPhase; + float lastValue = fLastValue; + + for (uint32_t i = 0; i < nframes; ++i) { + float x = in[i]; + + phase += dt; + float y = (phase > 1.0f) ? x : lastValue; + phase -= static_cast(phase); + + float y2x[2]; + y2x[0] = (y != lastValue) ? (0.5f * (y + lastValue)) : y; + y2x[1] = y; + + lastValue = y; + + y = fDownsampler2x.process_sample(y2x); + out[i] = y; + } + + fPhase = phase; + fLastValue = lastValue; + } + +} // namespace fx +} // namespace sfz diff --git a/src/sfizz/effects/Lofi.h b/src/sfizz/effects/Lofi.h new file mode 100644 index 00000000..9b97c6e3 --- /dev/null +++ b/src/sfizz/effects/Lofi.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BSD-2-Clause + +// This code is part of the sfizz library and is licensed under a BSD 2-clause +// license. You should have receive a LICENSE.md file along with the code. +// If not, contact the sfizz maintainers at https://github.com/sfztools/sfizz + +#pragma once +#include "Effects.h" +#include "hiir/Downsampler2xFpu.h" + +namespace sfz { +namespace fx { + + /** + * @brief Bit crushing effect + */ + class Lofi : public Effect { + public: + /** + * @brief Initializes with the given sample rate. + */ + void setSampleRate(double sampleRate) override; + + /** + * @brief Sets the maximum number of frames to render at a time. The actual + * value can be lower but should never be higher. + */ + void setSamplesPerBlock(int samplesPerBlock) override; + + /** + * @brief Reset the state to initial. + */ + void clear() override; + + /** + * @brief Computes a cycle of the effect in stereo. + */ + void process(const float* const inputs[], float* const outputs[], unsigned nframes) override; + + /** + * @brief Instantiates given the contents of the block. + */ + static std::unique_ptr makeInstance(absl::Span members); + + private: + float _bitred_depth = 0; + float _decim_depth = 0; + + /// + class Bitred { + public: + void init(double sampleRate); + void clear(); + void setDepth(float depth); + void process(const float* in, float* out, uint32_t nframes); + + private: + float fDepth = 0.0; + float fLastValue = 0.0; + hiir::Downsampler2xFpu<12> fDownsampler2x; + }; + + /// + class Decim { + public: + void init(double sampleRate); + void clear(); + void setDepth(float depth); + void process(const float* in, float* out, uint32_t nframes); + + private: + float fSampleTime = 0.0; + float fDepth = 0.0; + float fPhase = 0.0; + float fLastValue = 0.0; + hiir::Downsampler2xFpu<12> fDownsampler2x; + }; + + /// + Bitred _bitred[EffectChannels]; + Decim _decim[EffectChannels]; + }; + +} // namespace fx +} // namespace sfz diff --git a/src/sfizz/effects/Nothing.cpp b/src/sfizz/effects/Nothing.cpp new file mode 100644 index 00000000..d2101de1 --- /dev/null +++ b/src/sfizz/effects/Nothing.cpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BSD-2-Clause + +// This code is part of the sfizz library and is licensed under a BSD 2-clause +// license. You should have receive a LICENSE.md file along with the code. +// If not, contact the sfizz maintainers at https://github.com/sfztools/sfizz + +#include "Nothing.h" +#include + +namespace sfz { +namespace fx { + + void Nothing::setSampleRate(double sampleRate) + { + (void)sampleRate; + } + + void Nothing::setSamplesPerBlock(int samplesPerBlock) + { + (void)samplesPerBlock; + } + + void Nothing::clear() + { + } + + void Nothing::process(const float* const inputs[], float* const outputs[], unsigned nframes) + { + for (unsigned c = 0; c < EffectChannels; ++c) { + if (inputs[c] != outputs[c]) + std::memcpy(outputs[c], inputs[c], nframes * sizeof(float)); + } + } + +} // namespace fx +} // namespace sfz diff --git a/src/sfizz/effects/Nothing.h b/src/sfizz/effects/Nothing.h new file mode 100644 index 00000000..2ac27600 --- /dev/null +++ b/src/sfizz/effects/Nothing.h @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BSD-2-Clause + +// This code is part of the sfizz library and is licensed under a BSD 2-clause +// license. You should have receive a LICENSE.md file along with the code. +// If not, contact the sfizz maintainers at https://github.com/sfztools/sfizz + +#pragma once +#include "Effects.h" + +namespace sfz { +namespace fx { + + /** + * @brief Effect which does nothing + */ + class Nothing : public Effect { + public: + /** + * @brief Initializes with the given sample rate. + */ + void setSampleRate(double sampleRate) override; + + /** + * @brief Sets the maximum number of frames to render at a time. The actual + * value can be lower but should never be higher. + */ + void setSamplesPerBlock(int samplesPerBlock) override; + + /** + * @brief Reset the state to initial. + */ + void clear() override; + + /** + * @brief Copy the input signal to the output + */ + void process(const float* const inputs[], float* const outputs[], unsigned nframes) override; + }; + +} // namespace fx +} // namespace sfz diff --git a/tests/RegionT.cpp b/tests/RegionT.cpp index aa86f52b..71063e74 100644 --- a/tests/RegionT.cpp +++ b/tests/RegionT.cpp @@ -1350,6 +1350,23 @@ TEST_CASE("[Region] Parsing opcodes") region.parseOpcode({ "eq1_freqcc15", "50000" }); REQUIRE(region.equalizers[0].frequencyCC[15] == 30000.0f); } + + SECTION("Effects send") + { + REQUIRE(region.gainToEffect.size() == 1); + REQUIRE(region.gainToEffect[0] == 1.0f); + region.parseOpcode({ "effect1", "50.4" }); + REQUIRE(region.gainToEffect.size() == 2); + REQUIRE(region.gainToEffect[1] == 0.504f); + region.parseOpcode({ "effect3", "100" }); + REQUIRE(region.gainToEffect.size() == 4); + REQUIRE(region.gainToEffect[2] == 0.0f); + REQUIRE(region.gainToEffect[3] == 1.0f); + region.parseOpcode({ "effect3", "150.1" }); + REQUIRE(region.gainToEffect[3] == 1.0f); + region.parseOpcode({ "effect3", "-50.65" }); + REQUIRE(region.gainToEffect[3] == 0.0f); + } } // Specific region bugs diff --git a/tests/SynthT.cpp b/tests/SynthT.cpp index e5bca7c9..9ed0c73c 100644 --- a/tests/SynthT.cpp +++ b/tests/SynthT.cpp @@ -13,7 +13,7 @@ constexpr int blockSize { 256 }; TEST_CASE("[Synth] Play and check active voices") { sfz::Synth synth; - synth.setSamplesPerBlock(256); + synth.setSamplesPerBlock(blockSize); sfz::AudioBuffer buffer { 2, blockSize }; synth.loadSfzFile(fs::current_path() / "tests/TestFiles/groups_avl.sfz"); @@ -29,7 +29,7 @@ TEST_CASE("[Synth] Play and check active voices") TEST_CASE("[Synth] Change the number of voice while playing") { sfz::Synth synth; - synth.setSamplesPerBlock(256); + synth.setSamplesPerBlock(blockSize); sfz::AudioBuffer buffer { 2, blockSize }; synth.loadSfzFile(fs::current_path() / "tests/TestFiles/groups_avl.sfz"); @@ -77,6 +77,7 @@ TEST_CASE("[Synth] Check that we can change the size of the preload before and a { sfz::Synth synth; synth.setPreloadSize(512); + synth.setSamplesPerBlock(blockSize); sfz::AudioBuffer buffer { 2, blockSize }; synth.loadSfzFile(fs::current_path() / "tests/TestFiles/groups_avl.sfz"); synth.setPreloadSize(1024); @@ -92,6 +93,7 @@ TEST_CASE("[Synth] Check that we can change the oversampling factor before and a { sfz::Synth synth; synth.setOversamplingFactor(sfz::Oversampling::x2); + synth.setSamplesPerBlock(blockSize); sfz::AudioBuffer buffer { 2, blockSize }; synth.loadSfzFile(fs::current_path() / "tests/TestFiles/groups_avl.sfz"); synth.setOversamplingFactor(sfz::Oversampling::x4); @@ -208,3 +210,112 @@ TEST_CASE("[Synth] Trigger=release_key and an envelope properly kills the voice synth.renderBlock(buffer); REQUIRE( synth.getVoiceView(0)->isFree() ); } + +TEST_CASE("[Synth] Number of effect buses and resetting behavior") +{ + sfz::Synth synth; + synth.setSamplesPerBlock(blockSize); + sfz::AudioBuffer buffer { 2, blockSize }; + + REQUIRE( synth.getEffectBusView(0) == nullptr); // No effects at first + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/base.sfz"); + REQUIRE( synth.getEffectBusView(0) != nullptr); // We have a main bus + // Check that we can render blocks + for (int i = 0; i < 100; ++i) + synth.renderBlock(buffer); + + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/bitcrusher_2.sfz"); + REQUIRE( synth.getEffectBusView(0) != nullptr); // We have a main bus + REQUIRE( synth.getEffectBusView(1) != nullptr); // and an FX bus + // Check that we can render blocks + for (int i = 0; i < 100; ++i) + synth.renderBlock(buffer); + + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/base.sfz"); + REQUIRE( synth.getEffectBusView(0) != nullptr); // We have a main bus + REQUIRE( synth.getEffectBusView(1) == nullptr); // and no FX bus + // Check that we can render blocks + for (int i = 0; i < 100; ++i) + synth.renderBlock(buffer); + + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/bitcrusher_3.sfz"); + REQUIRE( synth.getEffectBusView(0) != nullptr); // We have a main bus + REQUIRE( synth.getEffectBusView(1) == nullptr); // empty/uninitialized fx bus + REQUIRE( synth.getEffectBusView(2) == nullptr); // empty/uninitialized fx bus + REQUIRE( synth.getEffectBusView(3) != nullptr); // and an FX bus (because we built up to fx3) + REQUIRE( synth.getEffectBusView(3)->numEffects() == 1); + // Check that we can render blocks + for (int i = 0; i < 100; ++i) + synth.renderBlock(buffer); +} + +TEST_CASE("[Synth] No effect in the main bus") +{ + sfz::Synth synth; + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/base.sfz"); + auto bus = synth.getEffectBusView(0); + REQUIRE( bus != nullptr); // We have a main bus + REQUIRE( bus->numEffects() == 0 ); + REQUIRE( bus->gainToMain() == 1 ); + REQUIRE( bus->gainToMix() == 0 ); +} + +TEST_CASE("[Synth] One effect") +{ + sfz::Synth synth; + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/bitcrusher_1.sfz"); + auto bus = synth.getEffectBusView(0); + REQUIRE( bus != nullptr); // We have a main bus + REQUIRE( bus->numEffects() == 1 ); + REQUIRE( bus->gainToMain() == 1 ); + REQUIRE( bus->gainToMix() == 0 ); +} + +TEST_CASE("[Synth] Effect on a second bus") +{ + sfz::Synth synth; + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/bitcrusher_2.sfz"); + auto bus = synth.getEffectBusView(0); + REQUIRE( bus != nullptr); // We have a main bus + REQUIRE( bus->numEffects() == 0 ); + REQUIRE( bus->gainToMain() == 0.5 ); + REQUIRE( bus->gainToMix() == 0 ); + bus = synth.getEffectBusView(1); + REQUIRE( bus != nullptr); + REQUIRE( bus->numEffects() == 1 ); + REQUIRE( bus->gainToMain() == 0.5 ); + REQUIRE( bus->gainToMix() == 0 ); +} + + +TEST_CASE("[Synth] Effect on a third bus") +{ + sfz::Synth synth; + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/bitcrusher_3.sfz"); + auto bus = synth.getEffectBusView(0); + REQUIRE( bus != nullptr); // We have a main bus + REQUIRE( bus->numEffects() == 0 ); + REQUIRE( bus->gainToMain() == 0.5 ); + REQUIRE( bus->gainToMix() == 0 ); + bus = synth.getEffectBusView(3); + REQUIRE( bus != nullptr); + REQUIRE( bus->numEffects() == 1 ); + REQUIRE( bus->gainToMain() == 0.5 ); + REQUIRE( bus->gainToMix() == 0 ); +} + +TEST_CASE("[Synth] Gain to mix") +{ + sfz::Synth synth; + synth.loadSfzFile(fs::current_path() / "tests/TestFiles/Effects/to_mix.sfz"); + auto bus = synth.getEffectBusView(0); + REQUIRE( bus != nullptr); // We have a main bus + REQUIRE( bus->numEffects() == 0 ); + REQUIRE( bus->gainToMain() == 1 ); + REQUIRE( bus->gainToMix() == 0 ); + bus = synth.getEffectBusView(1); + REQUIRE( bus != nullptr); + REQUIRE( bus->numEffects() == 1 ); + REQUIRE( bus->gainToMain() == 0 ); + REQUIRE( bus->gainToMix() == 0.5 ); +} diff --git a/tests/TestFiles/Effects/base.sfz b/tests/TestFiles/Effects/base.sfz new file mode 100644 index 00000000..07b3874d --- /dev/null +++ b/tests/TestFiles/Effects/base.sfz @@ -0,0 +1,4 @@ + +lokey=0 +hikey=127 +sample=*sine diff --git a/tests/TestFiles/Effects/bitcrusher_1.sfz b/tests/TestFiles/Effects/bitcrusher_1.sfz new file mode 100644 index 00000000..dd28c5c4 --- /dev/null +++ b/tests/TestFiles/Effects/bitcrusher_1.sfz @@ -0,0 +1,9 @@ + +lokey=0 +hikey=127 +sample=*sine + + +type=lofi +bitred=90 +decim=10 diff --git a/tests/TestFiles/Effects/bitcrusher_2.sfz b/tests/TestFiles/Effects/bitcrusher_2.sfz new file mode 100644 index 00000000..a0544f26 --- /dev/null +++ b/tests/TestFiles/Effects/bitcrusher_2.sfz @@ -0,0 +1,13 @@ + +lokey=0 +hikey=127 +sample=*sine +effect1=100 + + +directtomain=50 +fx1tomain=50 +type=lofi +bus=fx1 +bitred=90 +decim=10 diff --git a/tests/TestFiles/Effects/bitcrusher_3.sfz b/tests/TestFiles/Effects/bitcrusher_3.sfz new file mode 100644 index 00000000..51585e31 --- /dev/null +++ b/tests/TestFiles/Effects/bitcrusher_3.sfz @@ -0,0 +1,13 @@ + +lokey=0 +hikey=127 +sample=*sine +effect1=100 + + +directtomain=50 +fx3tomain=50 +type=lofi +bus=fx3 +bitred=90 +decim=10 diff --git a/tests/TestFiles/Effects/to_mix.sfz b/tests/TestFiles/Effects/to_mix.sfz new file mode 100644 index 00000000..65cd1837 --- /dev/null +++ b/tests/TestFiles/Effects/to_mix.sfz @@ -0,0 +1,12 @@ + +lokey=0 +hikey=127 +sample=*sine +effect1=100 + + +fx1tomix=50 +bus=fx1 +type=lofi +bitred=90 +decim=10