Limiting the polyphony or killing notes in the PolyPerc SC engine on Norns

I can’t seem to find any info on limiting the polyphony of the SC engines on norns. I’ve seem some mentions of an engine.notesOff() but I can’t seem to get that to work with either note numbers or Hz.

I guess I could keep track of the number of notes played and limit that, but I wouldn’t be able to have current note priority with that method.

Any pointers?

the norns SC engine system enforces very little design isomorphism between engines. different polyphonic engines have different strategies for managing polyphony: MollyThePoly and PolySub keep track of the instantiation of new synths to perform voice stealing and limiting; others use a paraphonic approach (simpler); and PolyPerc, which allocates one-shot, self-freeing synths, uses the simplest strategy: to basically do nothing.

(aside: i believe that PolyPerc represents some of @tehn’s first forays into supercollider coding, and is a good example of the most basic kind of polyphonic engine to get you started.)

(there is no notesOff command for PolyPerc, not sure where you saw that. perhaps as a proposal for a common design pattern that should be enforced/provided for polyphonic engines in future.)

give me a few minutes and i will show a couple of ways to extend PolyPerc to limit polyphony. since voices are self-freeing and are not referenced after creation, voice-stealing will be difficult to implement; but implemtning notesOff and simply limiting new voice counts is quite simple.

4 Likes

here is PolyPerc with an additional command that summarily stops all playing notes

// CroneEngine_PolyPerc
// pulse wave with perc envelopes, triggered on freq
Engine_PolyPerc : CroneEngine {
	var pg;
    var amp=0.3;
    var release=0.5;
    var pw=0.5;
    var cutoff=1000;
    var gain=2;
    var pan = 0;

	*new { arg context, doneCallback;
		^super.new(context, doneCallback);
	}

	alloc {
		pg = ParGroup.tail(context.xg);
    SynthDef("PolyPerc", {
			arg out, freq = 440, pw=pw, amp=amp, cutoff=cutoff, gain=gain, release=release, pan=pan;
			var snd = Pulse.ar(freq, pw);
			var filt = MoogFF.ar(snd,cutoff,gain);
			var env = Env.perc(level: amp, releaseTime: release).kr(2);
			Out.ar(out, Pan2.ar((filt*env), pan));
		}).add;

		this.addCommand("hz", "f", { arg msg;
			var val = msg[1];
      Synth("PolyPerc", [\out, context.out_b, \freq,val,\pw,pw,\amp,amp,\cutoff,cutoff,\gain,gain,\release,release,\pan,pan], target:pg);
		});

		this.addCommand("amp", "f", { arg msg;
			amp = msg[1];
		});

		this.addCommand("pw", "f", { arg msg;
			pw = msg[1];
		});

		this.addCommand("release", "f", { arg msg;
			release = msg[1];
		});

		this.addCommand("cutoff", "f", { arg msg;
			cutoff = msg[1];
		});

		this.addCommand("gain", "f", { arg msg;
			gain = msg[1];
		});

		this.addCommand("pan", "f", { arg msg;
		  postln("pan: " ++ msg[1]);
			pan = msg[1];
		});

		this.addCommand("freeAllNotes", "", {
			// immediately free all currently sustaining notes.
			// this simply ignores any currently sustaining envelopes, so will cause clicks.
			pg.freeAll;
		});

	}
}

TODO / exercise for reader: add a second ASR envelope and gate parameter to the synthdef, to use when forcibly freeing a voice. this will avoid clicks.

3 Likes

here is a test sclang script for that change. (i am running this on a macbook with norns classes installed.)

e = Engine_PolyPerc.new(Crone.context, {});

// helper to send a command to the engine with one argument.
~com = { arg name, value; NetAddr.localAddr.sendMsg(("/command/" ++ name).asSymbol, value); };


// set very very long release time
~com.value('release', 60);


// make 10 notes
Routine {

	10.do {
		arg i;
		var hz = 330 * ((i*7)%5 + 1)/((i*9)%7 + 1);
		var t = 0.2 * ((i*8)%7 + 1)/((i*11)%9 + 1);
		[t, hz].postln;
		~com.value('hz', hz);
		t.wait;
	};

	// 4 seconds after last note, release all notes; summarily stops everything
	4.wait;
	~com.value('freeAllNotes');

}.play;
2 Likes

