Universal MLPRegressor for preset interpolation

hey, after attending a FluCoMa workshop at Standford in 2023, i have spent some time writing a universal Prototype for the MLPRegressor, so you could plugin your abitrary SynthDef and navigate a 2D space for preset interpolation.
The Prototype is based on @elgiano awesome ProtoDef class. You additionally need the awesome NodeProxyGui2. The implementation works for single and array NamedControls and additionally has the possibility to use a MIDI Controller or an LFO to navigate the 2D space.

Currently the additional LFO to navigate the 2D space needs an additional Bus on top of the already existing Buffer which is necessary for the MLPRegressor and is a bit unfortunate imo.
Additional things i would like to implement and im not really sure how to do that are:

  • instead of placing individual points on the 2D grid i would like to use a bunch of Nodeproxy presets and organize them on a grid with FluidGrid
  • im not sure if the current LFO is the best way to navigate the 2D space. There are additional ways for surface scanning which could be interesting and i havent implemented them yet. But maybe instead of using an LFO it would be possible to create a language side attempt with different functions via SkipJack to get rid of the additional Bus. Or maybe rethink the implementation of the MLPRegressor and its needs for Buffers, couldnt the MLPregressor be implemented with Busses instead?
  • currently every single param inside ~startMLP needs its own Changed Ugen (e.g. the more params you have the more ugens you need). I was wondering if FluidBufToKr is the best option here.

After spending an unimaginable amount of hours on this, I really hope this could be a community effort :slight_smile:

Here is the Prototype:

