A new approach to SynthDef control input syntax

TL;DR New quark defining an alternate (I hope improved) syntax for SynthDef control inputs.

It’s not thoroughly tested! But it seems to cover a lot of territory already.

Quarks.install("https://github.com/jamshark70/ddwSynthArgPreprocessor");

thisProcess.recompile;

// edit: initial posting omitted this
SynthArgPreprocessor.install;

// then...
s.boot;

(
a = {
    ##freq = 440;
    ##amp = 0.1;
    SinOsc.ar(freq, 0, amp).dup
}.play;
)

a.set(\freq, 220, \amp, 0.8);

a.release;

So… why?

The faults of using function arguments for SynthDef controls have been amply discussed: confusing behavior of expression defaults, weird prefixes for audio- and trigger-rate controls in particular.

The faults of NamedControl have been downplayed a bit – I personally struggle with them, so I’ve avoided NamedControl for the common case of a single-channel kr control. I do use NamedControl when it counts, especially for arrayed controls. (What are the faults? If you wish to define controls all at the top, names have to be given redundantly: var name = \name.kr; if you dislike the redundancy, there’s a risk of error if you accidentally provide more than one default value for the same control name in different places.)

What would be nice, then, is a syntax that combines the advantages of arg-style (syntactic efficiency, and clearly separating control input definition from usage) and the advantages of NamedControl (clearer syntax for atypical rates, easier to understand default expressions).

I had this idea some years ago but wasn’t able to pull it together. But, recently, I was working on some code-generation classes involving Controls, and realized that I could use those insights for this problem.

So this version supports: ## name = default: rate, lag, spec;.

  • name should be a literal identifier.
  • default is an expression producing a number, or array of numbers. (Note that scsynth does not support a control-input default that depends on a server signal – control defaults always have to be numeric.)
  • rate should be one of k, a, t, i. (Actually I didn’t do i rate yet… oops.)
  • lag is an expression producing a number. (Actually I’m not sure if an arrayed control can have an array of lags… I suspect that it should, but I haven’t tested this yet.)
    • lag can depend on a server signal! It should be OK in this version, although there are probably some exotic cases where it wouldn’t work.)
  • spec is an expression producing an object that answers to .asSpec: e.g., [0, 1, \lin] or \freq.

The default can be omitted; the “rate, lag, spec” also doesn’t have to be fully specified (you could write only rate, or “rate, lag”).

Also, it isn’t strictly required to put ## controls at the top of the function.

(
SynthDef(\test, {
	## freq = 440;
	var sig = SinOsc.ar(freq);

	## out = 0;
	Out.ar(out, sig)
}).dumpUGens;
)

… produces a valid SynthDef. So if you prefer to have controls defined closer to the place where they are used, this is OK. (There is one exception: it’s impossible to support something like the following.)

(
SynthDef(\test, {
	var sig;
	## lagTime = 0.1;

	## freq = 440;
	sig = SinOsc.ar(freq);

	lagTime = lagTime * 2;

	## out = 0: k lagTime;
	Out.ar(out, sig)
}).dumpUGens;
)

… because lagTime = lagTime * 2; must come after the var block, but ## control inputs are vars, and out’s lag time depends on a non-var expression. Can’t be done. But, this is also a very weird scenario.

Now here’s a nice side effect of the logic to consolidate controls as much as possible:

SynthArgPreprocessor.enabled = false;

(
SynthDef(\wideGraph, {
	var freq = \freq.kr(Array.fill(65, { exprand(200, 800) }));
	var amp = \amp.kr(Array.fill(65, { rrand(0.05, 0.2) }));
	Out.ar(\out.kr, SinOsc.ar(freq, 0, amp).sum)
}).add;
)

-> SynthDef:wideGraph
exception in GraphDef_Recv: exceeded number of interconnect buffers.

SynthArgPreprocessor.install;

(
SynthDef(\slimGraph, {
	## freq = Array.fill(65, { exprand(200, 800) });
	## amp = Array.fill(65, { rrand(0.05, 0.2) });
	## out;
	Out.ar(out, SinOsc.ar(freq, 0, amp).sum)
}).add;
)

// no server error!

The problem is that the NamedControl approach splits the freq and amp arrays into separate Control UGens, and this interacts with the UGen sorting logic to pull all the SinOscs up to the top, where each one requires its own wire buffer. SynthArgPreprocessor unifies blocks of controls into one UGen, as much as possible – here, freq, amp and out are all one Control, which in this case works better with the UGen sort (you get a long, narrow chain of SinOsc → MulAdd – minimal wire buffer usage). It’s a subtle problem that never really came up until it became common practice to split up controls. I’ve been thinking for a year or two now how to alleviate that problem without using literal arrays in function arg lists (which are really impractical for larger arrays).

Anyway… feel free to try it out, kick the tires – if you find any bugs, bring them up here or log a bug at Issues · jamshark70/ddwSynthArgPreprocessor · GitHub .

hjh

6 Likes

Amazing!
Preprocessor is very powerful and scary.

Have you considered doing something similar for outputs?

Perhaps like this…

SynthDef(\io, {
    #< freq = Array.fill(65, { exprand(200, 800) });
    #< amp: ar = 0;
    #> out: ar, 1 = 0; //rate, num chans, default 0

    out.( SinOsc.ar(freq).sum * amp )                     // or using pipes:  SinOsc.ar(freq).sum * amp |> out
}).add;

Where #>out is Out.ar(\out.kr, _).

This way the synth could store what ins and outs it has and let you connect synths together in a channel safe way… similar to jitlib