here is a more extensive modification to PolyPerc (now renamed), wherein the current number of playing voices is queried before starting a new voice. if voice count is > 16, new note is ignored. (the max voice count is a classvar, which is like a static class member in C++.)

(because of the asynchronous and split-process structure of supercollider language/audio server, this is a little more complex than one might hope for - it requires setting up a query/response pattern within the engine.)

Engine_PolyPercLimited.sc

// CroneEngine_PolyPercLimited
// pulse wave with perc envelopes, triggered on freq
// now with voice limit
Engine_PolyPercLimited : CroneEngine {
	classvar maxVoices = 16;
	var server;
	var pg;
	var amp=0.3;
	var release=0.5;
	var pw=0.5;
	var cutoff=1000;
	var gain=2;
	var pan = 0;

	// to limit polyphony, we'll have to query the server for node count.
	// this is async, so we need to store requested hz value and set up a responder.
	var hz;
	var queryTreeResponder;


	*new { arg context, doneCallback;
		^super.new(context, doneCallback);
	}

	playNote { arg hzArg;
		hz = hzArg;
		// send a queryTree message to the server; spawn a synth on response
		server.sendMsg('/g_queryTree', pg.nodeID);
	}

	alloc {
		server = Crone.server;

		pg = ParGroup.tail(context.xg);

		// set up a responder for node tree queries
		queryTreeResponder = OSCFunc({ arg msg;
			var numChildren = msg[3];
			postln(numChildren);
			if (numChildren < maxVoices, {
				var synth;
				synth = Synth("PolyPerc", [
					\out, 0,
					\freq, hz,
					\pw, pw,
					\amp, amp,
					\cutoff, cutoff,
					\gain, gain,
					\release, release,
					\pan, pan

				], target:pg);
			}, {
				postln("too many notes!");
			});
		}, '/g_queryTree.reply');

		SynthDef("PolyPerc", {
			arg out, freq = 440, pw=pw, amp=amp, cutoff=cutoff, gain=gain, release=release, pan=pan;
			var snd = Pulse.ar(freq, pw);
			var filt = MoogFF.ar(snd,cutoff,gain);
			var env = Env.perc(level: amp, releaseTime: release).kr(2);
			Out.ar(out, Pan2.ar((filt*env), pan));
		}).add;

		this.addCommand("hz", "f", { arg msg;
			var val = msg[1];
			this.playNote(val);
		});

		this.addCommand("amp", "f", { arg msg;
			amp = msg[1];
		});

		this.addCommand("pw", "f", { arg msg;
			pw = msg[1];
		});

		this.addCommand("release", "f", { arg msg;
			release = msg[1];
		});

		this.addCommand("cutoff", "f", { arg msg;
			cutoff = msg[1];
		});

		this.addCommand("gain", "f", { arg msg;
			gain = msg[1];
		});

		this.addCommand("pan", "f", { arg msg;
			postln("pan: " ++ msg[1]);
			pan = msg[1];
		});

		this.addCommand("freeAllNotes", "", {
			// immediately free all currently sustaining notes.
			// this simply ignores any currently sustaining envelopes, so will cause clicks.
			pg.freeAll;
		});

	}
}

some debug statements have been added to the engine and it has been refactored a bit.


test script. similar, but slightly slower release and we now attempt to create a fast sequence of 10k notes.

polyperc_test_2.scd

e = Engine_PolyPercLimited.new(Crone.context, {});

// helper to send a command to the engine with one argument.
~com = { arg name, value; NetAddr.localAddr.sendMsg(("/command/" ++ name).asSymbol, value); };

// set medium longish release time
~com.value('release', 8);

// make a huge number of notes
// engine changes should limit polyphony
Routine {
	10000.do {
		arg i;
		var hz = 330 * ((i*7)%5 + 1)/((i*9)%7 + 1);
		var t = 0.2 * ((i*8)%7 + 1)/((i*11)%9 + 1);
		[t, hz].postln;
		~com.value('hz', hz);
		t.wait;
	};
}.play;

notice that the pattern plays the first 16 notes without pause, and subsequent note requests are ignored until older voices have died out.


probably the next step would be to add a non-periodic voice-count poll from the engine back to lua, allowing the script to perform voice allocation / ignore logic.

