SynthDef coding-style, controls, specs and GUI

Hi everyone, I have a question about SynthDefs and coding-style.

I’ve noticed that I’ve been adding a control and a GUI spec for practically everything that can be changed in my SynthDef DSP. I also include very detailed comments. I’ve developed my own GUI builder that cycles through the defined controls for each SynthDef or Ndef and creates EZSliders for every control to which I’ve added a custom spec (no spec in the SynthDef means no EZSlider). The slider order in the GUI is the same as the control order in the SynthDef. I can also define control labels in a dictionary that’s passed controlkeys with section strings to the builder. These then appear in the user interface as section labels before the control. I’ve also integrated my own parameter snapshot function, as well as automatic restore parameter values with Ctrl+. to the Pdef in case the GUI was previously opened with values that differed from the SynthDef defaults.

I did all this because I’m still relatively new to sound design (at least that’s how I feel) and believe that I might be able to explore sounds more effectively using the GUI sliders.

The problem now is that this entire process about SynthDef coding slows me down considerably and takes up a lot of time. All specs, all comments, and even the definitions of the variable names, so that the code is reasonably readable and makes sense.

Now I’m thinking about simplifying all of this drastically. So, I won’t use my GUI builder anymore; I’ll just use Ndef and Pdef GUIs for very, very few parameters—only when I really want a slider. Otherwise, I’ll just do sound design directly with code using set or pattern. And I’ll only create controls for parameter values that I’ll actually want to change from the outside later on. Maybe I still don’t quite realize where the line is between what makes sense and what doesn’t?

How do you guys handle this? Is the GUI overrated? When should you really define a control as an argument? Only when you actually need it, or in anticipation of several you might need but aren’t sure about yet? How do you handle comments?

Here’s a synthdef in my old style, and then how I might write it in a simplified way in the future.

Old Version:

(
// SynthDef: Metal-Pipe Strike Test Synthesizer - V0.1.0
SynthDef(\metalpipe, {
	arg out=0, amp=1, gate=1;
	// Global
	var ampOut = \ampOut.kr(1, spec: [0, 2, \lin, 0.01].asSpec);
	var pan = \pan.kr(0, spec: [-1, 1, \lin, 0.01].asSpec);
	var cfHpGuard = \cfHpGuard.kr(20, spec: [20, 22000, \exp, 1].asSpec);
	var cfLpGuard = \cfLpGuard.kr(20000, spec: [20, 22000, \exp, 1].asSpec);
	// Sine-Transient: Stick / Tonal Exciter
	var ampTransOut = \ampTransOut.kr(0.35, spec: [0, 2, \lin, 0.01].asSpec);
	var ampTransRes = \ampTransRes.kr(0.15, spec: [0, 2, \lin, 0.01].asSpec);
	var freqTrans = \freqTrans.kr(330, spec: [20, 20000, \exp, 1].asSpec);
	var pitchTransHiHz = \pitchTransHiHz.kr(1000, spec: [20, 20000, \exp, 1].asSpec);
	var relPitchTrans = \relPitchTrans.kr(0.015, spec: [0.001, 2, \exp, 0.001].asSpec);
	var atkAmpTrans = \atkAmpTrans.kr(0.001, spec: [0.001, 1, \exp, 0.001].asSpec);
	var relAmpTrans = \relAmpTrans.kr(0.05, spec: [0.01, 2, \exp, 0.01].asSpec);
	// Noise-Transient: Stick / Rough Exciter
	var ampNoiseOut = \ampNoiseOut.kr(0.5, spec: [0, 4, \lin, 0.01].asSpec);
	var ampNoiseRes = \ampNoiseRes.kr(0.3, spec: [0, 4, \lin, 0.01].asSpec);
	var mixHashNoise = \mixHashNoise.kr(0.8, spec: [0, 1, \lin, 0.01].asSpec);
	var cfHpNoise = \cfHpNoise.kr(100, spec: [20, 20000, \exp, 1].asSpec);
	var cfBpNoise = \cfBpNoise.kr(6500, spec: [20, 20000, \exp, 1].asSpec);
	var rqBpNoise = \rqBpNoise.kr(0.9, spec: [0.02, 4, \exp, 0.01].asSpec);
	var mixBpNoise = \mixBpNoise.kr(0, spec: [0, 1, \lin, 0.01].asSpec);
	var driveTanhNoise = \driveTanhNoise.kr(1, spec: [0.1, 20, \exp, 0.01].asSpec);
	var atkAmpNoise = \atkAmpNoise.kr(0.001, spec: [0.001, 1, \exp, 0.001].asSpec);
	var relAmpNoise = \relAmpNoise.kr(0.1, spec: [0.01, 2, \exp, 0.01].asSpec);
	// Noise-Tail: Metallic Dust / Air Texture
	var ampTail = \ampTail.kr(0.35, spec: [0, 2, \lin, 0.01].asSpec);
	var cfHpTail = \cfHpTail.kr(300, spec: [20, 20000, \exp, 1].asSpec);
	var cfPeakEqTail = \cfPeakEqTail.kr(3000, spec: [20, 20000, \exp, 1].asSpec);
	var rqPeakEqTail = \rqPeakEqTail.kr(0.5, spec: [0.02, 4, \exp, 0.01].asSpec);
	var dbPeakEqTail = \dbPeakEqTail.kr(3, spec: [-24, 24, \lin, 0.1].asSpec);
	var delayAmpTail = \delayAmpTail.kr(0.006, spec: [0, 0.1, \lin, 0.001].asSpec);
	var atkAmpTail = \atkAmpTail.kr(0.012, spec: [0.001, 1, \exp, 0.001].asSpec);
	var relAmpTail = \relAmpTail.kr(0.22, spec: [0.01, 2, \exp, 0.01].asSpec);
	// Feedback-Resonator: Hollow Metal Body
	var ampResOut = \ampResOut.kr(1.8, spec: [0, 4, \lin, 0.01].asSpec);
	var ampFbRes = \ampFbRes.kr(0.98, spec: [0, 0.98, \lin, 0.01].asSpec).clip(0, 0.98);
	var timeDelayResMs = \timeDelayResMs.kr(0.8, spec: [0.1, 5, \exp, 0.01].asSpec).clip(0.1, 5);
	var cfHpFbRes = \cfHpFbRes.kr(150, spec: [20, 22000, \exp, 1].asSpec);
	var cfLpFbRes = \cfLpFbRes.kr(2500, spec: [20, 22000, \exp, 1].asSpec);
	var timeDelayMax=0.2;
	var envAmpTrans, envPitchTrans, sigTrans, envAmpNoise, sigNoise, sigExc, sigStick, envAmpTail,
	sigTail, sigFbIn, sigFbRes, sig;
	// Trans (Attack): pitched sine transient with pitch envelope and amplitude envelope.
	// Roles, flexible: tonal knock / punch / short attack body.
	envAmpTrans = Env.perc(atkAmpTrans, relAmpTrans, curve: -4).ar(0, gate);
	envPitchTrans = Env([pitchTransHiHz, freqTrans], [relPitchTrans], curve: -4).ar(0, gate);
	sigTrans = SinOsc.ar(envPitchTrans, 0, 1) * envAmpTrans;
	// Noise (Attack): HP-filtered White/Hash noise transient with optional BP mix,
	// Tanh saturation and amplitude envelope.
	// Roles, flexible: stick noise / rim clack / rough metal contact.
	envAmpNoise = Env.perc(atkAmpNoise, relAmpNoise, curve: -4).ar(0, gate);
	sigNoise = HPF.ar(WhiteNoise.ar(1).blend(Hasher.ar(Sweep.ar), mixHashNoise), cfHpNoise);
	sigNoise = sigNoise.blend(BPF.ar(sigNoise, cfBpNoise, rqBpNoise), mixBpNoise);
	sigNoise = (sigNoise * driveTanhNoise).tanh * envAmpNoise;
	// Stick / Exciter Mix: direct attack output and separate resonator excitation.
	// Roles, flexible: balances audible stick hit against the energy sent into the metal body.
	sigStick = (sigTrans * ampTransOut) + (sigNoise * ampNoiseOut);
 	sigExc = (sigTrans * ampTransRes) + (sigNoise * ampNoiseRes);
	// Tail: PeakEQ/HP-filtered WhiteNoise with slightly delayed amplitude envelope.
	// Roles, flexible: metallic dust / short noisy after-ring / air texture.
	envAmpTail = Env([0, 0, 1, 0], [delayAmpTail, atkAmpTail, relAmpTail], curve: -4).ar(0, gate);
	sigTail = BPeakEQ.ar(WhiteNoise.ar(1), cfPeakEqTail, rqPeakEqTail, dbPeakEqTail);
	sigTail = HPF.ar(sigTail, cfHpTail) * ampTail * envAmpTail;
	// Feedback Resonator: short LP/HP-filtered delay feedback loop excited by the stick signal.
	// Roles, flexible: hollow tube body / metallic ring / resonant pipe tone.
	sigFbIn = LeakDC.ar(LocalIn.ar(1));
	sigFbRes = DelayC.ar(sigExc + (sigFbIn * ampFbRes), timeDelayMax, timeDelayResMs * 0.001);
	sigFbRes = HPF.ar(LPF.ar(sigFbRes, cfLpFbRes), cfHpFbRes);
	LocalOut.ar(sigFbRes);
	// Mix: direct stick hit, noisy tail and resonator body.
	sig = Mix([sigStick, sigTail, sigFbRes * ampResOut]);
	// Stereofield placement and amplitude output stage.
	sig = Pan2.ar(sig, pan, amp * ampOut);
	// Guard filters, cascaded for a steeper resonance-free rolloff.
	sig = HPF.ar(sig, cfHpGuard); sig = HPF.ar(sig, cfHpGuard);
	sig = LPF.ar(sig, cfLpGuard); sig = LPF.ar(sig, cfLpGuard);
	// Output
	Out.ar(out, sig);
	// Free the synth when all output channels stay below -70 dB for 100 ms.
	// Impulse starts the silence check immediately; .product requires silence on all channels.
	FreeSelf.kr(DetectSilence.ar(sig + Impulse.ar(0), amp: -70.dbamp, time: 0.1).product);
}).add;

// Section headers for GUI titles:
~sectionsMetalPipe = IdentityDictionary[
	\ampOut -> "Global",
	\ampTransOut -> "Sine-Transient: Stick / Tonal Exciter",
	\ampNoiseOut -> "Noise-Transient: Stick / Rough Exciter",
	\ampTail -> "Noise-Tail: Metallic Dust / Air Texture",
	\ampResOut -> "Feedback-Resonator: Hollow Metal Body"
];

// Custom GUI call:
~buildGui.(\metalpipe, ~sectionsMetalPipe, [17, 17]);

Pdef(\metalpipe,
	Pbind(
		\instrument, \metalpipe,
		\dur, 1,
		\amp, 1,
		\pan, 0,
		\sendGate, false
	)
).play;
)