(
ProtoDef(\makeMLP) { |synthDefName, initArgs=#[], excludeParams=#[], ignoreParams=#[], numChannels = 2|

	~init = { |self|

		self.getNodeProxy;

		self.params = IdentityDictionary.new();
		self.paramValsUni = IdentityDictionary.new();

		self.dsXY = FluidDataSet(s);
		self.dsParams = FluidDataSet(s);

		self.bufXY = Buffer.alloc(s, 2);
		self.bufParams = Buffer.alloc(s, self.getNumParamVals);

		self.busXY = Bus.control(s, 2);

		self.mlp = FluidMLPRegressor(
			server: s,
			hiddenLayers: [7],
			activation: FluidMLPRegressor.sigmoid,
			outputActivation: FluidMLPRegressor.sigmoid,
			maxIter: 1000,
			learnRate: 0.1,
			batchSize: 1,
			validation: 0,
		);

		self.window = Window.new(self.synthDefName, Rect(10, 50, 440, 990)).front;
		self.window.layout = VLayout.new();

		self.initFont;
		self.window.view.children.do{ |c| c.font = self.font };

		self.setUpDependencies;
		self.makeParamSection;

	};

	~initFont = { |self|
		var fontSize = 14;
		self.font = Font.monospace(fontSize, bold: false, italic: false);
	};

	~getNodeProxy = { |self|

		self.synthDef = try{ SynthDescLib.global[self.synthDefName].def } ?? {
			Error("SynthDef '%' not found".format(self.synthDefName)).throw
		};

		self.nodeProxy = NodeProxy.audio(s, self.numChannels);
		self.nodeProxy.prime(self.synthDef).set(*self.initArgs);

	};

	~nodeProxyGui = { |self|
		NodeProxyGui2(self.nodeProxy, show: false)
		.excludeParams_(self.excludeParams)
		.ignoreParams_(self.ignoreParams)
	};

	~getNumParamVals = { |self|

		var paramVals;

		paramVals = self.nodeProxy
		.getKeysValues(except: self.excludeParams ++ self.ignoreParams)
		.flop[1];

		paramVals.collect{ |val, i|

			case
			{ val.isNumber } { 1 }
			{ val.isArray} { val.size };

		}.sum;

	};

	~setUpDependencies = { |self|

		var dataChangedFunc = { |obj ...args| self.dataChanged(*args) };

		self.addDependant(dataChangedFunc);

		self.window.onClose_{

			self.stopLFO;

			self.nodeProxy.stop;
			self.nodeProxy.free;

			self.unmapMidi;

			self.removeDependant(dataChangedFunc);
			self.bufXY.free;
			self.bufParams.free;

			self.busXY.free;

			self.params.clear;
			self.paramValsUni.clear;

			self.stopMLP;
			self.mlp.clear;

			self.dsXY.clear;
			self.dsParams.clear;

		};

	};

	// notify change
	~setData = { |self, values|

		self.changed(\slider, *values);
		self.changed(\buffer, *values);

	};

	// dataChanged is called when values change
	~dataChanged = { |self, what ...args|

		case
		{ what == \slider } {

			self.bufXY.setn(0, args);

		}
		{ what == \buffer } {

			self.xySlider.setXY(*args);

		}
		{ what == \controller } {

			var updateSliderFunc, index, value;

			index = args[0];
			value = args[1];

			self.bufXY.set(index, value);

			updateSliderFunc = {
				switch(index,
					0, { self.xySlider.x_(value) },
					1, { self.xySlider.y_(value) }
				);
			};

			{ updateSliderFunc.() }.defer;

		};

	};

	~startLFO = { |self|

		var limitUpdateRate = 0.01;

		self.stopLFO;

		self.lfoSynth = {
			var reset = \reset.tr(0);
			var rate = \rate.kr(0.1) * (1 - \freeze.kr(0));
			var phase = Phasor.kr(reset, rate * ControlDur.ir);
			var sig = SinOsc.kr(DC.kr(0), phase + [0.0, 0.25] * 2pi);
			sig = (sig * \radius.kr(1)).linlin(-1, 1, 0, 1);
			BufWr.kr(sig[0], self.bufXY, 0);
			BufWr.kr(sig[1], self.bufXY, 1);
			sig;
		}.play(outbus: self.busXY);

		SkipJack.new(
			updateFunc: { self.changed(\buffer, *self.busXY.getnSynchronous(2)) },
			dt: limitUpdateRate,
			stopTest: { self.window.isClosed or: self.lfoSynth.isNil },
			clock: AppClock
		);

	};

	~stopLFO = { |self|
		self.lfoSynth !? { self.lfoSynth.free; self.lfoSynth = nil; };
	};

	~resetLFO = { |self|
		self.lfoSynth !? { self.lfoSynth.set(\reset, 1); };
	};

	~pauseLFO = { |self|
		self.lfoSynth !? { self.lfoSynth.set(\freeze, 1); };
	};

	~resumeLFO = { |self|
		self.lfoSynth !? { self.lfoSynth.set(\freeze, 0); };
	};

	~trainMLP = { |self, train|

		self.trainTask = self.trainTask ?? {

			Task {

				loop {

					var condition = CondVar.new;
					var done = false;

					self.mlp.fit(self.dsXY, self.dsParams) { |loss|
						done = true;
						condition.signalOne;
						"> Training done. Loss: %".format(loss).postln;
					};

					condition.wait { done };

					0.1.wait;
				};

			};

		};

		if (train) {
			self.trainTask.play;
		} {
			self.trainTask.pause;
		};

	};

	~startMLP = { |self|

		self.stopMLP;

		self.mlpSynth = {

			var xyData, xyTrig, paramValUni, paramTrig;

			// when data in xy buffer has changed, ...
			xyData = FluidBufToKr.kr(self.bufXY);
			xyTrig = Changed.kr(xyData.round(0.01)).sum;

			// ... trigger MLP ...
			self.mlp.kr(xyTrig, self.bufXY, self.bufParams);

			// ... to get associated param value from parameter buffer ...
			paramValUni = FluidBufToKr.kr(self.bufParams);
			paramTrig = Changed.kr(paramValUni.round(0.01)).sum;

			// ... and send OSC message to update NodeProxy!
			SendReply.kr(paramTrig, "/paramsChanged", paramValUni);

			Silent.ar;
		}.play;

		self.responder = OSCFunc({ |msg|
			var paramValsUniOSC = msg.drop(3);
			self.setParamVals(paramValsUniOSC);
		}, "/paramsChanged", argTemplate:[self.mlpSynth.nodeID]);

	};

	~stopMLP = { |self|

		self.mlpSynth !? { self.mlpSynth.free; self.mlpSynth = nil };
		self.responder !? { self.responder.free; self.responder = nil };

	};

	~makeParamSection = { |self|

		self.params.clear;

		self.nodeProxy
		.controlKeysValues(except: self.nodeProxy.internalKeys ++ self.excludeParams ++ self.ignoreParams)
		.pairsDo{ |key, val|

			var spec;

			spec = case

			{ val.isNumber } {
				(self.nodeProxy.specs.at(key) ?? { Spec.specs.at(key) }).asSpec;
			}
			{ val.isArray } {
				(self.nodeProxy.specs.at(key) ?? { Spec.specs.at(key) }).asSpec.dup(val.size);
			};

			self.params.put(key, spec);
		};

		if(self.paramLayout.notNil) { self.paramLayout.remove };
		self.paramLayout = self.makeParamLayout;
		self.window.layout.add(self.paramLayout);
		self.window.layout.add(self.nodeProxyGui);

	};

	~getParamValsUni = { |self|

		self.paramValsUni.clear;

		self.params.sortedKeysValuesDo{ |key, spec|

			var paramVal;

			paramVal = self.nodeProxy.get(key);

			case

			{ paramVal.isNumber } {
				var paramValUni;
				paramValUni = spec.unmap(paramVal);
				self.paramValsUni.put(key, paramValUni);
			}
			{ paramVal.isArray } {
				var paramValsUni;
				paramValsUni = paramVal.collect{ |val, n|
					spec.wrapAt(n).unmap(val);
				};
				self.paramValsUni.put(key, paramValsUni);
			};

		};

		self.paramValsUni;

	};

	~addPoint = { |self, id|

		var paramValsUniSerialised;

		self.getParamValsUni;

		paramValsUniSerialised = self.paramValsUni.asSortedArray.flop[1].flat;

		self.bufParams.loadCollection(paramValsUniSerialised, 0) {
			self.dsParams.addPoint(id, self.bufParams);
		};

		self.dsXY.addPoint(id, self.bufXY);

	};

	~setParamVals = { |self, paramValsUniOSC|

		var paramValsUni;

		paramValsUni = paramValsUniOSC.reshapeLike(self.paramValsUni.asSortedArray.flop[1]);

		self.params.sortedKeysValuesDo{ |key, spec, i|

			var paramVal;

			paramVal = self.nodeProxy.get(key);

			case

			{ paramVal.isNumber } {
				var value = spec.map(paramValsUni[i]);
				self.nodeProxy.set(key, value);
			}
			{ paramVal.isArray } {
				paramVal.collect{ |val, n|
					var value = spec.wrapAt(n).map(paramValsUni[i].wrapAt(n));
					self.nodeProxy.seti(key, n, value);
				};
			};

		};

	};

	~makeParamLayout = { |self|

		var view, layout, buttons;
		var graphView, pointView, plotDataSetXY;
		var counter = 0;

		view = View.new().layout_(VLayout.new());

		plotDataSetXY = {
			self.dsXY.dump { |v|
				if (v["cols"] == 0) {
					v = Dictionary["cols" -> 2, "data" -> Dictionary[]]
				};
				pointView.dict = v
			};
		};

		pointView = FluidPlotter(standalone: false).pointSizeScale_(20/6);
		plotDataSetXY.();

		self.xySlider = Slider2D()
		.background_(Color.white.alpha_(0))
		.action_{ |obj|
			self.changed(\slider, obj.x, obj.y);
		};

		graphView = StackLayout(self.xySlider, View.new().layout_(
			VLayout(pointView).margins_(10)
		)).mode_(\stackAll);

		buttons = VLayout(

			Button(bounds: 100@20).states_([["Add Point"]])
			.action_{
				var id = "point-%".format(counter);
				self.addPoint(id);
				counter = counter + 1;
				plotDataSetXY.();
			},

			Button(bounds: 100@20).states_([["Save Data"]])
			.action_{
				FileDialog({ |folder|
					self.dsXY.write(folder +/+ "xydata.json");
					self.dsParams.write(folder +/+ "paramsdata.json");
				}, {}, 2, 0, true);
			},

			Button(bounds: 100@20).states_([["Load Data"]])
			.action_{
				FileDialog({ |folder|
					self.dsXY.read(folder +/+ "xydata.json");
					self.dsParams.read(folder +/+ "paramsdata.json");
				}, fileMode: 2, acceptMode: 0, stripResult: true);
				plotDataSetXY.();
			},

			Button(bounds: 100@20).states_([
				["Train"], ["Train", Color.white, Color.yellow(0.5)]])
			.action_{ |obj|
				self.trainMLP(obj.value > 0)
			},

			Button(bounds: 100@20).states_([["Save MLP"]])
			.action_{
				Dialog.savePanel({ |path|
					if(PathName(path).extension != "json"){
						path = "%.json".format(path);
					};
					self.mlp.write(path);
				});
			},

			Button(bounds: 100@20).states_([["Load MLP"]])
			.action_{
				Dialog.openPanel({ |path|
					self.mlp.read(path, action: {
						{ plotDataSetXY.() }.defer
					});
				});
			},

			Button(bounds: 100@20).states_([["Start MLP"],
				["Stop MLP", Color.white, Color.red(0.8)]])
			.action_{ |obj|
				if (obj.value > 0) { self.startMLP } { self.stopMLP };
			}.value_(self.mlpSynth.notNil),

			Button(bounds: 100@20).states_([["Clear"]])
			.action_{|state|
				self.mlp.clear;
				self.dsXY.clear;
				self.dsParams.clear;
				plotDataSetXY.();
			},

			Button(bounds: 100@20).states_([["Start LFO"],
				["Stop LFO", Color.white, Color.red(0.8)]])
			.action_{ |obj|
				if (obj.value > 0) { self.startLFO } { self.stopLFO };
			}.value_(self.lfoSynth.notNil)

		);

		layout = HLayout([graphView, s: 1], buttons);

		view.layout.add(layout);

		view.children.do{ |c| c.font = self.font };

		view;
	};

	~mapMidi = { |self, ccs|

		self.midiResponders = self.midiResponders ?? { Order[] };
		self.unmapMidi;

		ccs.do { |cc, n|
			self.midiResponders[cc] = MIDIFunc.cc({ |v, c|
				var val = v / 127;
				self.changed(\controller, n, val);
			}, cc).fix
		};

	};

	~unmapMidi = { |self|
		self.midiResponders.do(_.free);
	};

};
)

