Event dispatch with lookups and lambdas
Posted: Thu Feb 02, 2023 5:53 pm
The more things I have in a UI, the more of a pain I find it dispatching events from the Notify method to update smoothed knob values here, start processing a newly-connected input there, etc.
So, I wrote this thing:
What does it do? Well, it simplifies your handlers in Notify to this:
and the wiring of events starts looking like this:
as opposed to the old style:
There's a slight penalty in terms of hash lookup and method dispatch, but none of this is happening in the main ProcessSample loop, so I doubt it's worth caring about very much.
A bit further downstream of this, you can start defining components representing common collections of UI controls and their interactions, and writing custom registration methods to do all the wiring for them. Here's an example:
A CvModulatableKnob is actually three things: a big knob which controls a value, an audio input which supplies a modulating signal, and a little knob which controls how much the modulating signal modifies the value. It's a common pattern in many modules.
To make it efficient, we just want to be providing the value of the big knob if the audio input isn't connected. Once the audio input is connected, we want to start pulling values from it, and multiplying them together with the value of the little knob and the value of the big knob to get the actual value of the control - that's a lot more work for the CPU, so we only switch it on when we see that the input's been wired up. We also smooth the two knobs' values, although again there's no point in applying the smoothing to the little knob's value unless the input is connected.
To make all this work, we need to handle four events - knob value changes for the big and little knobs, and input connection and disconnection for the audio input. Now imagine you have several of these. The Notify method can get quite big if we have to write out dispatch for all the events we care about in longhand. By passing them through the notification receiver instead, we can simplify things quite a bit. The CvModulatableKnob doesn't have to know anything about the event handling and value smoothing - it just accepts DoubleSuppliers as inputs, and pulls values from them as it sees fit. The flag that controls whether the input is connected is hooked up like this:
- we ask the DisconnectableInput object to tell us when the notification receiver has told it that its connection status has changed.
The other thing to notice here is that CvModulatableKnob doesn't know anything at all about VoltageKnob and VoltageAudioJack components, which is just as well because my IDE doesn't know anything about them either. By binding VoltageAudioJack::GetValue as a method reference, I can pass the ability to read a value from an audio jack into code, and a code editing environment, that doesn't have access to the Voltage Modular core libraries. That code is then effectively decoupled from the UI - all it knows is that it has a DoubleSupplier that will give it a double value when it asks for one, and that DoubleSupplier may very well (as here) be supplying a smoothed value rather than the literal value of a UI knob.
So, I wrote this thing:
Code: Select all
public class NotificationReceiver {
private final Map<Object, DoubleConsumer> valueObservers = new IdentityHashMap<>();
private final Map<Object, BooleanConsumer> statusObservers = new IdentityHashMap<>();
public NotificationReceiver register(Object component, DoubleConsumer valueObserver) {
valueObservers.put(component, valueObserver);
return this;
}
public NotificationReceiver register(Object component, BooleanConsumer valueObserver) {
statusObservers.put(component, valueObserver);
return this;
}
private boolean newDoubleValue(Object component, double newValue) {
var observer = valueObservers.get(component);
if (observer != null) {
observer.accept(newValue);
return true;
} else {
return false;
}
}
public boolean knobValueChanged(Object component, double newValue) {
return newDoubleValue(component, newValue);
}
public boolean jackConnected(Object component) {
var observer = statusObservers.get(component);
if (observer != null) {
observer.accept(true);
return true;
} else {
return false;
}
}
public boolean jackDisconnected(Object component) {
var observer = statusObservers.get(component);
if (observer != null) {
observer.accept(false);
return true;
} else {
return false;
}
}
}
Code: Select all
case Knob_Changed: return receiver.knobValueChanged(component, doubleValue);
case Jack_Connected: return receiver.jackConnected(component);
case Jack_Disconnected: receiver.jackDisconnected(component);
// etc
Code: Select all
receiver.register(frequencyKnob, myController::setFrequencyValue)
.register(fmAmountKnob, myController::setFmAmountValue);
Code: Select all
case Knob_Changed: {
if (component == frequencyKnob) {
myController.setFrequencyValue(doubleValue);
return true;
}
if (component == fmAmountKnob) {
myController.setFmAmountValue(doubleValue);
return true;
}
}
break;
// etc
A bit further downstream of this, you can start defining components representing common collections of UI controls and their interactions, and writing custom registration methods to do all the wiring for them. Here's an example:
Code: Select all
CvModulatableKnob oddEvenBalance = new CvModulatableKnob(
0.0, 1.0,
receiver.registerInput(oddEvenBalanceCv, oddEvenBalanceCv::GetValue),
receiver.registerSmoothedKnob(oddEvenBalanceKnob, oddEvenBalanceKnob.GetValue()),
receiver.registerSmoothedKnob(oddEvenBalanceMod, 0.0));
To make it efficient, we just want to be providing the value of the big knob if the audio input isn't connected. Once the audio input is connected, we want to start pulling values from it, and multiplying them together with the value of the little knob and the value of the big knob to get the actual value of the control - that's a lot more work for the CPU, so we only switch it on when we see that the input's been wired up. We also smooth the two knobs' values, although again there's no point in applying the smoothing to the little knob's value unless the input is connected.
To make all this work, we need to handle four events - knob value changes for the big and little knobs, and input connection and disconnection for the audio input. Now imagine you have several of these. The Notify method can get quite big if we have to write out dispatch for all the events we care about in longhand. By passing them through the notification receiver instead, we can simplify things quite a bit. The CvModulatableKnob doesn't have to know anything about the event handling and value smoothing - it just accepts DoubleSuppliers as inputs, and pulls values from them as it sees fit. The flag that controls whether the input is connected is hooked up like this:
Code: Select all
public CvModulatableKnob(double lowerBound,
double upperBound,
DisconnectableInput cvValue,
DoubleSupplier knobValue,
DoubleSupplier modulationAmount) {
this.bottom = lowerBound;
this.top = upperBound;
this.cvValue = cvValue;
this.knobValue = knobValue;
this.modulationAmount = modulationAmount;
this.outputValue = knobValue;
cvValue.onConnectionStatusChanged(this::setCvIsConnected);
}
The other thing to notice here is that CvModulatableKnob doesn't know anything at all about VoltageKnob and VoltageAudioJack components, which is just as well because my IDE doesn't know anything about them either. By binding VoltageAudioJack::GetValue as a method reference, I can pass the ability to read a value from an audio jack into code, and a code editing environment, that doesn't have access to the Voltage Modular core libraries. That code is then effectively decoupled from the UI - all it knows is that it has a DoubleSupplier that will give it a double value when it asks for one, and that DoubleSupplier may very well (as here) be supplying a smoothed value rather than the literal value of a UI knob.