Simplified Version:

(
// SynthDef: Metal-Pipe Strike Test Synthesizer - V0.1.0
SynthDef(\metalpipe, {
	arg out=0, amp=1, gate=1, ampOut=1, pan=0;
	var envAmpTrans, envPitchTrans, sigTrans, envAmpNoise, sigNoise, sigExc, sigStick, envAmpTail,
	sigTail, sigFbIn, sigFbRes, sig;
	// Sine-Transient
	envAmpTrans = Env.perc(0.001, 0.05, curve: -4).ar(0, gate);
	envPitchTrans = Env([1000, 330], [0.015], curve: -4).ar(0, gate);
	sigTrans = SinOsc.ar(envPitchTrans, 0, 1) * envAmpTrans;
	// Noise-Transient
	envAmpNoise = Env.perc(0.001, 0.1, curve: -4).ar(0, gate);
	sigNoise = HPF.ar(WhiteNoise.ar(1).blend(Hasher.ar(Sweep.ar), 0.8), 100);
	sigNoise = sigNoise.blend(BPF.ar(sigNoise, 6500, 0.9), 0);
	sigNoise = (sigNoise * 1).tanh * envAmpNoise;
	// Stick / Exciter Mix
	sigStick = (sigTrans * 0.35) + (sigNoise * 0.5);
 	sigExc = (sigTrans * 0.15) + (sigNoise * 0.3);
	// Noise-Tail
	envAmpTail = Env([0, 0, 1, 0], [0.006, 0.012, 0.22], curve: -4).ar(0, gate);
	sigTail = BPeakEQ.ar(WhiteNoise.ar(1), 3000, 0.5, 3);
	sigTail = HPF.ar(sigTail, 300) * 0.35 * envAmpTail;
	// Feedback-Resonator
	sigFbIn = LeakDC.ar(LocalIn.ar(1));
	sigFbRes = DelayC.ar(sigExc + (sigFbIn * 0.98), 0.2, 0.8 * 0.001);
	sigFbRes = HPF.ar(LPF.ar(sigFbRes, 2500), 150);
	LocalOut.ar(sigFbRes);
	// Mix
	sig = Mix([sigStick, sigTail, sigFbRes * 1.8]);
	// Stereofield placement
	sig = Pan2.ar(sig, pan, amp * ampOut);
	// Guard filters
	sig = HPF.ar(sig, 20); sig = HPF.ar(sig, 20);
	sig = LPF.ar(sig, 20000); sig = LPF.ar(sig, 20000);
	// Output
	Out.ar(out, sig);
	// Synth freeing
	FreeSelf.kr(DetectSilence.ar(sig + Impulse.ar(0), amp: -70.dbamp, time: 0.1).product);
}).add;

Pdef(\metalpipe,
	Pbind(
		\instrument, \metalpipe,
		\dur, 1,
		\amp, 1,
		\pan, 0,
		\sendGate, false
	)
).play;
)