a = Synth(\io, [\out, Bus.audio(s, 1)]);   // this could be the default if no arg is supplied on creation.
b = Synth(\io, [\out, 0]);

b.map(\amp, a.outs[\out]);

or better yet...

a.outs[\out].connect(b.ins[\amp])

or even...

a[\out] >> b[\amp]

This creates a nicer symmetry in that you currently can’t map from an out ctl, but can map to an in ctl.

The .kr is what “declares” it as a named control, so you can just write \name.kr; or [\name.kr, \name2.kr];.

… which opens you up to the problem of using the same control in multiple locations, where you have to take care with the defaults.

It has come up fairly often, when this is discussed, that many users (myself included) find it useful to have a quick glance at the available controls, without having to pick out symbols in the middle of complex expressions. It is true that, if you’re using symbol.kr and you want to pull controls to the top and highlight them visually, that the best way is var lines with the name appearing as the var name and as the symbol. One of the motivations for this design is to keep the visual highlighting but remove the redundancy.

Users who aren’t interested in this kind of input-summary can keep using symbol.kr as they like, of course, no problem. My initial stab at this was mainly just to see if it’s possible / practical. Possible, yes. Practical, remains to be seen.

hjh

1 Like

I hadn’t… Your example isn’t a big part of my use case. Interesting idea but I’m unlikely to develop it myself.

hjh

fantastic!

Have you considered using a keywork like ctl (to go with var and arg) which would have the benefit of making clear that this makes a “Control” ?

(I’m guessing this is impractical because of the preprocessor but perhaps this could be done in the language?)

i-rate controls are now in.

Hmm… that would probably be prettier than the double-hashes.

For this prototype, I wanted to avoid fully parsing the code, just because it’s a lot of work for minimal benefit, and also reproducing work that the sclang executable already does faster. So I chose a syntax that isn’t allowed anywhere in SC – # is used only as a prefix for closed functions, literal arrays and multi-variable assignment – and ## doesn’t mean anything in standard SC. ctl is an identifier, and as such, can appear in many places.

But it only just occurred to me that SC disallows two alphanumeric symbols in a row separated only by a space, except for var, classvar and arg. So ctl anIdentifier = expression couldn’t be mistaken for a variable named ctl, and a regular expression could disambiguate.

Except for one problem: ## signal: a e.g. for an audio-rate control. If it’s ctl signal: a then it looks exactly like a valid binary-op expression. Possible solutions would be a/ just declare that, when using this quark, you should avoid variables named ctl (“at your own risk”), or b/ pick a separator other than : (but the colon looks nice).

I just went ahead and did this, in a separate branch “topic/ctlKeyword” – you can try – with a note in the help advising to avoid somethingctl anoperator: k notation. I’ll probably merge that in later, but for now, “kick the tires” and see if it’s OK first.

hjh

Hm, thinking about this some more. How would you like to specify Out vs ReplaceOut or XOut (noting also that XOut has one additional input)?

hjh

I think the ## prefix is not bad at all. They stand out, and that’s a notable quality, in a good way, as makes reading easier.

At the same time, would fit the tradition of SC to create dialects inside the language.

There’s also dictionaries, which allow a nice “idiom” for this sort of thing, I reckon, i.e.

var ctl = (freq: [440, 443], amp: 0.13).localControls;
SinOsc.ar(ctl.freq, 0) * ctl.amp

One simple definition of localControls copied below.

This one gives qualified names for arrayed inputs (i.e. freq1, freq2) which is useful when auto-generating control interfaces.

But there are other useful variants.

+Event {
	localControls {
		^this.collect { | defaultValue name |
			defaultValue.isArray.if {
				var qualifier = 1;
				defaultValue.collect { | each |
					var qualifiedName = name ++ qualifier.asString;
					qualifier = qualifier + 1;
					NamedControl.kr(qualifiedName, each)
				}
			} {
				NamedControl.kr(name, defaultValue)
			}
		}
	}
}
1 Like

I’d encode it in the type/rate ar, arX, arR.

where #> out: arX returns {|sig, fade(1)| XOut.ar(\out.kr, fade, sig) }.

There should also be checks to make sure the number of channels match… I actually really like this and might fork your code when I get a chance.

One thing though… I’m not sure it is a good idea to have a preprocessor running all the time, seems like it would be very easy to get undebuggable errors.

Perhaps a wrapper around SynthDef could be made that enables and disable the preprocessor so it runs only when the function is evaluated? Maybe call it something like DDWSynthDef?

1 Like

I don’t think there’s any way to have a code block which activates the preprocessor and then applies the preprocessor to the block of code which was running when it activated the preprocessor. All the pre-compiling work has to be done before compiling, and compiling has to be done before executing.

Btw this…

One thing though… I’m not sure it is a good idea to have a preprocessor running all the time, seems like it would be very easy to get undebuggable errors.

… is exactly the tricky thing about any preprocessor project. Shortcuts in the logic can very easily come back to haunt you. (I just discovered this morning that I was incorrectly removing \ characters in string or symbol literals – fixed now, but it reminded me why preprocessors need extremely careful testing.)

Feel free to fork the repo and play with the outputs!

hjh

Hmm … yes I see the issue now, you’d have to do this sort of thing to get it to recompile, which would cause other issues…

SynthDef {
   new {|name, func|
      //Enable 
      func.def.source_code.compile.()
      //Disable
   }
} 

Shame there is no code block/ast object which you could pass and alter, like you get in something like nim.

Re: “ctl” keyword – just fixed a bug for ctls with no specified default or other properties. Before the bugfix, ctl name; failed to match; now it does (in the topic branch).

hjh

1 Like