GUI construction: Ringz tuner

This document explains the construction of a GUI to edit banks of parallel resonant filters, intended for creating a wide variety of electronic percussion sounds. One reader on sc-forum called it a "sample factory," which is right on--that's exactly why I wrote it, and what I use it for. Record the sounds produced by it, load them into a sound editor, extract the useful bits, and over time you can build a large library of custom drum sounds.

This is what the GUI looks like:

The multisliders let you set the parameters on each filter (ten maximum). You add a new filter by clicking the "new resonz" button. On the righthand side, you can define your own noise source (exciter) and envelope for the exciter.

This document will present the code piecemeal, in order to explain it in manageable chunks. The complete code, ready to run, is available in the "Download code examples" link.

The server must be booted first.

s.boot;

Server preparation

First, the preparation section. We create a couple of mixer channels, make a GUI for them, create the basic filter synthdef and a few other resources.

// preparation
(
m = MixerChannel(\perc, s, 1, 2, postSendReady:true, outbus:s.options.numOutputBusChannels);
n = MixerChannel(\ringz, s, 1, 2);

// set node order: filter channel must follow source channel
// see the MixerChannel helpfile
m.addAsDependantOf(n);

MixingBoard("drum tuner", nil, m, n);

SynthDef(\ringz, { |ffreq = 2500, decay = 0.01, in, out, amp = 0|
   Out.ar(out, Ringz.ar(In.ar(in, 1), ffreq, decay, amp))
}).send(s);

~nodes = Array.new(10);
~ffreqs = 20 ! 10;
~rqs = 1 ! 10;
~decays = ~dt.(~ffreqs, ~rqs);

// func to calc decay time from freq and rq
~log001 = log(0.001);   // constant, will be used repeatedly
~sr = s.sampleRate;
~dt = { |ffreq, rq|
   (~log001 / log(1 - (pi/~sr * ffreq * rq))) / ~sr;
};
~freqspec = \freq.asSpec;
~rqspec = [1, 0.05].asSpec;
~ampspec = \amp.asSpec;
) 

Creating GUI objects

I often use FlowViews when building a GUI, especially for quick and dirty solutions. A FlowView uses a FlowLayout to position the GUI objects, left to right and then down. This way, you don't have to specify the origin of every object, just its size (width and height). You give up some control in that free placement of objects isn't possible. However, FlowViews and other SCCompositeViews can be nested. So, you can place two FlowViews side-by-side to get a columnar layout, or put an SCCompositeView inside a FlowView so that you can do free placement inside the composite view, while using automatic placement elsewhere.

470@490 is a shortcut syntax to create a Point. When use a Point(x, y) as the bounds of a GUI object, it's the same as using Rect(0, 0, x, y). Inside a FlowView, the left/top origin (0, 0) will be populated with the next position provided by the FlowLayout.

Here, I use two FlowViews to create a left panel and a right panel.