create a test SynthDef and use the Prototype for preset interpolation:

(
SynthDef(\test, {

	var tFreq, trig, env, freqRange, freq, fmod, sig;

	tFreq = \tFreq.kr(4, spec: ControlSpec(1, 12));
	trig = Impulse.ar(tFreq);
	env = Env.perc(\atk.kr(0.1, spec: ControlSpec(0.005, 1)), \rel.kr(1, spec: ControlSpec(0.05, 1))).ar(0, trig);

	//freqRange = \freqRange.kr([440, 440], spec: ControlSpec(100, 8000));
	//freq = exprand(freqRange[0], freqRange[1]);
	freq = \freq.kr(440, spec: ControlSpec(20, 1000));

	fmod = SinOsc.ar(\fmFreq.kr(3, spec: ControlSpec(1, 100)));

	sig = SinOsc.ar(freq + (freq * fmod * \fmIndex.kr(2, spec: ControlSpec(0, 5))));

	sig = sig * env;

	sig = Pan2.ar(sig, \pan.kr(0, spec: ControlSpec(-1, 1)));

	sig = sig * \amp.kr(-25, spec: ControlSpec(-35, -15, \lin, 1)).dbamp;

	Out.ar(\out.kr(0), sig);
}).add;
)


(
~testMLP = Prototype(\makeMLP) {

	~synthDefName = \test;

	~initArgs = [

	];

	~excludeParams = [

	];

	~ignoreParams = [

		\amp,

	];

};
)


