How to build a fully decoupled GUI

There a many questions regarding building GUIs and decoupling in the forum and I have spent a good deal of time wrapping my head around the concept myself. Here is my take on a simple GUI, I guess the example I would have liked to find myself when starting out. There are many other great examples with a slightly different approach on the forum.

The code displays:

  • Data organization in events
  • Building automated GUI elements with layouts
  • mapping and unmapping GUI elements
  • connecting GUI elements (otherwise not very useful)

The code is modular, so new synths and corresponding GUI elements can easily be added (see the end of the code example);

The GUI has these virtues:

  • The gui will always reflect the state of the synths
  • The state of the synths can be changed from code or one or several GUI elements
  • The gui can be closed and re-opened at any time
  • After command + period, the GUI can still be used and will update values in the event, when synths are re-initiated, values will be sent to the server

The basic idea, one which I feel is very much the ‘SC way’, is to keep everything organized in events. There are many advantages of using events other than the ones displayed here. The main point here is that it is easier to read p[\filter][\vals][\amp] than p[2][1][5].

Synth are updated through the ~setPar function, which updates the event with the new value, sends the new value to the synth and sends a changed message, which is either ignored (if no GUI element is listening) or cause one or more GUI updates. Even if no GUI is used, this approach has its virtues as you never have to query a synth for its current value, all the bookkeeping is done in the language. When a GUI is opened a ‘mirror’ event is created in ‘g’, so that, ie

p[\source][\vals][\amp] corresponds to the knob in g[\source][\vals][\amp] and is mapped and unmapped with the array in p[\source][\maps][\amp]. The synth in p[\source][\syn] is updated through the ~setPar function.

There are several ways to add and remove dependencies. Here I simply add all dependencies using SimpleController to one big list and remove all of them, when the view is closed.

I hope this example is useful. Feel free to comment and ask. The sounds and effect are very basic and just for demonstration.