What do you think about this direction? Does the reduced version make sense, or am I stripping things down too much? Would you keep working closer to the current more complicated style with args and specs, or move toward the reduced style where most values stay directly at their “point of use” in the SynthDef?

P.S. Feel free to use or modify the synth example if it is useful to you.
P.P.S. My English is really bad, is that okay with you guys? I even use a translator sometimes to help (not always), but that doesn’t really make it much better…
P.P.P.S. The SynthDef comments are also translated, not sure if this fits my meaning exactly everything, but it looks not bad at all.

1 Like

Very cool! I much prefer your more stripped down version - for reading certainly! But they each have their virtues!

Myself, I like to keep things as terse an possible (maybe to a fault…)

Something that I now can’t live without is function chaining: Function chaining operators - opinions? thoughts? do you like this? - a big, hopefully discursive, post

so you can write:

sigFbRes = DelayC.ar(sigExc + (sigFbIn * 0.98), 0.2, 0.8 * 0.001) 
  |> HPF.ar(LPF.ar(_, 2500), 150)
  |> LocalOut.ar(_);

and for Pbinds if you want to save a few characters you can now type:

Pbind(instrument: \metalpipe, dur: 1)

I don’t much use specs preferring to set values by hand but this is personal!

cheers!

1 Like

My ddwSynthArgPreprocessor handles all the requirements with an alternate syntax:

// old way
var ampOut = \ampOut.kr(1, spec: [0, 2, \lin, 0.01].asSpec);

// new way
## ampOut = 1, k, [0, 2, \lin, 0.01];

The preprocessor syntax removes the redundant doubling of the control name, and incorporates rate, lag (not shown) and spec in the same place. About as tight as it’s going to get. For whatever reason, the quark never caught on, though, some sort of inertia.

hjh

3 Likes

Hi and welcome,

you’re touching a couple of important questions. Indeed, a decision for this or that strategy has enormous consequences. Much depends on personal preferences, the overall goal (e.g., performance-oriented or not) and the concrete setup of sound experimentation.
In general, I think, GUIs are very important in the process, but not every time and there are some caveats. E.g., when doing granular synthesis with Pbindefs, you might be much more flexible with setups based on coding alone. A psychological disadvantage of GUIs lies in the fact, that they tend to give the impression of something fixed and our task is to play with them, whereas, more often than not, the underlying architecture of a SynthDef needs more thought. Also, the number of params is very relevant. Let it go above 20 or so and it’s easy to loose oversight. Very often, it helps to think about strategies to reduce this number. E.g. an array of 10 numbers can be derived from a simple function with a curve, then you only need min, max and the curve param.

It absolutely makes sense to use GUIs not as a default but only if you want to dig deeper. But it also makes sense to build your own GUIs if you want something that standard solutions don’t provide. Ndef and Pdef GUIs are fine, but I developed VarGui because I wanted to have control of server and language, i.e., synth params, environmental variables for patterns, synths and players in one GUI. Also I wanted to handle array controls, which is not possible with the standard GUIs as they were made before array controls were added to SC.
One remark to your code example: I like to pass the specs with the metadata argument of the SynthDef. That way, the core ugen graph is more readable and metadata can be added in a separate step. But, of course, these things are also a matter of taste, same with arguments vs NamedControl.

(
SynthDef(\fm_1a, { |freq = 400, modFreq = 200, modIndex = 1, amp = 0.1, gate = 1|
	var freqDev = modIndex * modFreq;
	Out.ar(0,
		SinOsc.ar(
			SinOsc.ar(modFreq, 0, freqDev, freq),
			mul: amp,
		) ! 2 * EnvGen.ar(Env.asr(), gate, doneAction: 2)
	);
}, metadata: (
	specs: (
		freq: [20, 5000, \exp, 0, 400],
		modFreq: [20, 1000, \exp, 0, 200],
		modIndex: [0, 10, \lin, 0, 1],
		amp: [0, 0.3, \lin, 0, 0.1],
		gate: [0, 1, \lin, 1, 1]
	)
)).add;
)


// standard options

Ndef(\a, \fm_1a).gui

SynthDescLib.global[\fm_1a].makeGui


// VarGui (miSCellaneous_lib quark)

\fm_1a.sVarGui.gui

Best Regards

Daniel

3 Likes

You don’t need to declare all inputs as vars, you can just reuse them

