# Bug report draft: compiled network passes audio UNFILTERED inside a HardcodedMasterFX (raw node works)
-
Hiya @Christoph-Hart
Me and my fav Bot (RIP Fable) has identified an issue with audio processing and modulations in HardcodedMasterFX that sit inside a master container (i.e. a top level filter for my device that is obs monophonic due to voicing reasons).
I hope the below explains everything. Happy to drop a minimal script if needed.
Thanks
Phelan
Adjacent to the resolved "HISE 4.1 Hardcoded Master & Poly FX P1 P2 Modulation" thread (forum topic 13276).
Environment
- HISE develop, version 4.9.2 (running commit 8606dea6), macOS, Apple Silicon.
- A custom C++ SNEX filter node (ZDF filter, 6 params; param 0 = Cutoff/Frequency, 1 = Res/Resonance).
Context
After the 4.1 P1/P2 modulation breaking change (params no longer auto-modulated; must opt in per parameter), I'm trying to restore modulation on a global filter that sits in a HardcodedMasterFX.
What works
- The RAW C++ node placed directly in the HardcodedMasterFX FILTERS audio correctly. (But its params can no longer be modulated by the P1/P2 slots, as expected after the breaking change - createExternalModulationInfo is not implemented on it.)
- The SAME node wrapped in a compiled network and used POLYPHONICALLY in a HardcodedPolyphonicFX (per-voice) filters AND modulates correctly. This is my sampler filter and it is fine.
What doesn't (the bug)
To get P1/P2 modulation back on the MASTER (mono) filter, I wrapped the node in a compiled network:
container.chain-> single C++ filter nodeAllowCompilation="1", NOAllowPolyphonic(so it's monophonic for the master FX)- container params Cutoff/Res have
ExternalModulation="Combined"connected to the node's Frequency/Resonance.
Result in the HardcodedMasterFX:
- P1/P2 modulation now works (the matrix/extra_mod drives Cutoff/Res).
- Parameter changes reach the node (verified via scripting: getAttribute(0) returns the set cutoff, e.g. 2401 Hz).
- The effect is NOT bypassed (isBypassed() == false).
- BUT the audio passes through UNFILTERED at any cutoff/topology/response. The wrapped node never cooks its coefficients - it behaves as if prepare()/sampleRate never reached the inner node.
Earlier I also tried the POLYPHONIC network (AllowPolyphonic=1) in the master FX: that produced SILENCE (channel/voice mismatch), which is why I made the monophonic variant.
Questions
- Should a monophonic compiled network prepare/process its inner node inside a HardcodedMasterFX? It currently passes audio through unprocessed (inner node not prepared) while the raw node prepares fine.
- What is the recommended way to get a node to BOTH filter audio AND expose P1/P2 modulation inside a HardcodedMasterFX?
- For C++ nodes specifically: what is the exact signature/usage of
createExternalModulationInfoto declare params 0 and 1 as Combined-modulatable on the raw node (so I can skip the network wrapper entirely)? You mentioned "with C++ nodes you can directly call a method that defines the parameter's modulation connections" - a minimal example would let me do this directly.
-
@Phelan-Kane not my expertise field, but would that help?
https://forum.hise.audio/topic/14270/how-do-you-set-up-external-modulation-slots-c -
Here's a real life example of c++ modslots.
It's not minimal i'm afraid but it shows it in use.
If you'd like to see the minimal example, it's just as @ustk said, have a look at this post:
https://forum.hise.audio/topic/14270/how-do-you-set-up-external-modulation-slots-c// ==============================| Griffin Poly EQ Filter |=================================== // // File: Griffin_EQFilter_Poly.h // Node: Griffin_EQFilter_Poly // Package: Griffin DSP Essentials for HISE // Author: Griffinboy // HISE Forum: https://forum.hise.audio/user/griffinboy // Copyright: Copyright (c) 2026 Griffinboy // // Description: // Minimum-phase EQ filter with per-voice state and frame processing. // Designed for sample-accurate modulation in synthesis contexts. // Uses more CPU than the block-rate Griffin_EQFilter. // // Has Mod slots for frequency, Q, and gain. // // License: // GNU General Public License v3.0 or later (GPL-3.0-or-later). // // This file is part of Griffin DSP Essentials for HISE. // // 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 3 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. If not, see <https://www.gnu.org/licenses/>. // // ================================================================================================ #pragma once #include <algorithm> #include <cmath> #include <JuceHeader.h> #include "src/griffinboy/modules/essentials/eq_filter/eq_filter.h" namespace project { using namespace juce; using namespace hise; using namespace scriptnode; template <int NV> struct Griffin_EQFilter_Poly: public data::filter_node_base { using VoiceState = griffin::modules::essentials::eq_filter::EQFilterFrameProcessor; using PlotModel = griffin::modules::essentials::eq_filter::EQPlotModel; using Defaults = griffin::modules::essentials::eq_filter::EQFilterDefaults; PolyData<VoiceState, NV> voices; PlotModel plotModel; SimpleReadWriteLock topologyLock; double plotSampleRate = 44100.0; SNEX_NODE(Griffin_EQFilter_Poly); struct MetadataClass { SN_NODE_ID("Griffin_EQFilter_Poly"); }; static constexpr bool isModNode() { return false; }; static constexpr bool isPolyphonic() { return NV > 1; }; static constexpr bool hasTail() { return true; }; static constexpr bool isSuspendedOnSilence() { return true; }; static constexpr int getFixChannelAmount() { return 2; }; static constexpr int NumTables = 0; static constexpr int NumSliderPacks = 0; static constexpr int NumAudioFiles = 0; static constexpr int NumFilters = 1; static constexpr int NumDisplayBuffers = 0; void prepare(PrepareSpecs specs) { SimpleReadWriteLock::ScopedWriteLock sl(topologyLock); voices.prepare(specs); for (auto& voice : voices) voice.prepare(specs.sampleRate, specs.blockSize); plotModel.prepare(specs.sampleRate); plotSampleRate = specs.sampleRate > 0.0 ? specs.sampleRate : 44100.0; if (auto fd = dynamic_cast<FilterDataObject*>(this->externalData.obj)) fd->setSampleRate(plotSampleRate); sendCoefficientUpdateMessage(); voiceManager.prepare(specs); voiceManager.setActive(1.0); } void reset() { SimpleReadWriteLock::ScopedWriteLock sl(topologyLock); for (auto& voice : voices) voice.reset(); voiceManager.reset(); } void handleHiseEvent(HiseEvent& e) { voiceManager.handleHiseEvent(e); } template <typename T> void process(T& data) { if (auto sl = SimpleReadWriteLock::ScopedTryReadLock(topologyLock)) { static constexpr int NumChannels = getFixChannelAmount(); auto& fixData = data.template as<ProcessData<NumChannels>>(); auto& voice = voices.get(); auto fd = fixData.toFrameData(); while (fd.next()) voice.processFrame(fd.toSpan()); voiceManager.process(data); } } template <typename T> void processFrame(T& data) { if (auto sl = SimpleReadWriteLock::ScopedTryReadLock(topologyLock)) voices.get().processFrame(data); } int handleModulation(double& value) { ignoreUnused(value); return 0; } double getPlotValue(int getMagnitude, double freqNorm) override { if (getMagnitude == 0) return 0.0; const auto frequency = std::clamp(freqNorm, 0.0, 0.5) * plotSampleRate; return plotModel.getMagnitudeAtFrequency(frequency); } void setExternalData(const ExternalData& data, int index) { data::filter_node_base::setExternalData(data, index); ignoreUnused(index); if (auto fd = dynamic_cast<FilterDataObject*>(data.obj)) fd->setSampleRate(plotSampleRate); sendCoefficientUpdateMessage(); } void createExternalModulationInfo(OpaqueNode::ModulationProperties& info) { modulation::ParameterProperties::ConnectionList list; auto addParameterSlot = [&list](int parameterIndex) { modulation::ConnectionInfo slot; slot.connectedParameterIndex = parameterIndex; slot.modColour = HiseModulationColours::ColourId::FX; slot.connectionMode = modulation::ConnectionMode::Parameter; slot.modulationMode = modulation::ParameterMode::ScaleAdd; list.push_back(slot); }; addParameterSlot(2); addParameterSlot(3); addParameterSlot(4); info.fromConnectionList(list); info.setModulationBlockSize(Defaults::modulationBlockSize); } template <int P> void setParameter(double v) { if constexpr (P == 0) { SimpleReadWriteLock::ScopedWriteLock sl(topologyLock); const auto nextShape = (int)std::round(v); applyToVoices( [nextShape](VoiceState& filter) { filter.setType(nextShape); }); updatePlotFromFirstVoice( [this, nextShape] { plotModel.setType(nextShape); }); } else if constexpr (P == 1) { SimpleReadWriteLock::ScopedWriteLock sl(topologyLock); const auto nextSlope = (int)std::round(v); applyToVoices( [nextSlope](VoiceState& filter) { filter.setSlopeMode(nextSlope); }); updatePlotFromFirstVoice( [this, nextSlope] { plotModel.setSlopeMode(nextSlope); }); } else if constexpr (P == 2) { const auto value = (float)v; applyToVoices( [value](VoiceState& filter) { filter.setFrequencyHz(value); }); updatePlotFromFirstVoice( [this, value] { plotModel.setFrequencyHz(value); }); } else if constexpr (P == 3) { const auto value = (float)v; applyToVoices( [value](VoiceState& filter) { filter.setQ(value); }); updatePlotFromFirstVoice( [this, value] { plotModel.setQ(value); }); } else if constexpr (P == 4) { const auto value = (float)v; applyToVoices( [value](VoiceState& filter) { filter.setGainDb(value); }); updatePlotFromFirstVoice( [this, value] { plotModel.setGainDb(value); }); } } void createParameters(ParameterDataList& data) { { parameter::data p("Shape", { 0.0, 6.0, 1.0 }); StringArray names; addTypeLabels(names); p.setParameterValueNames(names); registerCallback<0>(p); p.setDefaultValue((double)Defaults::type); data.add(std::move(p)); } { parameter::data p("Slope", { 0.0, 3.0, 1.0 }); StringArray names; addSlopeLabels(names); p.setParameterValueNames(names); registerCallback<1>(p); p.setDefaultValue((double)Defaults::slopeMode); data.add(std::move(p)); } { parameter::data p("Frequency", { 20.0, 20000.0, 0.01 }); p.setSkewForCentre(1000.0); p.info.textConverter = parameter::pod::TextValueConverters::Frequency; registerCallback<2>(p); p.setDefaultValue(Defaults::frequencyHz); data.add(std::move(p)); } { parameter::data p("Q", { 0.025, 25.0, 0.001 }); p.setSkewForCentre(0.70710678); registerCallback<3>(p); p.setDefaultValue(Defaults::q); data.add(std::move(p)); } { parameter::data p("Gain", { -30.0, 30.0, 0.01 }); p.info.textConverter = parameter::pod::TextValueConverters::Decibel; registerCallback<4>(p); p.setDefaultValue(Defaults::gainDb); data.add(std::move(p)); } } private: static constexpr int getNumTypes() noexcept { return griffin::modules::essentials::eq_filter::EQFilterDesign::getNumTypes(); } template <typename Function> void applyToVoices(Function&& function) { // PolyData iteration follows HISE's poly callback. // Each active voice receives the parameter update in its own DSP state. for (auto& voice : voices) function(voice); } template <typename Function> void updatePlotFromFirstVoice(Function&& function) { // The plot is shared UI state. The first HISE voice owns graph updates // so voices do not compete for the displayed response. if (! voices.isVoiceRenderingActive() || voices.isFirst()) { function(); sendCoefficientUpdateMessage(); } } static void addTypeLabels(StringArray& names) { names.add("Lowpass"); names.add("Highpass"); names.add("Bandpass"); names.add("Notch"); names.add("Bell"); names.add("Low Shelf"); names.add("High Shelf"); } static void addSlopeLabels(StringArray& names) { names.add("12 dB/oct"); names.add("24 dB/oct"); names.add("48 dB/oct"); names.add("96 dB/oct"); } envelope::silent_killer<NV> voiceManager; }; } // namespace project