(
s.waitForBoot{
	SynthDef(\soundSource, {
		var rate = \rate.kr(2);
		var trig = Impulse.ar(rate);
		var midinote = Demand.ar(trig, 0, Drand([0, 2, 3, 5, 7, 9, 10] + \root.kr(50), inf));
		var env = Env.perc(\atk.kr(0.1), \rel.kr(1)).ar(0, trig);
		var sig = LFSaw.ar(midinote.midicps) * 0.1;
		Out.ar(\out.kr(0), sig!2 * env * \amp.kr(1))
	}).add;
	
	SynthDef(\delay, {
		var in = In.ar(\bus.kr(0), 2);
		var delay = CombC.ar(in, 0.5, \time.kr(0.3)) * \amt.kr(1);
		ReplaceOut.ar(\bus.kr(0), in + delay * \amp.kr(1))
	}).add;
	
	SynthDef(\filter, {
		var in = In.ar(\bus.kr(0), 2);
		var filter = LPF.ar(in, \freq.kr(440).lag(0.2));
		ReplaceOut.ar(\bus.kr(0), XFade2.ar(in, filter, \on.kr(1) * 2 - 1, \amp.kr(1)))
	}).add;
	s.sync;
	
	p = (); // holds data for each synth and later the synth itself after the init funcion has been invoked
	p.add(\source -> (
		init: { Synth(\soundSource, [ ], ~group[0]) },
		seq: [\rate, \atk, \rel, \root, \amp],
		vals: (rate: 6, atk: 0.2, rel: 0.4, root: 50, amp: 0.5),
		maps: (rate: [1, 10], amp: [0, 1], atk: [0.005, 1], rel: [0.05, 1], root: [40, 64, \lin, 1]),
	));
	
	p.add(\delay -> (
		init: { Synth(\delay, [ ], ~group[1]) },
		seq: [\time, \amt, \amp],
		vals: (time: 0.2, amt: 0.5, amp: 1),
		maps: (time: [0.001, 0.5], amt: [0, 1], amp: [0, 2]),
	));
	
	p.add(\filter -> (
		init: { Synth(\filter, [ ], ~group[2]) },
		seq: [\freq, \on, \amp],
		vals: (freq: 500, on: 1, amp: 1),
		maps: (freq: [50, 5000, \exp], on: [0, 1, \lin, 1], amp: [0, 2]),
	));
	
	~setPar = {| val = 0, name = \source, par = \amp| // set synths through this function
		p[name][\vals][par] = val;
		if (p[name][\syn].notNil) { p[name][\syn].set(par, val) };
		p[name][\vals].changed(par);
	};
	
	// BUILD && CONNECT GUI
	~seq = [\source, \delay, \filter ]; // display order of units
	g = (); // for the subviews of the main view, will mirror values in p
	~buildUnit = {|name|
		var vals, view;
		vals = p[name][\seq].collectAs({|par| par -> Knob() }, Event);
		view = View().layout_(VLayout(
			StaticText().string_(name.toUpper).font_(Font(\Arial, 16, true)),
			HLayout(*p[name][\seq].collect{|n| StaticText().string_(n.toUpper).align_(\center)} ),
			HLayout(*p[name][\seq].collect{|n| vals[n] })
		)).background_(Color.rand);
		g.add(name -> (view: view, vals: vals))
	};
	
	~connectUnit = {|name, sc|
		var e = p[name];
		var v = g[name];
		var update = { |par, v| { v[\vals][par].value = e[\maps][par].asSpec.unmap(e[\vals][par]) }.defer };
		e[\seq].do{ |par|
			v[\vals][par].action = {|view|
				var val = e[\maps][par].asSpec.map(view.());
				~setPar.(val, name, par)
			};
			sc.add(SimpleController(e[\vals]).put(par, update.(par, v)));
			e[\vals].changed(par);
		};
	};
	
	~buildAndConnect = {|seq|
		var sc, v;
		~seq.do{|name| ~buildUnit.(name) };
		sc = List.new;
		v = View().layout_(HLayout(*seq.collect{|name| g[name][\view]}))
		.front.alwaysOnTop_(true).onClose_{ sc.do{|n| n.remove } }; // clear dependencies when view is closed;
		seq.do{ |name| ~connectUnit.(name, sc) };
		v
	};
	v = ~buildAndConnect.(~seq);
	// INIT
	~initSynths = {
		s.waitForBoot{ 
			s.freeAll;
			s.sync;
			~group = p.keys.size.collect{ Group.new }.reverse; // reverse so ~group[0] is first, ~group[2] is last
			~seq.do{|name|
				p[name].add(\syn -> p[name].init.());
				p[name][\syn].onFree{ p[name][\syn] = nil };
				p[name][\seq].do{|par| ~setPar.(p[name][\vals][par], name, par) }
			}
		}
	};
	~initSynths.();
}
)

// Set values from code, GUI will follow if view is open //
~setPar.(2, \source, \rate);
~setPar.(0.05, \delay, \time);
~setPar.(0.2, \delay, \amt);
~setPar.(300, \filter, \freq);
// You can close the view at any time, and re-build it at any time, changes made in code will automatically update
~buildAndConnect.(~seq); 
// it will also work with two or more open at the same time, this feature is really helpful if you want to have certain values controlled/displayed by multiple GUI elements, not so useful in this example
// 

( // add a new tremolo unit and re-open GUI //
{	
	SynthDef(\tremolo, {
		var in = In.ar(\bus.kr(0), 2);
		var sig = in * ( SinOsc.ar(\rate.kr(2)) * \amt.kr(5) ).midiratio;
		ReplaceOut.ar(\bus.kr(0), sig * \amp.kr(1))
	}).add;	
	s.sync;	
	~group = ~group ++ [Group.after(~group.last)];
	p.add(\tremolo -> (
		init: { Synth(\tremolo, [ ], ~group[3]) },
		seq: [\rate, \amt, \amp],
		vals: (rate: 5, on: 1, amt: 1.3, amp: 1),
		maps: (rate: [0.1, 10], amt: [0, 20], amp: [0, 2]),
	));
	p[\tremolo].add(\syn -> p[\tremolo][\init].());
	~seq = ~seq ++ [\tremolo];
	if (v.isActive) { { v.close }.defer };
	0.2.wait;
	~buildAndConnect.(~seq);
}.fork(AppClock)
)

// if you hit command + period, adjust the GUI and re-init the synths, 
// the current GUI values will be send to the server
~initSynths.(); 
4 Likes

Hi

