How to use a collection of VCVLightLatch parameters as radio buttons?

Hello,

I’m writing a small utility module which presents two columns of ten VCVLightLatch parameters, like this…

I want each column to operate like HTML radio buttons do, whereby only one “value” from each column can be selected / active.

I’ve written some draft code (it loops through the VCVLightLatch params, checks which one has been “pressed”, keeps the value for that one and zeros out the others) but I can’t get it working.

I have a feeling I’m using the wrong approach.

I’d be grateful if anyone could give me some pointers on how to do this. It’s basically a switch presented as a bunch of toggle lights, if that makes sense.

Thank you, Alan.

2 Likes

Hi @chrtlnghmstr! Question makes total sense. I like the UI by the way!

The approach you outline sounds basically correct: sense which button in each group has changed from 0 to 1, then zero out the others in the group (or, maybe slightly more efficiently, zero out the previously pressed button, if any, which is in the group). Do you know which part isn’t working? Happy to take a look if you post the draft code.

To overthink for a moment: since, as you say, this is pretty much two switches, arguably the best way to do it would be a custom control that registers one param as a switch and drives the light values. (It can look exactly the same, you’d just have two params instead of twenty). I guess one reason not to think of it as exactly like a switch is if it’s important to capture the null state, where nothing’s pressed–sure, you could do it as a switch with an eleventh position that shows all lights off, but that’s a little unusual. However, if I’m clocking the model right, you’d probably want -10/10 => -10/10 as the default, yeah? Also, radio buttons in GUIs usually can’t get unselected once selected, and some GUIs force a preselection for that reason.

I’d suggest doing it first with twenty VCVLightLatches, but consider a custom switch class before release if you feel like it.

1 Like

I took a few minutes and wrote a vanilla version of this up–not carefully tested, but it seems to work OK. There are lots of other ways it could be done; this is a very simple, unoptimized one that doesn’t keep a local copy of anything other than the button that’s currently activated.

BLOCK_DEACTIVATION determines whether the group can be set back to a state in which no button is pressed. If it’s true then if the user presses the currently lit button it’ll stay lit; if it’s false then the system resets to the original state in which button_pressed is -1, meaning nothing is lit. The actual code in process() should be tested to make sure it handles either approach gracefully.

Happy to answer any questions on this, and any suggestions/developments are welcome, whether from you, @chrtlnghmstr , or others!

#include "plugin.hpp"

struct RBTest : Module {
  enum ParamId {
    ENUMS(RADIOBUTTON_PARAMS, 10),
    NUM_PARAMS
  };
  enum InputId {
    NUM_INPUTS
  };
  enum OutputId {
    NUM_OUTPUTS
  };
  enum LightId {
    ENUMS(RADIOBUTTON_LIGHTS, 10),
    NUM_LIGHTS
  };

  dsp::ClockDivider lightDivider;

   // -1 means "none pressed"; it's the starting condition
  int button_pressed = -1; 

  // configures behaviour when currently lit button is pressed
  bool BLOCK_DEACTIVATION = true;
  
  RBTest() {
    config(NUM_PARAMS, NUM_INPUTS, NUM_OUTPUTS, NUM_LIGHTS);
    lightDivider.setDivision(32);

    for (int i=0; i < 10; ++i)
    {
      configSwitch(RADIOBUTTON_PARAMS + i, 0.f, 1.f, 0.f,
                   "Button " + std::to_string(i), {"Off", "On"});
    }
  }

  void process(const ProcessArgs& args) override {
    // this is an audio-rate logical parameter check; 
    // probably should be put under a divider

    for (int check_button_num=0; check_button_num < 10; ++check_button_num)
    {
      bool this_button_status = params[RADIOBUTTON_PARAMS + check_button_num].getValue() > 0.f;

      if (this_button_status)
      {
        if (check_button_num != button_pressed)
        {
           button_pressed = check_button_num;

           // If we've pressed a button, blank everything except the current one.
           // This could probably be made more efficient at the cost of some complexity.

           for (int set_button_num=0; set_button_num < 10; ++set_button_num)
           {
              // putting the bool in .setValue() stops us from needing a conditional
              params[RADIOBUTTON_PARAMS + set_button_num].setValue(button_pressed == set_button_num);
           }
        }
      } else if (check_button_num == button_pressed) {
        // This catches attempts to *deactivate* the current button.
        if (BLOCK_DEACTIVATION)
        {
          // One approach is to override the attempt.
          // Note that the interface flashes "Off" for a second--
          // test to make sure that the DSP side handles this gracefully.
          params[RADIOBUTTON_PARAMS + check_button_num].setValue(1.0f); 
        } else {
          // Or we can allow it but reset button_pressed to -1.
          button_pressed = -1;
        }
      }
    }

    if (lightDivider.process()) {
      for (int set_light_num=0; set_light_num < 10; ++set_light_num)
      {
        lights[RADIOBUTTON_LIGHTS + set_light_num].setBrightness(params[RADIOBUTTON_PARAMS + set_light_num].getValue());
      }
    }
  }
};