// gui building
// left-hand side: filters
(
f = FlowView.new(nil, Rect(150, 5, 800, 500));
~leftview = FlowView(f, 470@490);
~rightview = FlowView(f, 280@490); 

Labels for the multisliders go across the top. Since they are truly static, I don't need to keep them in variables.

SCStaticText(~leftview, 150@20).string_("frequencies");
SCStaticText(~leftview, 150@20).string_("resonances");
SCStaticText(~leftview, 150@20).string_("amplitudes"); 

Since these multisliders are supposed to change synthesis parameters on the server, the server updates are written into an action function. The function receives the GUI object itself as an argument. The usual procedure is to take the GUI's value (which ranges from 0 to 1), use a ControlSpec to map it to the parameter, then send the value to the synth node. Here I have an extra step because of the conversion from rq to ring time. That conversion is done both when changing frequency and rq because the ring time depends on both parameters.

Even for a single-value slider, the action function will look similar, albeit simpler. This is the general technique to route a slider to a synth control. There are abstractions to do some of the work for you, such as NumberEditor, used below.

Note that you can set multiple properties in one statement by using setter syntax -- .property_(value) -- instead of assignment syntax. I usually format them this way for readability.

~ffreqview = SCMultiSliderView(~leftview, 150@200)
   .size_(10)
   .thumbSize_(14)
   .action_({ |v|
      ~ffreqs = ~freqspec.map(v.value);
      ~decays = ~dt.(~ffreqs, ~rqs);
      ~nodes.do({ |n, i|
         n.set(\ffreq, ~ffreqs[i], \decay, ~decays[i])
      })
   });
~rqview = SCMultiSliderView(~leftview, 150@200)
   .size_(10)
   .thumbSize_(14)
   .action_({ |v|
      ~rqs = ~rqspec.map(v.value);
      ~decays = ~dt.(~ffreqs, ~rqs);
      ~nodes.do({ |n, i|
         n.set(\decay, ~decays[i]);
      })
   });
~ampview = SCMultiSliderView(~leftview, 150@200)
   .size_(10)
   .thumbSize_(14)
   .action_({ |v| ~nodes.do({ |n, i| n.set(\amp, ~ampspec.map(v.value[i])) }) }); 

NumberEditor is a class in the crucial library that creates a linked slider and number box. It isn't a GUI object in and of itself, but it produces them very easily with the .gui method.

~rateEdit = NumberEditor(2, [0.5, 10]).action_({ |val| ~trigsynth.set(\trigrate, val) });

~leftview.startRow;   // startRow moves the FlowView to the next line
SCStaticText(~leftview, 100@20).string_("trigrate").align_(\right);
~rateEdit.gui(~leftview); 

Next we have a series of ActionButtons. ActionButton is another crucial library class that acts as a shortcut to create a single-action button.

~post is used as part of the action for two different buttons, so I define it as a separate function. Copying and pasting code into different places is usually bad code design.

~post = {
   (~nodes.size > 0).if({
      postf("\nKlank array:\n`[ %,\n   %,\n   % ]\n\n",
         ~ffreqs[..~nodes.size-1],
         ~ampspec.map(~ampview.value[..~nodes.size-1]),
         ~decays[..~nodes.size-1]);
      [~ffreqs, ~decays, ~ampspec.map(~ampview.value)]
         .flop[..~nodes.size-1].do({ |item|
            postf("[\\ffreq, %, \\decay, %, \\amp, %]\n", *item);
      });
   });
};

~leftview.startRow;
ActionButton(~leftview, "new resonz", { ~nodes.add(n.play(\ringz, [\in, m.inbus.index])) });
ActionButton(~leftview, "post parameters", ~post);
ActionButton(~leftview, "stop", {
   ~post.value;
   ~src.free;
   ~nodes.do(_.free);
   ~trigbus.free;
   "\n\nClose the MixingBoard window to remove the remaining synths.".postln;
});

SuperCollider's text archiving functionality is used to save the filter definitions. I collect the relevant objects into an array and write it to disk. Loading is a matter of retrieving the array, then putting the elements where they belong.

CocoaDialog offers open and save dialog boxes. When the box returns (either OK or cancel), the appropriate function is called. All the functionality of navigating, creating new folders, etc. is available! I want cancel to be ignored, so I give actions only for the OK buttons.

~leftview.startRow;
ActionButton(~leftview, "save", {
   CocoaDialog.savePanel({ |path|
      [~ffreqview.value, ~rqview.value, ~ampview.value,
         ~funcedit.string, ~envedit.string, ~nodes.size]
      .writeTextArchive(path)
   });
});

ActionButton(~leftview, "load", {
   var   values;
   CocoaDialog.getPaths({ |path|
      values = Object.readTextArchive(path[0]);
      (values.size != 6).if({
         Error("File does not contain a ringz spec.").throw;
      }, {
            // here, we unpack the archived array, restore the data in the GUI,
            // and refresh the server-side objects
         ~funcedit.setString(values[3], 0, ~funcedit.string.size);
         ~envedit.setString(values[4], 0, ~envedit.string.size);
         ~buildSynthDef.value;
         ~ffreqview.value = values[0];
         ~rqview.value = values[1];
         ~ampview.value = values[2];
         ~nodes.do(_.free);
         ~nodes = { |i|
            n.play(\ringz, [\in, m.inbus.index]);
         } ! values[5];   // make the nodes
         {   ~ffreqview.doAction;
            ~rqview.doAction;
            ~ampview.doAction;
         }.defer(0.2);
      });
   }, nil, 1)
});
)