Thanks for sharing, looks great.
But, am hitting an error trying to run the code on MacOS 13.6, please see below

// START

ERROR: Message ‘toUpper’ not understood.
RECEIVER:
Symbol ‘source’
ARGS:
PATH: /Users/h_qi/Documents/MUSIQI - SOUNDS/SCWORK/tutorials_reference/2024 Thor_Madsen How to build a fully decoupled GUI.scd

PROTECTED CALL STACK:
Meta_MethodError:new 0x13068e2c0
arg this = DoesNotUnderstandError
arg what = nil
arg receiver = source
Meta_DoesNotUnderstandError:new 0x130690580
arg this = DoesNotUnderstandError
arg receiver = source
arg selector = toUpper
arg args = [ ]
Object:doesNotUnderstand 0x1181ba200
arg this = source
arg selector = toUpper
arg args = nil
a FunctionDef 0x138692788
sourceCode = “{|name|
var vals, view;
vals = p[name][\seq].collectAs({|par| par → Knob() }, Event);
view = View().layout_(VLayout(
StaticText().string_(name.toUpper).font_(Font(\Arial, 16, true)),
HLayout(*p[name][\seq].collect{|n| StaticText().string_(n.toUpper).align_(\center)} ),
HLayout(*p[name][\seq].collect{|n| vals[n] })
)).background_(Color.rand);
g.add(name → (view: view, vals: vals))
}”
arg name = source
var vals = ( ‘amp’: a Knob, ‘atk’: a Knob, ‘rel’: a Knob, ‘rate’: a Knob,
‘root’: a Knob )
var view = nil
ArrayedCollection:do 0x131847700
arg this = [ source, delay, filter ]
arg function = a Function
var i = 0
a FunctionDef 0x1084337b8
sourceCode = “{|seq|
var sc, v;
~seq.do{|name| ~buildUnit.(name) };
sc = List.new;
v = View().layout_(HLayout(*seq.collect{|name| g[name][\view]}))
.front.alwaysOnTop_(true).onClose_{ sc.do{|n| n.remove } }; // clear dependencies when view is closed;
seq.do{ |name| ~connectUnit.(name, sc) };
v
}”
arg seq = [ source, delay, filter ]
var sc = nil
var v = nil
a FunctionDef 0x1382a42d8
sourceCode = "{
SynthDef(\soundSource, {
var rate = \rate.kr(2);
var trig = Impulse.ar(rate);
var midinote = Demand.ar(trig, 0, Drand([0, 2, 3, 5, 7, 9, 10] + \root.kr(50), inf));
var env = Env.perc(\atk.kr(0.1), \rel.kr(1)).ar(0, trig);
var sig = LFSaw.ar(midinote.midicps) * 0.1;
Out.ar(\out.kr(0), sig!2 * env * \amp.kr(1))
}).add;

SynthDef(\\delay, {
	var in = In.ar(\\bus.kr(0), 2);
	var delay = CombC.ar(in, 0.5, \\time.kr(0.3)) * \\amt.kr(1);
	ReplaceOut.ar(\\bus.kr(0), in + d...etc..."
Routine:prStart	0x131234100
	arg this = a Routine
	arg inval = 53.350608209

CALL STACK:
DoesNotUnderstandError:reportError
arg this =
< closed FunctionDef >
arg error =
Integer:forBy
arg this = 0
arg endval = 0
arg stepval = 2
arg function =
var i = 0
var j = 0
SequenceableCollection:pairsDo
arg this = [*2]
arg function =
Scheduler:seconds_
arg this =
arg newSeconds = 58.650911834
Meta_AppClock:tick
arg this =
var saveClock =
Process:tick
arg this =
^^ ERROR: Message ‘toUpper’ not understood.
RECEIVER: source

// END

maybe the .toUpper method is from a quark, can’t remember. Can you try and delete the .toUpper in all cases and run the code again? ToUpper just capitalizes the text, so has no bearings on the functionality, just that text will be in lower cases.

EDIT:

I just checked, toUpper is a method of the Char class, so that shouldn’t be a problem, unless a quark is overriding it…

Thanks for the quick response.
That’s done the trick, all running now.
I do have a lot of quarks installed, so will let you know if I find the culprit!

Glad it worked (and some more characters)