struct RBTestWidget : ModuleWidget {
   RBTestWidget(RBTest* module) {
     setModule(module);
     setPanel(createPanel(asset::plugin(pluginInstance, "res/RBTest.svg")));

     for (int make_latch_num=0; make_latch_num < 10; ++make_latch_num)
     {
        addParam(createLightParamCentered<VCVLightLatch<MediumSimpleLight<WhiteLight>>>(mm2px(Vec(5.08, 20 + (make_latch_num * 10))), module, RBTest::RADIOBUTTON_PARAMS + make_latch_num, RBTest::RADIOBUTTON_LIGHTS + make_latch_num));
     }
   }
};

Model* modelRBTest = createModel<RBTest, RBTestWidget>("RBTest");
2 Likes

Thank you very much! I will try that approach tonight (after work!).

1 Like

Great question! I’m facing the same design problem in a module I’m writing now.

I think, from a data modelling perspective, I would prefer to operate on a single param, which means building a composite widget for the UI. I’m likely to need it elsewhere in my current project, so it’s worth the effort.

What’s needed is a random-access switch. Current switches and knobs are linear, and you have to visit the intermediary values.

Rack has a some wonderful plugins to map/automate parameters (e.g. CV-map and ilk) so I worry that a composite widget could make it difficult to use these mappers/extenders. These tools may address only the direct child widgets of a module, so having a deeper hierarchy could make them unusable. I can see potential problems whether it’s modelled as multiple params with mutual exclusion logic in process(), or as a single param with multiple widgets. I don’t have enough experience yet to predict what issues there may be.

2 Likes

This is a really good point. Conserving params is good but there’s an explicit difference in the behaviour as well.

I’m writing such a tool right now, so I’ve read a ton of mapping/automating module code, and the good news (I think) is that they typically work through ParamHandles, so they should be widget-hierarchy-agnostic. With a ParamHandle one can put in special controller modes to jump from position to position directly in a switch without hitting the intermediate values, so that’s one way to handle it. The widgets that are basically impossible to map are the ones that directly capture input and process it–but, fair enough, those are the equivalents of touchscreens in hardware, you wouldn’t expect them to be mappable in the same way.

And actually this is why conserving params is good–it would let you map @chrtlnghmstr 's module above to two knobs instead of twenty buttons without having special logic in the controller. Don’t want to go too far OT on this thread but it’s very interesting stuff, and I appreciate that you’re thinking about the controller ecosystem in your own design, @pachde!

2 Likes

Great feedback about how the mappers work.

In my immediate example it’s 8 led-push-buttons which happen to choose a file to load, so it’s logically a 9-way switch underneath. Pressing an unlit button turns it on (sets appropriate non-zero param value and loads a file) and turns all others off. Pressing a lit button turns it off, leaving you at param value 0 (no file selected). So, it’s an asymmetrical value latching.

The button light states look like a bitset, but the underlying value is an integer (or enum), an index into the bitset. There’s no light or button at index 0.

Not sure if mappers can make proper sense of that.

You’re right that classic option groups don’t behave like this, but then, these were designed for the context of a modal dialog (implicit unset or default entry state, with ok or cancel exits, so a linear interaction path). A module panel isn’t modal (it’s nonlinear).

1 Like

Right, makes good sense. If I follow, that’s the behaviour the code snippet I put above will display if the (poorly named) BLOCK_DEACTIVATION == false, and it does have the additional benefit that the display never overrides the user’s physical expectations (if you push a lit latch, it becomes unlit). The “hard” radio-button approach with either no unset state or an unset state that can’t be re-reached after user interaction feels a little strange on a physical device or simulacrum thereof.

If a mapper’s mapping the ParamHandle whose underlying param range is [0, 8], stepped by 1, and the module and widget working together correctly translate button inputs to the correct param state, and translate the correct param state to the display, the mapping should work just fine. In your case, if the 9 states are mapped to 9 positions on a hardware knob (say), the mapping of 0 to “nothing lit” (UI-land)/ “nothing loaded” (process-land) is sensible.

Now this raises a side question related to your earlier point–how do you skip values on a knob the way you can with a set of grouped buttons? My general answer, which is one of the reasons I’m writing this control module, is that a way of deferring output from a continuous controller is incredibly useful, including for cases like this. More on that in another thread soon.

