Ndef simple channel expansion question

In the Ndef graph below ‘s’ is stereo but ‘z’ is mono (Sc 3.11.2 & Sc 3.12.1):

Ndef('o', { LFSaw.kr(#[8, 7.23], 0) * 3 + 80 });
Ndef('f', { LFSaw.kr(0.4, 0) * 24 + Ndef('o') });
Ndef('s', { SinOsc.ar(Ndef('f').midicps, 0) * 0.04 });
Ndef('z', { CombN.ar(Ndef('s'), 0.2, 0.2, 4) * 0.1 });
Ndef('z').play

['o','f','s','z'].collect({ arg item; Ndef(item).bus.numChannels }) == [2, 2, 2, 1]

However if the CombN decaytime is set to [4, 4] (for instance) then ‘z’ is stereo as well.

All of ‘o’ ‘f’ ‘s’ and ‘z’ are ‘elastic’:

['o','f','s','z'].collect({ arg item; Ndef(item).reshaping }) == ['elastic', 'elastic', 'elastic', 'elastic']

Why doesn’t ‘s’ make ‘z’ expand? How should I write this instead?

Ps. Are either of the two expressions below the correct way to make ‘elastic’ the default rule?

Ndef(\nil).proxyspace.reshaping = 'elastic';
BusPlug.defaultReshaping = \elastic;

Sorry to pester about this, I’m sure I’m misunderstanding something very obvious!

I’ve checked it’s not some stray extension, it’s the same with only the standard library.

It seems that f is stereo because of o, and then s because of f, but then not z because of s.

Any help much appreciated.

Actually, I think I’ve stumbled upon the same question!

Randomly panned dust into a comb resonator, fine

Ndef(\comby, {CombL.ar(Pan2.ar(Dust.ar(7), pos: LFNoise0.kr(5)), delaytime: 0.015, decaytime: 4)}).play

Ndef(\comby).numChannels == 2

// But, written differently, the second Ndef does not expand to two channels, only get one:

Ndef.clear

Ndef(\dusty, {Pan2.ar(Dust.ar(7), pos: LFNoise0.kr(5))})

Ndef(\comby, {CombL.ar(Ndef(\dusty), delaytime: 0.015, decaytime: 4)}).play

Ndef(\comby).numChannels == 1

Even if I explicitly make the second Ndef two channels, still only hearing the left

Ndef.clear

Ndef(\dusty, {Pan2.ar(Dust.ar(7), pos: LFNoise0.kr(5))})

Ndef(\comby).mold(2,\audio)

Ndef(\comby, {CombL.ar(Ndef(\dusty), delaytime: 0.015, decaytime: 4)}).play

Ndef(\comby).numChannels == 2

I have tried elastic reshaping, but makes no difference.

Right, it looks to me like the problem is not with what I was trying to do, but with CombL. Other ugens expand as I would have expected:

Ndef(\dusty, {Pan2.ar(Dust.ar(7), pos: LFNoise0.kr(5))})
Ndef(\comby, {FreqShift.ar(Ndef(\dusty), freq: -500 )}).play
Ndef(\comby).numChannels // 2
Ndef(\comby).clear
Ndef(\comby, {Ringz.ar(Ndef(\dusty),freq: 440, mul: 0.2)}).play
Ndef(\comby).numChannels // 2
Ndef(\comby).clear
Ndef(\comby, {CombL.ar(Ndef(\dusty), delaytime: 0.015, decaytime: 4)}).play
Ndef(\comby).numChannels // 1
1 Like

I always thought that the standard way to access a NodeProxy’s signal within another synthesis function is by calling .ar or .kr on it, depending on the rate:

Ndef(\ar2, { SinOsc.ar([440, 550]) });

LPF.ar(Ndef(\ar2).ar, 1000)  // two LPFs

LPF.ar(Ndef(\ar2), 1000)  // one LPF

I realize there’s an effort underway to eliminate the need for rate constructors/accessors, but such shouldn’t be assumed to be completely implemented everywhere at present. (Perhaps it’s a good idea to move in that direction…?)

hjh

1 Like

Thanks James, I hadn’t thought of that. Let me give that a try when I get home.

Ah, brilliant, thanks.

And if you don’t happen to know the rate of the proxy it does:

Ndef('z', { CombN.ar(Ndef('s').perform(Ndef('s').bus.rate.rateToSelector), 0.2, 0.2, 4) * 0.1 });

(I couldn’t find rateToSelector in the standard libraries, but it’s easy to write.)

Thanks kindly!

1 Like

I looked at this some more.

‘f’ and ‘s’ both use math operators on the source Ndef:

  • ‘f’: Ndef(\o) is the second operand of a binary operator.
  • ‘s’: Ndef(\f) is the only operand of the unary operator midicps.

So these are handled by the AbstractOpPlug hierarchy – any math operator applied to any subclass of BusPlug will end up here.

UnaryOpPlug takes its number of channels from the source BusPlug. BinaryOpPlug determines its number of channels as the larger of the two operands (ignoring operands that haven’t been initialized yet – a = NodeProxy.new; b = NodeProxy.new; (a + b).numChannels is nil).

And, when a *OpPlug is resolved into something that can plug into a UGen (by asUGenInput.value), this requires resolving into the multichannel array.

So far so good.

‘z’ does not apply any math operators. So the Ndef is a direct input – that is, you’re not passing in the channels – you’re passing in some other object that represents the channels. By asUGenInput or asAudioRateInput, this other object is expected to convert itself into its channels.

JITLib reshaping logic appears to be that a source BusPlug should adapt itself to the number of channels that the target expects – this.value(for) where for is the signal processor for which the source is resolving itself.

UGens assume that they are single-channel. So Ndef(\s) is trying to resolve itself down to a single channel “for CombN.” (I confirmed using some debugging traces that AbstractFunction:asAudioRateInput is receiving for == CombN and subsequently, BusPlug:ar is being called with arg numChannels = 1 – so this is where it’s going askew.)

CombN.ar(Ndef(\s).ar) bypasses that logic – ar converts the proxy into the array of two channels, and then CombN must be stereo.

It’s still a bit mysterious – it looks like kr and ar inputs are treated differently: Ndef(\f).midicps is kr and resolves to two channels, but if I try CombN.ar(Ndef(\s) + 0) to force a math operation, CombN still squeezes this down to one channel.

So my conclusion for now is that to be safe, UGens require UGens (not proxies), or arrays of UGens, as inputs, and to get these from a NodeProxy, best practice is to call the rate method.

hjh

3 Likes

Thanks for the detailed analysis!

The Ndef rules are quite subtle, moreso than I’d understood.

However, setting the rate by reading from the bus parameter solves the immediate problem, thankyou!

Also, in case useful to anyone, the program below rewrites simple Ugen graphs as Ndef graphs.

I.e. the Ndef graph above is written:

var o = LFSaw.kr([8, 7.23], 0) * 3 + 80;
var f = LFSaw.kr(0.4, 0) * 24 + o;
var s = SinOsc.ar(f.midicps, 0) * 0.04;
CombN.ar(s, 0.2, 0.2, 4) * 0.1

It’s a (very!) simple Ast rewrite rule, but it does allow:

  • using the same notation for Ndef and UGen graphs
  • reevaluating binding expressions as the Ndef graph runs (obviously… thanks Ndef!)

https://gitlab.com/rd--/stsc3/-/blob/master/Language/Smalltalk/SuperCollider/Ndef.hs

1 Like

As another limitation, n-ary (n>2) ops are only defined for UGens but not Ndefs at the moment.

blend(SinOsc.ar(), LFSaw.ar(), 0.3) // -> a XFade2

Ndef(\xa) { SinOsc.ar() }
Ndef(\xb) { LFSaw.ar() }

blend(Ndef(\xa), Ndef(\xb), 0.4)
// ERROR: NotYetImplementedError
// RECEIVER: BusPlug:composeNAryOp

It might be why Ndef math is not widely advertised in the documentation, as far as I can tell. I only discovered it because I saw the BinaryOpPlug class in the sources… Ndefs will actually try to play any AbstractFunction (which is a superclass of BinaryOpPlug) because:

+ AbstractFunction {
	prepareForProxySynthDef { ^{ this.value } }
}

So

Ndef(\xs, { 0.2 * SinOsc.ar() } + { 0.3 * LFSaw.ar() })
Ndef(\xs).scope

Works, even though

SynthDef(\blergh, { 0.2 * SinOsc.ar() } + { 0.3 * LFSaw.ar() })
// ERROR: Message 'def' not understood.

does not, although you can fool it by some wrapping:

SynthDef(\oh, { value({ 0.2 * SinOsc.ar() } + { 0.3 * LFSaw.ar() }) } ).add
// needs an Out to actually play, of course

You can easily guess why AbstractFunctions can’t be synthdef’d directly. The latter paradigm uses explicit I/O ugens like Out, unlike Ndef which uses a functional one, grabbing the returned value of your function and wrapping that in a single Out.

Sadly

({ 0.2 * SinOsc.ar() } + { 0.3 * LFSaw.ar() }).play 
// ERROR: Message 'play' not understood.
// RECEIVER: a BinaryOpFunction

Although I suspect it’s not hard to make that work because when you play a regular Function, it’s almost JITLib that plays it, really via the somewhat obscure GraphBuilder class that only differs from the ProxySynthDef business in that it’s somewhat simpler and returns a regular SynthDef, but it also does the kind of function result capture and Out node addition, even with JITlib style envelope having a \fadeTime control.

In difference to that, SinOsc doesn’t call asAudioRateInput on any of its inputs

CombN : PureUGen {

	*ar { arg in = 0.0, maxdelaytime = 0.2, delaytime = 0.2, decaytime = 1.0, mul = 1.0, add = 0.0;
		^this.multiNew('audio', in.asAudioRateInput(this), maxdelaytime, delaytime, decaytime).madd(mul, add)
	}
}

SinOsc : PureUGen {
	*ar {
		arg freq=440.0, phase=0.0, mul=1.0, add=0.0;
		^this.multiNew('audio', freq, phase).madd(mul, add)
	}

Also

Ndef.findRespondingMethodFor(\asAudioRateInput)
// -> AbstractFunction:asAudioRateInput

and that does

	asAudioRateInput { |for|
		var result = this.value(for);
		^if(result.rate != \audio) { K2A.ar(result) } { result }
	}

So it calls something like Ndef-instance.value(CombN-instance) which seem to try to guess the rate (in BusPlug.value)

	value { | something |
		var n;
		if(UGen.buildSynthDef.isNil) { ^this }; // only return when in ugen graph.
		something !? { n = something.numChannels };
		^if(something.respondsTo(\rate) and: { something.rate == 'audio'} or: { this.rate == \audio }) {
			this.ar(n)
		} {
			this.kr(n)
		}

	}

But the big catch is that numChannels is always 1 for UGens, as far as I understand it! The help says

Discussion:
For a UGen, this will always be 1, but Array also implements this method, so > multichannel expansion is supported. See Multichannel Expansion.

So since something is a CombN instance in our case something.numChannels is going to be 1 there.

It was actually more mysterious to me how it works (properly) when asAudioRateInput isn’t in the call chain, i.e. what it usually does e.g. in the case of SinOsc etc. to guess the number of channels properly.

Actually, that does

	*multiNewList { arg args;
		var size = 0, newArgs, results;
		args = args.asUGenInput(this);

and then

	asUGenInput {
		^this.value(nil)
	}

So funnily enough it goes also in the same BusPlug.value function but with ignored (nil) argument instead of a Ugen for something! And that calls Ndef.ar(nil) instead of 1 as arg. Apparently there’s magic in Ndef.ar (actually BusPlug.ar) that returns all the channels on nil arg. Yeah, it’s on the last line of that method:

	ar { | numChannels, offset = 0, clip = \wrap |
		var output;
		if(this.isNeutral) {
			this.defineBus(\audio, numChannels)
		};
		this.prepareOutput;
		output = InBus.ar(bus, numChannels, offset, clip);
		// always return an array if no channel size is specified
		^if(numChannels.isNil) { output.asArray } { output }
	}

By the way, that seems to rely on an undocumented feature in InBus that seems to return all channels if nil is provided as 2nd arg!

b = Bus.audio(s, 3)
InBus.ar(b)
// -> [ an OutputProxy, an OutputProxy, an OutputProxy ]

InBus.ar(b, 2)
// -> [ an OutputProxy, an OutputProxy ]

// An here's another obscure feature:
InBus.ar(b, 4)
// -> [ a XFade2, a XFade2, a XFade2, a XFade2 ]

Yeah, the code is self-documenting:

InBus { // ...
	*new1 { |rate, bus, numChannels, offset, clip|
		var out, index, n, busRate;
		bus = bus.value.asBus;
		busRate = bus.rate;
		n = bus.numChannels;
		numChannels = numChannels ? n;

So it seems to me the fix is to have BusPlug.value check if the class of something is a Ugen, and in that case just make n = nil before calling .ar because that magically gets the whole bus thanks to InBus smarts.

Edit: previous version had a bug. It broke something much more trivial like Ndef(\jc, Ndef(\ar2)) with ERROR: Message 'isUGenClass' not understood. RECEIVER: Instance of Ndef . The reason being that that is treated as if it were an identity math expression. I’ll have to ask on the Dev sub-forum what’s the best way to inquire about classes and instance types in one method, but for now the version below that defines both *isUGenClass and isUGenClass works properly in all cases I could think of.

So, this fixes it for me:

// CombN.isUGen == false while CombN.ar.isUGen == true because isUGen an instance method! So:
+ Object {
	*isUGenClass { ^false }
	isUGenClass { ^false }
}

+ UGen {
	*isUGenClass { ^true }
	isUGenClass { ^false }
}
+ BusPlug {
	value { | something |
		var n;
		if(UGen.buildSynthDef.isNil) { ^this }; // only return when in ugen graph.
		// UGen.numCh always 1, so we let InBus.new1 called by ar/kr auto-detect on nil
		if(something.notNil) { // nil.isUGen == false while nil.isUGenClass --> Error
			if(something.isUGen or: { something.isUGenClass }) {
				//("is UG or UGC" + something + n).postln
			} {
				n = something.numChannels
			}
		};
		//("bpv: " + something + something.isUGen + (something !? { something.isUGenClass }) + n).postln;
		^if(something.respondsTo(\rate) and: { something.rate == 'audio'} or: { this.rate == \audio }) {
			this.ar(n)
		} {
			this.kr(n)
		}

	}
}

/*
-> Ndef('o')
bpv:  nil false nil nil
-> Ndef('f')
bpv:  nil false nil nil
-> Ndef('s')
is UG or UGC CombN nil
bpv:  CombN false true nil
-> Ndef('z')
-> Ndef('z')
-> [ 2, 2, 2, 2 ]
*/

That transformer (which looks fairly useful) could probably be written in SC itself as it just replaces every e.g. BinaryOpUGen with BinaryOpPlug and the like for unary ones, if I’m not mistaken.

A simple conversion function (in the style of .value) at every node of a BinaryOpUGen etc. could recursively produce the other kind of tree nodes and vice-versa, if I’m not mistaken.

It doesn’t seem too useful though, because it’s not clear how to handle any input parameters like controls. In fact, that’s generally a problem with the AbstractFunction trees in SC. Somewhat related: see my question here about “docking” Ndefs one into another. A fundamental feature of the SynthDef builder is that it collects into one “master list” all the generated controls during function execution on the client, which also produces the (AST-like) node graph. Since I see you’re a Haskell expert, I’m guessing you’ve implemented the SynthDef compiler in Haskell using some kind of moand to collect the side effects like Control generation. So, if I’m not misusing those terms, we could ask: what is the monad transformer for Controls, when you turn the UGens into Ndefs?

This is a bit misleading. With my patch you still get one LPF object from that, but that object does multi-channel expansion in a UGen context because of BusPlug.value that expands.

Ndef(\ar2, { SinOsc.ar([440, 550]) });
Ndef(\fi1, { LPF.ar(Ndef(\ar2), 1000) })  // one LPF?
Ndef(\fi1).bus.numChannels // -> 2
Ndef(\fi1).objects[0].synthDef.dumpUGens
/* 
-> a ProxySynthDef
temp__0fi181545551_1016
[ 0_InFeedback, audio, [ 4 ] ]
[ 1_LPF, audio, [ 0_InFeedback[0], 1000 ] ]
[ 2_LPF, audio, [ 0_InFeedback[1], 1000 ] ]
[ 3_Control, control, nil ]
[ 4_Control, control, nil ]
[ 5_EnvGen, control, [ 3_Control[0], 1.0, 0.0, 4_Control[0], 2, 0, 2, 1, -99, 1.0, 1.0, 3, 0, 0.0, 1.0, 3, 0 ] ]
[ 6_*, audio, [ 1_LPF, 5_EnvGen ] ]
[ 7_*, audio, [ 2_LPF, 5_EnvGen ] ]
[ 8_Control, scalar, nil ]
[ 9_Out, audio, [ 8_Control[0], 6_*, 7_* ] ]
*/

Frankly looking at the debug message I get from that (bpv: nil false nil nil), it probably worked correctly even without my patch in this case because it passed nil to BusPlug.value. Even without my patch, only a few cases were broken when non-nil was passed, like with CombN.

The reason why the expansion doesn’t work outside SynthDef compile context is:

+ BusPlug {
	value { | something |
		var n;
		if(UGen.buildSynthDef.isNil) { ^this }; // only return when in ugen graph.
		// otherwise expands by calling .ar or .kr on itself

So

Ndef(\fi1, { LPF.ar(Ndef(\ar2).value.postln, 1000) }) 
// posts: [ an OutputProxy, an OutputProxy ]

LPF.ar(Ndef(\ar2).value.postln, 1000)
// posts: Ndef('ar2')

You were probably expecting that LPF(Ndef(... to expand to OutputProxies array, like most UGens do even outside SynthDef build context. However, because the AbstractFunction business happens via .value, and Ndefs subclass that, it’s tricky to decide when to expand them and when not. I’m guessing there are some contexts when .value gets called on Ndefs and it’s expected they just return themselves instead of an OutputProxy array. I don’t know what the latter contexts are precisely, ProxySpace maybe.

That transformer (which looks fairly useful) could probably be written in SC itself…

Very likely!

…I’m guessing you’ve implemented the SynthDef compiler in Haskell using some kind of monad to collect the side effects like Control generation.

No, it’s just a plain value graph, controls are values, there are no side-effects.

There’s a value type for when you need to capture disconnected subgraphs.

You write something like:

Mrg(SinOsc(…), LocalOut(…))

instead of:

LocalOut(…); SinOsc(…)

Mrg captures the disconnected right hand side, and makes the left hand side the value of the expression.

Graphs are sorted by edges, and aside from some obscure-ish buffer sharing optimisations, plain graphs work fine.

(PV_Copy is an exception, you need a variant, say PV_Split, that outputs both buffers…)

The control model is very simple I’m afraid.

1 Like

I wasn’t. My point was that LPF.ar(Ndef(\something)...) is incorrect usage when it’s missing the rate method to convert it into a UGen array.

hjh

Well, it works automagically in SynthDef context. To explain slightly more, it turned out that

Ndef(\jc, Ndef(\ar2))

has exactly the same call chain from a point onwards as if you had written

Ndef(\jc, { Ndef(\ar2).value })

Which means the latter is a function source to be eval’d in ProxySynthDef context, which due to the “magic” of BusPlug.value does

Ndef(\jc, { Ndef(\ar2).ar })

So, even the deceptively simple “data copy” Ndef(\jc, Ndef(\ar2)) is treated as “identity math expression” and actually implemented by compiling a new ProxySynthDef that does the copying.

I wouldn’t mind an effort to make it work everywhere. But my understanding had been that, if you want (a) UGen(s) from a NodeProxy, best practice was to ask it for its ar or kr version. Then you know what you’re going to get. Otherwise, you might get lucky but you might not.

Returning to the top of the thread, Ndef(\f).midicps got lucky because of the unary math operator, but Comb.ar did not.

If behavior wasn’t carefully defined and it happens to work in one context, that’s no guarantee of it working in every context.

If it’s changed so that a NodeProxy fed into a UGen’s input could expand, that would be great!

hjh

Because of the bug I analyzed in post 11, which I then patched in post 12. So that automagic works for me for CombN too, now. :yellow_heart:

Apparently, I’m not allowed anymore edits, LOL; scsynth.org ate my the last one. I wanted to add here for precision that Ndef(\jc, { Ndef(\ar2).ar }) is actually more like

Ndef(\jc, { Ndef(\ar2).value( Ndef(\jc).bus.numChannels ) })

which becomes

Ndef(\jc, { Ndef(\ar2).ar( Ndef(\jc).bus.numChannels ) })

The arg passed to ar from value was incorrect for some UGens before my patch due to various call paths and the lack of a single unified test for UGens in that BusPlug.value. With the patch in post #12 above I’ve added a unified test for UGens (and UGen classes) at the “receiving end” there in BusPlug.value, so that should fix all UGen cases, meaning they all default to auto-sizing to the full output bus of the receiver, as what happens when .ar(nil) is called. (That works because nil is passed to InBus for the number of channels and the latter will build a full bus in that case–a bit of undocumented behavior, in the help at least. That InBus then gets turned in the full-sized array of OutputProxies in SynthDef contexts (including .ProxySynthDef.) That was a mouthful. :tongue: )