HISE Logo Forum
    • Categories
    • Register
    • Login

    I think I've figured out a better way to create parameters for a node

    Scheduled Pinned Locked Moved C++ Development
    9 Posts 4 Posters 133 Views
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • OrvillainO
      Orvillain
      last edited by Orvillain

      Boy, I hope I don't butcher this! But it is working for me, so I thought I would write it it.

      Context: I am working on a BBD Delay model, as previously shown. There are around 90 odd parameters that I want, because I want my UI to set each parameter to a specific value, according to a chip or pedal voicing dictionary.

      Now the classic method of doing this:

      void createParameters(ParameterDataList& data)
      		{
      			{ // Delay Time
      			 	parameter::data p("Delay Time", { 10.0, 5000.0, 1.0});
      			 	registerCallback<0>(p);
      			 	p.setDefaultValue(500.0);
      			 	data.add(std::move(p));
      			 }
      		}
      

      And then this:

      template <int P> void setParameter(double v)
      
      		{
      			 if (P == 0) {effect.setDelayMs(v); return;}
      
      		}
      

      Honestly I just found it unreadable when trying to scale this out to 90+ parameters.

      Now I knew about lambda functions from Python, and I have heard many c++ developers talk about them over the years, so with a bit of ChatGPT research, this is what I've come up with.

      Everything to follow happens inside the project namespace, and inside the nodes main struct.

      First we need a parameter spec. When we create a parameter we need to specify several things - name, min value, max value, and optionally the step size. So we write a little struct to capture these things:

      		struct ParamSpec
      		{
      			int index; // the code doesn't use this - it is purely for readability later on
      			const char* name;
      			float min, max, step, def;
      		};
      

      Then we need a set of parameters, that are never changing, are static, and constant. Like this:

          inline static constexpr ParamSpec kSpecs[] = {
              // idx, name,         min,    max,      step, def,
              { 0,   "Delay Time",  20.0f,  10000.0f, 1.0f, 500.0f},
      
              { 1,   "Mix",         0.0f,   1.0f,     0.01f, 0.5f },
      
              { 2,   "Feedback",    0.0f,   2.0f,     0.01f, 0.5f},
          };
      

      This creates essentially an array of ParamSpec objects. We've filled in the data required, and the whole thing is assigned to kSpecs. The compiler knows this will never change, because it is a static constexpr object.

      Then immediately after we just want the number of parameters:

      inline static constexpr std::size_t kNumParams = std::size(kSpecs);
      

      From what I can gather, some compilers need this total size number to be static as well.

      From here, we write a function that uses std::index_sequence to essential unroll the array and create the objects we need. Like this:

      		template <std::size_t... Is>
      		void createParametersImpl(ParameterDataList& data, std::index_sequence<Is...>)
      		{
      			( [&] {
      				const auto& s = kSpecs[Is];
      
      				parameter::data p(s.name, { s.min, s.max, s.step });
      				this->template registerCallback<Is>(p);
      				p.setDefaultValue(s.def);
      				data.add(std::move(p));
      			}(), ... );
      		}
      

      The way std::index_sequence works in this context is, it unrolls our array of parameters and creates a big array of them, complete with the relevant data. You can think of it as a compile-time for loop, that counts what we're supplying it.

      Then Is becomes the index of whatever thing inside the array we're looking at and s becomes the specific instance inside the array. Because it is a struct with named parameters inside it (min/max/step/def) we can access them very easily by going s.def, for example.

      So we have the index, we have each individual parameter value, and we have the name - plus this->template is prepended to the registerCallback line, so that we know the compiler knows where that function lives.

      So after putting that in place, our createParameters method becomes:

      		void createParameters(ParameterDataList& data)
      		{
      			createParametersImpl(data,
      		        std::make_index_sequence<std::size(kSpecs)>{});
      		}
      

      You can see that it runs our actual parameter creation method, which iterates over our kSpecs array and fires off the expected parameter creation and callback registration lines. This means we can maintain one single source of truth for our parameters. This in theory should create all of our parameters at compile time, register them for the node, and they will appear on the node when you load it up in HISE - and they'll be in the order specified in the kSpecs list.

      So to round that up. Do something like this inside your nodes struct and you will have an easier time of creating parameters:

      struct ParamSpec
      {
          int index;               // purely for readability; not used by code
          const char* name;
          float min, max, step, def;
      };
      
      inline static constexpr ParamSpec kSpecs[] = {
          // idx, name,         min,    max,      step,  def
          { 0,   "Delay Time",  20.0f,  10000.0f, 1.0f,  500.0f },
          { 1,   "Mix",          0.0f,       1.0f, 0.01f, 0.5f  },
          { 2,   "Feedback",     0.0f,       2.0f, 0.01f, 0.5f  },
      };
      
      inline static constexpr std::size_t kNumParams = std::size(kSpecs);
      
      template <std::size_t... Is>
      void createParametersImpl(ParameterDataList& data, std::index_sequence<Is...>)
      {
          ( [&] {
              const auto& s = kSpecs[Is];
              parameter::data p(s.name, { s.min, s.max, s.step }); // use {min,max} if step unsupported
              this->template registerCallback<Is>(p);
              p.setDefaultValue(s.def);
              data.add(std::move(p));
          }(), ... );
      }
      
      void createParameters(ParameterDataList& data)
      {
          createParametersImpl(data, std::make_index_sequence<kNumParams>{});
      }
      
      

      But what if we wanted to link each parameter to a particular callback inside the DSP class? That's where lambdas come in.

      The very first step is to declare a Self reference at the top of our nodes struct. In my case that is BBDProcessor, but in your case it would be something else, and the name needs to match your nodes name. For instance:

      namespace project
      {
      	using namespace juce;
      	using namespace hise;
      	using namespace scriptnode;
      
      	template <int NV>
      	struct BBDProcessor: public data::base
      	{
      		using Self = BBDProcessor<NV>;
      		SNEX_NODE(BBDProcessor);
      
      		struct MetadataClass { SN_NODE_ID("BBDProcessor"); };
      

      This line:
      using Self = BBDProcessor;

      That gives us a reference to our node class. To be clear, you should use whatever your node is called - not BBDProcessor.

      <NV>
      

      is required too.

      After that, we can add an extra line to our ParamSpec:

      		struct ParamSpec
      		{
      			int index;
      			const char* name;
      			float min, max, step, def;
      			void (*apply)(Self&, double);
      		};
      

      *void (apply)(Self&, double);
      Breaking this down bit by bit:

      void = doesn't return anything
      (*apply) = apply is a pointer
      (Self&, double) = takes two arguments .... makes Self a reference to another thing... which will ultimately be the node's class itself.... and the second argument is a double value.

      This effectively gives our ParamSpec an internal function, that later on will point to another function. But it needs to know WHICH function we want to run.

      So we move onto updating our actual parameter specifications, by adding a lamda to each of the entries. In my case, I have a particular object that is an instance of a DSP class I wrote (my main BBD Delay effect) so that makes this is quite easy. It looks like this:

      inline static constexpr ParamSpec kSpecs[] = {
          // idx, name,        min,   max,   step,  def,    apply
          { 0, "Delay Time",   20.0f, 10000.0f, 1.0f, 500.0f,
            +[](Self& s, double v){ s.effect.setDelayMs(asFloat(v)); } },
      
          { 1, "Feedback",     0.0f,  2.0f,     0.01f, 0.5f,
            +[](Self& s, double v){ s.effect.setFeedback(asFloat(v)); } },
      
          { 2, "Mix",          0.0f,  1.0f,     0.01f, 0.5f,
            +[](Self& s, double v){ s.effect.setMix(asFloat(v)); } },
      };
      

      +[](Self& s, double v){ s.effect.setDelayMs(static_cast(v)); } },

      +[] tells the compiler this is a lambda pointer function. Emphasis on the pointer - it is effectively saying "hey compiler, point at this function but treat it as throwaway"

      Now for this to work, we do need some helper functions:

      		static float asFloat(double v) { return static_cast<float>(v); }
      		static int   asInt(double v)   { return (int)std::lround(v); }
      		static bool  asBool(double v)  { return v >= 0.5; }
      

      These are used to convert the double coming from our parameter, into whatever kind of value our actual setter function expects. I'm not yet using any bools, but you can see I am casting as a float for delayMs, feedback, and mix.

      Finally, our setParameter method becomes:

      
      		template <int P>
      		void setParameter(double v)
      		{
      			static_assert(P < (int)std::size(kSpecs), "Parameter index out of range");
      			kSpecs[P].apply(*this, v);
      		}
      

      This is the thing that causes any given parameter index to trigger the associated function; in my case, delay functions.

      So that's it. After a bit of study, I think I finally get this.

      • ParamSpec holds everything that the node needs to know about each parameter.
      • createParametersImpl() uses a compile-time index_sequence to build and register them.
      • setParameter() uses the apply function pointer to trigger the associated function that we setup in the array of parameters.
      • Adding new parameters is now just a case of adding a single line to kSpecs.

      No management of indexes. No messing around later on with multi-line editing and moving lines around when you want to change parameter order. No needing to delete parameters from the creation and value set stages separately. It is all contained within one place.

      I probably wouldn't have gotten here without ChatGPT tbh. Being able to say "hey my dude, I'm a complete idiot and have an IQ of 76, please explain it better before I unsubscribe" without getting developer derision (common!) is quite a powerful thing.

      Anyway, jokes aside, hope this helps!

      Musician - Instrument Designer - Sonic Architect - Creative Product Owner
      Crafting sound at every level. From strings to signal paths, samples to systems.

      HISEnbergH ustkU griffinboyG 3 Replies Last reply Reply Quote 2
      • HISEnbergH
        HISEnberg @Orvillain
        last edited by

        @Orvillain Nice find this looks like a big time saver (in certain scenarios). Thanks for sharing!

        1 Reply Last reply Reply Quote 1
        • ustkU
          ustk @Orvillain
          last edited by

          @Orvillain Noice way of handling those boys!

          Hise made me an F5 dude, browser just suffers...

          1 Reply Last reply Reply Quote 0
          • OrvillainO
            Orvillain
            last edited by

            So this works fine on MacOS, but is hitting some compile errors on Windows :)

            Will figure it out and update!

            Musician - Instrument Designer - Sonic Architect - Creative Product Owner
            Crafting sound at every level. From strings to signal paths, samples to systems.

            1 Reply Last reply Reply Quote 0
            • OrvillainO
              Orvillain
              last edited by

              The error I get is:

              error C2027: use of undefined type 'project::BBDProcessor<1>'
              

              So that must mean that MVSC doesn't work the same way when it comes to:

              using Self = BBDProcessor<NV>;
              

              Musician - Instrument Designer - Sonic Architect - Creative Product Owner
              Crafting sound at every level. From strings to signal paths, samples to systems.

              1 Reply Last reply Reply Quote 0
              • griffinboyG
                griffinboy @Orvillain
                last edited by

                @Orvillain

                That's very interesting.

                In case you didn't already know, there are quite a few other 'hidden' features in parameter data. Such as defining skews, labelled discrete type parameters etc.

                OrvillainO 1 Reply Last reply Reply Quote 0
                • OrvillainO
                  Orvillain @griffinboy
                  last edited by

                  @griffinboy said in I think I've figured out a better way to create parameters for a node:

                  @Orvillain

                  That's very interesting.

                  In case you didn't already know, there are quite a few other 'hidden' features in parameter data. Such as defining skews, labelled discrete type parameters etc.

                  Yeah I figured that would be the case. For my purposes right now, I only need the ones I've got.

                  I've got my node compiling on Windows now. But the node crashes on instantiation. Investigating ....

                  Musician - Instrument Designer - Sonic Architect - Creative Product Owner
                  Crafting sound at every level. From strings to signal paths, samples to systems.

                  1 Reply Last reply Reply Quote 1
                  • OrvillainO
                    Orvillain
                    last edited by Orvillain

                    Okay, fixed my parameter creation issue.... will post a update soon... just need to confirm that it works on MacOS as well as Windows.... not too many changes if it does work.

                    EDIT: Right.. yes it works on MacOS. Will post more details after. Gotta do the school run. siiiiiigh.

                    Musician - Instrument Designer - Sonic Architect - Creative Product Owner
                    Crafting sound at every level. From strings to signal paths, samples to systems.

                    1 Reply Last reply Reply Quote 0
                    • OrvillainO
                      Orvillain
                      last edited by

                      So on Windows the original solution did not compile. I had to figure out what was going on. The central issue was that the 'Self' reference we setup before was not accessible at the stage that MSVC wanted during compile.

                      So the solution. All of the following happens inside the node struct:

                      • Remove this:
                      using Self = BBDProcessor<NV>;
                      

                      We simply don't need it now. Also remove the kSpecs array that exists inside the node class - probably best to copy it to another file for now.

                      • Update our ParamSpec struct so that the apply method takes an instance of the node class:
                      struct ParamSpec
                      		{
                      			int index;
                      			const char* name;
                      			float min, max, step, def;
                      			void (*apply)(BBDProcessor<NV>&, double); // this line here was updated
                      		};
                      
                      • Establish some statics:
                      		static const std::array<ParamSpec, 1>& specs() noexcept;   // <- size 1 for now
                      		static constexpr int paramCount() noexcept { return (int)specs().size(); }
                      		static constexpr size_t kParamCount = 1;
                      

                      The first line defines a compile time static function called specs() - this is linked to an array type, with the data type of our ParamSpec object. We also need to tell it how many. I've gone for '1' in this example, but in my full node it is '71'.

                      The second line defines a static helper function that essentially returns the size of specs().

                      The third line sets up a constexpr for paramCount - essentially the same thing as our size. This specs() function will be further defined inside of the node class later.

                      Then we need to adjust how we create the parameter. So back to this function:

                      		template <std::size_t... Is>
                      		void createParametersImpl(ParameterDataList& data, std::index_sequence<Is...>)
                      		{
                      			( [&] {
                      				const auto& s = specs()[Is];
                      				parameter::data p(juce::String::fromUTF8(s.name), {s.min, s.max, s.step});
                      				this->template registerCallback<Is>(p);
                      				p.setDefaultValue(s.def);
                      				data.add(std::move(p));
                      			}(), ... );
                      		}
                      

                      This is very similar to before. But we get a reference to our specs() object, assign it to s. This becomes the parameter source. Is is still our compile-time iterator.

                      • Now we want to adjust how we set a parameter:
                      		template <int P>
                      		void setParameter(double v)
                      		{
                      			static_assert(P < kParamCount, "Parameter index out of range");
                      			const auto& spec = specs()[P];
                      			jassert(spec.apply != nullptr);
                      			spec.apply(*this, v);
                      		}
                      

                      There's an assert to catch whenever our parameter index goes out of range - ie; when adding extra parameters, did we update the ParamCount? If not... go back and do it, idiot. (speaking from experience!!)

                      We get an instance of specs() and assign it to spec. But since we're indexing with the template parameter, the compiler knows which parameter we're looking for inside specs. We also assert if the particular parameter we're working on right now doesn't have an apply method associated with it.

                      The key bit is spec.apply(*this, v);

                      This is a function pointer that looks in our parameter table for the relevant callback.

                      Now... at this point, we don't actually have a set of ParamSpecs anymore.... so we need to do that.... but it CANNOT be inside of the node's class. It needs to be its own separate struct, but still inside the project namespace. So at the bottom, you can add something like this:

                      
                      	template<int NV>
                      	const std::array<typename BBDProcessor<NV>::ParamSpec, 1>&
                      	BBDProcessor<NV>::specs() noexcept
                      	{
                      		static const std::array<ParamSpec, 1> tbl = {{
                      		// --- Core Delay ---
                      		{  0, "Delay Time (ms)",            5.0f,     10000.0f,   0.01f,   500.0f,
                      		+[](BBDProcessor<NV>& s, double v){ s.effect.setDelayMs(v); } },
                      		}};
                      		return tbl;
                      	};
                      

                      So this is a templated static function. Each instance of BBDProcessor (or rather, our nodes struct) gets its own version of this. Which is fine, because for our node, we only want one set of this anyway. If we use two nodes in parallel, each will obviously get its own internal set of parameters.

                      const std::array<ParamSpec, 1>&
                      

                      This bit sets up a reference to a compile-time fixed size array, with the data type ParamSpec. All told, we're basically overriding the specs() function inside of the BBDProcessor struct (our node class).

                      The rest of it just constructs a table of parameters much as we did before, but it returns the table at the end. So specs() inside the node class becomes a version of the table.

                      Note the changes here:

                      +[](BBDProcessor<NV>& s, double v){ s.effect.setDelayMs(v); } },
                      

                      No longer using self as the type for s. Instead we're using the node class as the type.

                      This all happens at compile time, so once you have it plumbed in correctly, you don't really need to worry too much about anything except getting the table correct, and the param counts correct.

                      Musician - Instrument Designer - Sonic Architect - Creative Product Owner
                      Crafting sound at every level. From strings to signal paths, samples to systems.

                      1 Reply Last reply Reply Quote 1
                      • First post
                        Last post

                      17

                      Online

                      2.0k

                      Users

                      12.7k

                      Topics

                      110.2k

                      Posts