~testMLP.startLFO;
~testMLP.resetLFO;
~testMLP.pauseLFO;
~testMLP.resumeLFO;

~testMLP.lfoSynth.set(\radius, 0.8);
~testMLP.lfoSynth.set(\rate, 0.03);

~testMLP.stopLFO;

~testMLP.setData([0.5, 0.5]);
~testMLP.bufXY.getn(0, 2, { |a| a.postln });

~testMLP.mapMidi([0, 1]);
~testMLP.unmapMidi;
5 Likes

It would mean a world to me if we could advance this attempt with all the ideas i have been sharing. This approach is working fine for basic usage, but i hope you have some ideas how we could advance its nature to be absolutely universal. You wont believe how many hours i have spent on this, it really means something to me :slight_smile:

1 Like

This sounds like something I might use and be interested in helping work on

(I don’t currently use flucoma much because the “barrier to entry” always seems daunting, like I have to learn how it works every time and it never has become intuitive. But I am always impressed by it when I do manage to wrangle it, and am very interested in lowering the barrier for this specific preset space case in particular)

Your use case sounds not quite aligned with how I would personally want to use something like this. (For example, I don’t currently use nodeproxies, I am working on a custom preset system that operates over a whole network of separate synths/nodes sort of like a proxy space)

An easy way to make this more universal would be to make it into a class instead of a ProtoDef so it could be distributed in the standard way as a quark or extension and not require ProtoDef to be installed.

And I think standard best practice would be to separate GUI code from the functionality so that it could e.g. be incorporated into a larger gui system or the gui could be omitted altogether

Maybe more challenging is to find ways to make the functionality more general while still being easy to do the things you describe. e.g. decouple it from the nodeproxy ecosystem so it could play with arbitrary preset collections, and then write some glue to interface with node proxies. Also as you suggest I don’t see why an LFO needs to be built-in on a specific circular path like you have, seems much more powerful to either let the user make their own LFO, or even to draw a path through the 2d space (I remember we talked about something like this here on the forum a while ago…)