SynthDef(\a) {
   var s = \a.kr(1);
   s + \a.kr
}.add

Just only declare the args the first time.

The down side is that it means they aren’t all at the top.

But I think if your SynthDef doesn’t fit on one screen, you’ve got a problem, that you can probably solve with functions.

f = { |in| SinOsc.ar(in) };
SynthDef(\a){
   ...
   Out.ar(\out.kr, f.(...))
}

One thing I’d recommend is not putting control in the dub functions.

I also find the pipe feature (|>) very useful, primarily because it means you often don’t need to declare all your variables out front

SynthDef(\a) {
   var a = ... 
      |> ... 
      |> ... ;
   var b = ...
      |> ...
      |> ...;
}

If you give them decent names, you need less comments. This does require a slightly different way of things some times - but I think it’s worth it.

Is also consider adding another operator that doesn’t change the output, this is useful for side effects. I normally use an adverb for this.

SynthDef(\a) {
   var a = ... 
      |> ... // 1.
      |>.o Out.ar(..., _) \\ doesn't return the output of Out.
      |> ... // Takes the value at 1 as the input.
}

This is what I find useful, but you should experiment and find what works for you!

1 Like

Thank you all for your replies!

First, regarding the chaining operators:
Am I correct that this is an extension or something similar and isn’t part of standard SC?
Also, I spent about an hour looking at code examples for this writing style, and I actually find the vanilla style with sig = sig… etc. quite pleasant and extremely clear! Even the nested structures and methods in UGens don’t give me any trouble! I sometimes prefer to write a lot of nested DSP code on a single line. My brain just works that way, and I can read it all very quickly.

What I’m trying to say is, I don’t know if I’d like this chaining operator style of writing. I’m not completely opposed to it, but I don’t see any immediate need to switch, more so if it’s an external installation. Of course, tips like these are always helpful, and maybe I’ll come back to this later after all.

My question was more about how exactly I decide when to use controls instead of hard-coding values, and when I should create controls with spec for a GUI.

I don’t waste that much time writing the code itself, but rather thinking about:

  • what a variable should be called
  • what the specs should be (min, max, mapping, …)
  • should this go in as a control or be hard-coded?
  • should this even be included as a GUI with specs?

If I simply define most values that has relevant effects like in a synth plugin as a control with specs for the GUI, and then I want to make a change in the dsp, it also takes me much longer to adjust everything again.

Also, as I said, I’m thinking about how important the GUI really is for sound exploration. I don’t perform live; the GUI is only there to let me change parameters while I’m creating a track or design the sound/instruments, but once I’ve found the sweet spot for a parameter, I hard-code it in (if it is not modulated by patterns, routines or others! In the final end, everything is coded, and I record the entire track without making any further manual changes via GUI. The expection is maybe tweaking mixing parameters here and there…

Of course, I’ve already gotten an answer here that the GUI is very important for sound exploration. That confirms my suspicion that cutting everything out so radically and simplifying it, is after all the wrong approach — even though I wish it were…

FWIW:

SynthArgPreprocessor.enabled = true;  // from the ddwSynthArgPreprocessor quark

(
SynthDef(\fm_1a, {
	## freq = 400: [20, 5000, \exp, 0, 400];
	## modFreq = 200: [20, 1000, \exp, 0, 200];
	## modIndex = 1: [0, 10, \lin, 0, 1];
	## amp = 0.1: [0, 0.3, \lin, 0, 0.1];
	## gate = 1: [0, 1, \lin, 1, 1];
	
	var freqDev = modIndex * modFreq;
	Out.ar(0,
		SinOsc.ar(
			SinOsc.ar(modFreq, 0, freqDev, freq),
			mul: amp,
		) ! 2 * EnvGen.ar(Env.asr(), gate, doneAction: 2)
	);
}).add;
)

Syntactically, this smokes all the other options discussed above. IMO if you really need specs for everything, the only reason not to write it this way is the dependency on a quark. (If we collectively decided that we want a proper solution for synth args with specs, we could bring the quark into core, or add to the backend lexer. I think this won’t happen, though, and it isn’t clear to me why we’re satisfied with syntactic compromises here.)

For some users (e.g. me) scattering args throughout the function body is an absolute non-starter. For me, listing all synth controls at the top is a matter of self-documentation. I (personally) do not like the waste of time involved in searching through a function body for control names.

Also, “you can just reuse them” is problematic because a subsequent declaration might accidentally have a different default, and then you get an error. I never liked the way the scattered-arg approach has been sometimes presented as trouble-free when, for simple kr args, it isn’t any better or worse than function-args-as-controls.

hjh