another approach would be to completely rearchitect PolyPerc to be paraphonic: basically, have a fixed number of static voice instances that are retriggered, and have a parallel set of commands that accepts voice ID in addition to new note frequency / other params.

4 Likes

These are great! Thanks you. I need to take a closer look at how I can implement these.

I’ll check back once I wrap my head around this.

EDIT:

I bumped the max notes up to 32 and it works beautifully in my app!

Thanks so much! I’ll throw a credit in the docs!

1 Like

cool. glad that works for you.

i did notice some early release behavior in the test script that seems odd. might poke at this again and see if there is a less clumsy way to do it using NodeWatcher or something.

i’d also recommend including this customized PolyPerc as a .sc file in your script’s /lib folder. to avoid a SC startup error, it should have a different class name than Engine_PolyPerc. then you could also play around with other aspects of the engine and make it more your own.

BTW: these conversations are useful to me as we move slowly ahead with revising the SC engine architecture for next major version, and consider different common patterns that could be provided as off-the-shelf engine behaviors.

3 Likes

Do you think it would be possible to implement a new note priority or note stealing method? Polyperc would probably need to be rewritten to ID each note sent to the engine and keep track of priority I assume.

yes, this would be an application of the “paraphonic” structure where there are just 32 static instances of the synth. this could lead to different ways of setting up the interaction between engine and script, e.g.

1. allocation / management on engine side

“new note” command takes no voice argument. allocation / stealing are implemented in the engine. heuristics could be selectable or fixed:

  • round robin
  • last in, first out
  • ignore new voices if all instances are running.

2. allocation / managment on lua side

“new note” command accepts an explicit voice index. since voices are allocated up front, there is no risk of exceeding voice count. script must decide when to explicitly free notes.

since polyperc is a one-shot synth, with no explicit gate argument, it would need to have appropriate retriggering behavior. (or use a “floating” instance, or something…)


anyways, i’d be curious to hear from you or anyone else, what would be an ideal interface between script and engine, for 1-shot and sustained poly synths.

i’m generally feeling that the dynamic management approach of PolySub and Polly is overcomplex, and paraphonic should be the default design; and i tend to think it’s just cleaner for voice management to happen on lua side to avoid need for crazy IPC. (b/c if engine manages allocation, eventually someone will want to have script query actual realtime running status of synth voices, and this requires 2 layers of async communication. that is doable, but it’s ugly and multiplies the OSC traffic requirement.)

its easy enough to provide voice assignment module as lua abstraction.


anyways with all that said, i’d be curious to just see a specific example of how you would expect voice-priority management to “naturally” work from the script’s perspective. then i can suggest the cleanest way to implement that.

[apologies for all the editing oopses]

2 Likes

It looks like there may be some kind of issue with the SC script that you posted above. It works most of the time, but on some instances, the audio meters get pegged in the mixer page, and I’m unable to get audio out of the script. Hmmm …

I might just drop the standard Polyperc back into my script for the initial release.

As far as use in my script, I have 6 monophonic sequences that are playing back at up 1/32 note rate. This overloads the CPU if all sequences are spitting out notes to the sequencer that rapidly.

In theory, I would like to be able to set the polyphony at say 16-32 notes and have the newest note received cancel out the oldest of the group. This seems to be the standard across the limited polyphony hardware synths I own.

1 Like

you mean using the modified engine i posted above, with a norns script?

i didn’t test it on norns so can’t make a guarantee. will revisit. but i would first eliminate any confounding factors - your script seems pretty complex and maybe you are sending pitch or cutoff values that produce audio badness in the synth.

ok, your goals are clear. you don’t want to manage voices from lua, and you want round-robin voice stealing. i’ll mock this up on norns with a lua script. (settling in for a rare norns dev session tonight.)


oh, the other thing is that you should probably remove the debug statements.

1 Like

Please don’t go down any rabbit holes on my account. I appreciate all of your insight and help thus far. I just started with Norns and lua few weeks ago and have much to learn.

1 Like

oh heck, it’s not a problem. been meaning to post a somewhat extended PolyPerc. may as well make it a little more robust/inotentional w/r/t polyphony.

will see where i get with it this session. few other things to look at first

5 Likes

gave this some more proper attention over the weekend.

posted this engine, which includes this class.

2 Likes

Responded over there … and thank you!