Of course, the alternative–really, the only practical alternative (without special handling) if the code uses individual params, as in my code snippet does–is to require a 1:1 correspondence between buttons (or, less optimally, knobs) and the interface. This has pluses and minuses. In some cases, the pluses are such that I actually might like to express a stepped knob as a set of grouped buttons on a controller. This is a problem in hardware too–you can’t go from 16’ to 4’ on a Minimoog without going through 8’ first, and that means your sounds are constrained by the controller during live play. The deferred output trick gets you the same functionality but less precision.

Hello,

Thanks for your help. I’ve got it working, though I ended up using VCVLightButton instead of VCVLightLatch. That was after looking at how the VCV Pulses module works.

I can’t get the saving of settings working for some reason but that’s a separate problem to solve.

Here’s the (slightly truncated) code:

#include "plugin.hpp"

struct Tug : Module {
	enum ParamId {
		ENUMS(BUTTON_PARAMS, 20),
		PARAMS_LEN
	};
	enum InputId {
		INPUT_INPUT,
		INPUTS_LEN
	};
	enum OutputId {
		OUTPUT_OUTPUT,
		OUTPUTS_LEN
	};
	enum LightId {
		ENUMS(BUTTON_LIGHTS, 20),
		LIGHTS_LEN
	};

	int fromRange = -1;
	int toRange = -1;
	dsp::BooleanTrigger tapTriggers[20];

	Tug() {
		config(PARAMS_LEN, INPUTS_LEN, OUTPUTS_LEN, LIGHTS_LEN);
		for (int i = 0; i < 20; i++) {
			configButton(BUTTON_PARAMS + i, string::f("Button %d", i + 1));
		}
		configInput(INPUT_INPUT, "Input");
		configOutput(OUTPUT_OUTPUT, "Output");
	}

	void process(const ProcessArgs& args) override {

		// find out which column light button has been pressed (if any) and save its index
		// index values 0 - 9 are from the "FROM column of light buttons
		// index values 10 - 19 are from the "TO" column of light buttons
		for (int i = 0; i < 20; i++) {
			bool tap = params[BUTTON_PARAMS + i].getValue() > 0.f;
			if (tapTriggers[i].process(tap)) {
				if (i < 10) {
					fromRange = i;
				} else {
					toRange = i;
				}
				break;
			}
		}

		// set all lights
		for (int i = 0; i < 10; i++) {
			lights[BUTTON_LIGHTS + i].setBrightness((i != fromRange ? 0.f : 1.f));
			lights[BUTTON_LIGHTS + i + 10].setBrightness((i + 10 != toRange ? 0.f : 1.f));
		}

		// actual work of the plugin will then go here…
	}

	// save settings
	json_t* settingsToJson() {
		json_t* rootJ = json_object();
		json_object_set_new(rootJ, "fromRange", json_integer(fromRange));
		json_object_set_new(rootJ, "toRange", json_integer(toRange));
		return rootJ;
	}

	// load settings
	void settingsFromJson(json_t* rootJ) {
		json_t* fromRangeJ = json_object_get(rootJ, "fromRange");
		json_t* toRangeJ = json_object_get(rootJ, "toRange");

		if (fromRangeJ) {
			fromRange = json_integer_value(fromRangeJ);
		}

		if (toRangeJ) {
			toRange = json_integer_value(toRangeJ);
		}
	}
};


struct TugWidget : ModuleWidget {
	TugWidget(Tug* module) {
		setModule(module);
		setPanel(createPanel(asset::plugin(pluginInstance, "res/Tug.svg")));

		// blah blah

		// from buttons
		addParam(createLightParamCentered<VCVLightButton<MediumSimpleLight<WhiteLight>>>(mm2px(Vec(10.16, 32.15)), module, Tug::BUTTON_PARAMS + 0, Tug::BUTTON_LIGHTS + 0));
		// then 9 more…

		// to buttons
		addParam(createLightParamCentered<VCVLightButton<MediumSimpleLight<WhiteLight>>>(mm2px(Vec(30.48, 32.15)), module, Tug::BUTTON_PARAMS + 10, Tug::BUTTON_LIGHTS + 10));
		// then 9 more…

		addInput(createInputCentered<PJ301MPort>(mm2px(Vec(10.16, 109.225)), module, Tug::INPUT_INPUT));

		addOutput(createOutputCentered<PJ301MPort>(mm2px(Vec(30.48, 109.225)), module, Tug::OUTPUT_OUTPUT));
	}
};


Model* modelTug = createModel<Tug, TugWidget>("Tug");

Thanks again!

1 Like

Figured out what I was doing wrong with saving settings using settingsToJson and settingsFromJson. As the guidance posted by @Vortico states in Rack development blog - #94 by Vortico I need to place those functions and the variables they save in the global scope of the plugin’s code.

1 Like