Using GUI objects for dynamic synthdef building

Apart from the filter definition, the other factor determining the sound is the noise source and its envelope. For maximum flexibility, I wanted to be able to type in a UGen graph and the envelope, and have the right synthdef built automatically.

SCTextView is a new object for editing text in GUI window. You can even run code using the enter key, but I needed to provide "evaluate" buttons because the actions are more complex.

The ~buildSynthDef function is explained below. It is the "guts" of producing the source synthdef.

// right-hand side: source editing
(
SCStaticText(~rightview, 100@20).string_("Noise function:");
ActionButton(~rightview, "evaluate", {
   ~buildSynthDef.value;
});
~rightview.startRow;
~funcedit = SCTextView.new(~rightview, 275@200)
   .string_("PinkNoise.ar");

~rightview.startRow;
SCStaticText(~rightview, 100@20).string_("Envelope:");
ActionButton(~rightview, "evaluate", {
   ~src.setn(\env, ~envedit.string.interpret.asArray);
});
~rightview.startRow;
~envedit = SCTextView.new(~rightview, 275@200)
   .string_("Env.perc(0.01, 0.2)");
)

Some interesting techniques here for dynamic synthdef construction. We get the objects from the text views by ".interpret"-ing the strings.

For the envelope, we create an array of Controls (synth inputs). On the server side, an envelope is always treated as an array, and envelopes can be changed while synth is playing if it's an array of inputs instead hardcoded into the synthdef as an Env. The line "~src.setn(\env, env.asArray);" does this: converts the envelope to an array representation, then populates the synth Controls so that the EnvGen can use it. The envelope can thus be replaced without stopping the synth and making a new def.

SynthDef.wrap lets you insert a function into a synthdef wrapper. Here, the envelope generator is a stable component, so it's defined and used outside the noise function--no need to write the envelope generator into your noise function every time.

The noise function is visible to the outside world because SynthDef.wrap turns its arguments into Controls also. This would not be the case if you used "func.value(args)" to add the function to that the UGen graph. You would not be able to modify any parameters defined inside the function, unless they were also defined in the wrapper passed in as an argument. SynthDef.wrap is more powerful.

The array [trig] in the .wrap call passes the trigger into the first function argument, so you can use the trigger with your oscillator. It's called a "prepend-arg" because the array elements get associated with the first, second, third etc. function arguments.

That means you can put "arg trig;" at the beginning of your exciter function, and then use the trigger for, say, a pitch envelope. The trig must be the first argument.

~buildSynthDef = {
   var   func, env;
   ~currentDef.notNil.if({
      s.sendMsg(\d_free, ~currentDef.name);
   });
   func = ("{ " ++ ~funcedit.string ++ " }").interpret;
   env = ~envedit.string.interpret;
   ~currentDef = SynthDef(\source, { |trigbus, outbus|
      var   trig, sig, envCtl;
      trig = In.kr(trigbus, 1);
      Out.kr(trigbus, trig);
      envCtl = Control.names(\env).kr(0 ! 100);   // allow 25 env nodes maximum
      sig = SynthDef.wrap(func, nil, [trig]) * EnvGen.ar(envCtl, trig);
      Out.ar(outbus, sig)
   }).send(s);
   {   ~src.free;
      ~src = m.play(\source, [\trigbus, ~trigbus.bus.index]);
      ~src.setn(\env, env.asArray);
   }.defer(0.1);
}; 

Roll 'em!

Final initialization: first, play the trigger onto a control bus. The trigger is kept separate so that rebuilding the synthdef doesn't break the rhythm. I use GenericGlobalControl from my library because it lets me play functions on it automatically.

Then, build and run the source synthdef.

// signal management
(
~trigbus = GenericGlobalControl(\t);
{ ~trigsynth = ~trigbus.play({ |trigrate| Impulse.kr(trigrate) },
   [\trigrate, ~rateEdit.value], m.synthgroup);
}.defer(0.2);

{ ~buildSynthDef.value; }.defer(0.5);
)