MAIN GOAL: Figure out issues with the param sources and handle installing onchange callbacks for midi control values
The problem I'm facing is that the way we're currently handling MIDI control input types is by storing a value and a callback in the param source type itself. The callback has the effect of both updating the state with new values when they are changed using the default onChange
handler used by constant types. I've already implemented the piece of juggling logic that handles un-registering and re-registering new callback handlers, dealing with state getting captured, etc.
The issue happens when we need to deserialize param sources on refresh. We need to dynamically install these callback handlers, but currently the only place where we actually have clean access to that onChange is in the render function for ConfigureParamSource
and that doesn't get rendered unless the UI is in view. I've thought up a potential messy solution for installing these automatically at the top level of the component, but I don't like it.
Most of these issues are coming up due to us not having clean access to the backend updater function. If we can get away with not storing the value or the callback in the MIDI control state, instead holding these values externally in the MIDI value cache or somewhere like that, it would solve a lot of complexity.
We need to do two things with those values: use them to update the backend MIDI engine when changed, and update the UI dynamically as well. We can probably handle the backend updating part of things pretty easily but using the built-in onChange
handler like we do, and perhaps modifying it to accept a second argument that is a flag to updateBackendOnly
or something which won't actually cause the state to be changed.
One of my initial thoughts on how to solve this was to make param sources something that doesn't become referentially unequal when changed; make it a class that wraps the inner state. Then, we can assign static callbacks to the value cache. However, the more I think about that the less appealing it sounds; we'd have to deal with react at some point anyway.
If we lift the MIDI value cache, or at least its inner value mapping, into state that triggers re-renders when updated, how would that look? That's looking a lot like separating out the param source's actual value from its state, if that makes any sense. In that way, we'd be making ParamSource
into a descriptor for the value rather than the value itself, which honestly matches pretty well to its real functionality. ParamSource
then becomes a descriptor on how to get a value rather than what the value is, yeah.
That still doesn't solve the main problem, though. How can we get backend state to update when MIDI control values change, and how can we get UI to update with that value as well? Well, I really do think we can handle backend value updating with the modified onChange
callback idea combined with wrapping it a watcher in the UI component that will handle registering/de-registering those callbacks. Ugh no, that causes us to have the deserialization problem again.
WAIT - what if we stop trying to solve this whole thing at the UI level, and instead solve this at the [fm-synth] level? Rather than having the MIDI control values changing update UI state, what if we created a dedicated listener that looks at all generic MIDI control events, passes them to the [fm-synth], and then [fm-synth] decides what to do with them as it sees them according to its current, live, up-to-date view of subscriptions? That sounds a lot like our midi value cache/callback registry thing that we have; the key difference is where the callback is located. Rather than being the callback which is provided to the UI component, it's a static callback that talks directly to the backend. That's the main hurdle here, isn't it? We need static access to the callback rather than have the callback only be available from the UI component.
Perhaps we can store, like, callback descriptors in the cache as well which are a mapping between MIDI control index and the "port" that it points to. That port can be something like {type: 'modulationIndex', srcOperatorIx: 3, dstOperatorIx: 0}
or {type: 'filterCutoff'}
. We don't store functions in the callback registry at all; we just store these descriptors, and then have the cache/callback registry statically connect with all of the [fm-synth]'s components and handle changing values as needed. We can still use the managed state re-rendering idea if we need to.
I think this will work, but I think we'll also need to go even further with this than we currently are. Rather than passing an onChange
function that's lovingly crafted to each of the param source UIs, we'll instead pass a sort of key that corresponds to what exactly it is controlling. There will be a single blessed onChange
function for all param sources, and it will be static and call into the global callback registry for the whole synth. We do onChange(key, value)
and then handle everything in the global registry for updating the backend, persisting the last value, and somehow re-rendering the UI with that value as well.
OK, so let's go over what this would look like. We enhande the MIDI value cache to be a global value store, statically constructed with the [fm-synth] and attached to all of its components statically as well. We expose an onChange
function which takes a key and a ParamSource
as arguments. When that is called, we handle updating the backend based off the key.
I took a shower and figured out how to handle this. Instead of trying to connect up virtual plugs and manage everything super dynamically with complicated callback registrations etc., we connect the MIDI controller directly to the [fm-synth] and bypass the UI entirely. We create a slab of memory in the [fm-synth] that holds the most recent value for each of the MIDI control indices. Then, we just expose an extremely simple method on the FM synth's Wasm that updates that memory every time a MIDI control value change is detected. We make the MIDI control value a first-class ParamSource
type, and we have it read values out of that array and apply scale + shift on them. It solves everything - there is no need to do dynamic registration, no need to shuffle callbacks around, no need to scan state, no need to dynamically look up change handlers based on some arbitrary state key, etc. It's as simple as it gets and it's really nice.
We will have to think up some way to handle external things like the filter, but honestly that's not even a param source so we can just ignore that. It can be handled by adding CSNs for each of the control indexes and connecting them; we can just not support it for now.
For UI, we can a map holding the last seen values for all of the control indices and hold it in the FM synth UI's state and re-render whenever one changes. OOH I just thought of this and it's better yet: we subscribe at the level of the component that renders the value, adding a callback to the value cache and avoiding the need to re-render the whole thing any time it changes. We have component-level state for the value and update it whenever that control index changes. That is as good as it gets. We then serialize that last-values map with the rest of the FM synth state and boom everything just works and in a very efficient way. I'm very happy with this solution.