If these kinds of ideas are things you would be open to I would be excited about helping work on it (although my time is quite limited for the next few months so can’t promise immediate action)

hey, thanks for your interest :slight_smile:

I think this could be a last step when the main functionality is working as expected.

Yes, thats totally possible. In my opinion NodeProxyGui2 is nice here because its creating a gui for your arbitrary SynthDef and its ignore / exclude methods nicely fit into the mlp attempt and additionally you can use random / vary to create presets.

The circular LFO is just a simple example. This could be adjusted to have more degrees of freedom with some additional functionality or could be excluded and the Environment just sets up the Bus and the SkipJack. Im my opinion the LFO params should also be part of the GUI.
My main point here is that FluidMLPRegressor.kr expects Buffers as its inputs, so you end up with parallel data structures (Buffer and Bus) for this LFO implementation, which i find really unfortunate.

I have to admit im always thinking in terms of signals (e.g. LFOs) and not so much in terms of language side attempts. But in this specific case a language side attempt would let us get rid of the additional Bus.

I also think it would be nice if there could be a way to restructure ~makeMLP, so that more params are not leading to more Ugens (dont know if thats possible) and pass a bunch of param-presets and create the points in the xy-space on a grid automatically instead of placing them manually with ~addPoint (maybe with FluidGrid). But i dont know how to approach that.

For the rest I will have to wait until in front of SC :), but re: when to convert to classes here is my argument for sooner rather than later

In my experience having used other forms of object prototyping, the bigger the prototype gets the more annoying it is to convert to a class (remove all references to self, explicitly return with ^, figure out *new vs -init, sometimes you need to use *initClass, blah blah) and the more likely it is to not work as expected. Also when multiple classes interact with each other, for simple example if we have the main class MLP2D (uninspired name) and then a GUI class MLP2DWindow where

+MLP2D {
  gui { ^MLP2DWindow(this) }
}

I imagine this would be annoying to figure out with ProtoDef and then reimplement with classes?

Or is there a straightforward and automatic way to do all this with ProtoDef?

(I agree it is also annoying to recompile the class library every time you make a change, but to me it is pretty fast and less annoying than doing the same work twice… And so much is idiosyncratic about both classes and object prototyping in SC that they often don’t translate well in my experience)

I have to study the helpfile in more detail. At least I know that you can seperate processing logic from gui by extending the ProtoDef definitions over multiple files.

Would like to hear your additional thoughts :slight_smile:
Quick update: Im currently working on a Klein-Bottle 2D surface scanner based on a Task / Skipjack as a potential blueprint for a language side navigation of the 2D space to get rid of the additional Bus which is needed for the LFO. But for my other questions we possibly need a bit of help from some FluCoMa experts.

Just got to play very briefly with this, and it works great! (I will be away from the computer entirely for the next 3 weeks…)

Yes seems like there’s a lot of inefficiency going on here, turning a buffer into a kr signal just to see if it’s changed (scztt previously suggested there is an easy way to make a BufChanged ugen, but nobody’s done it yet that I know of New class VisualBuffer - #3 by scztt )

then turning the result buffer into a kr in order to detect if any parameters have changed in order to send the reply back via OSC… I wonder if for starters it’s a reasonable assumption that if the XY position has changed, the parameters will also have changed?

…

Now, I would suggest to completely separate the interface to the regressor from the destination SynthDef / Ndef / nodeproxy presets / etc., the midi mapping, the LFO, and the GUI.

Then perhaps we wind up with (each of these would be a separate class)

  • a robust MLP object that handles training and manages a Synth which maps from some inputs to some outputs. (keeping this closer in spirit to FluidMLPRegressor but more sc-idiomatic and user friendly, e.g. conveniences for training and arranging presets in space, accepting both set messages for setting inputs and bus mapping to connect arbitrary LFOs, providing both language-side output via OSC as you have as well as write to buses for direct server-side mapping – would be super cool if it could run at audio rate?! – we could work on brainstorming a full spec while I’m away)
  • a generic GUI for this object, which could e.g. use a 2Dslider for 2D input and a multislider for higher dimensional input
  • an interface to SynthDef / nodeproxy which would be straightforward to plug into this object
  • a gui for this interface (already done – NodeProxyGui2 :slight_smile: )
  • either classes or simple templates for midi and LFO mapping

so all together this could plug together to create exactly what you have already shared, but opens up much more flexibility for different applications as well…

what do you think?