transit authority
norns engine study 3: using audio busses to build FX chains and aux sends, and using polls to send data from SuperCollider back to Lua
SuperCollider is a free and open-source platform for making sound, which powers the synthesis layer of norns. Many norns scripts are a combination of SuperCollider (where a synthesis engine is defined) and Lua (where the hardware and UI interactions are defined).
This study extends the topics covered in rude mechanicals, which outlines starting points for engine development on norns using SuperCollider, and skilled labor, which covers polyphony and realtime timbral changes. As with each of those studies, we’ll assume that you’ve already got a bit of familiarity with SuperCollider – if not, be sure to check out learning SuperCollider for helpful resources and come back here after some experimentation.
sections
preparation
If you haven’t already, please download SuperCollider on your primary non-norns computer. Though we’ll eventually end up at norns, being able to quickly execute snippets of SuperCollider code during the experimentation stages will provide the foundation necessary for engine construction.
Please note that if you’re new to SuperCollider, you’ll likely make some unexpectedly loud / sharp sounds. Nathan Ho has some fantastic tips on his site, specifically for “Levels management and volume safety”:
A quick summary:
- If you are on macOS, upgrade to at least SC 3.12 right dang now and add Server.default.options.safetyClipThreshold = 1 to your startup file so the audio output clips.
- Work at low levels in your system’s volume control, but high levels in SC. If your audio is at a comfortable level and peaks in SC at -6 dBFS, the loudest sounds can only peak at 6 dB louder than that, so a synthesis accident can be startling, but unlikely to be dangerous.
- As a corollary: if SC produces a quiet signal, do not turn up the volume using your computer’s volume control! Instead, turn it up in SC.
- Use a Limiter on the master bus.
- Type in gain amounts as e.g. * -60.dbamp instead of * 0.001.
hidden text
To keep things relatively navigable, we’ve compressed big chunks of code into the following interaction:
<~~ click to expand
Hello! This is how big chunks of code will be presented throughout the study.
Please be sure to expand them as you come across them, otherwise the study will feel like it’s missing a lot of crucial information.
where we were, where we’ll go
The Moonshine engine we extended in skilled labor built on our understanding of SuperCollider’s relationship to norns scripting by:
- breaking our SuperCollider files into separate class and CroneEngine files
- using Groups in SuperCollider to manage realtime parameter changes to a playing voice
- modeling an approach to polyphony and voice distribution in SuperCollider
- structuring a template for Lua parameters
- gluing it all together into an example norns script
This third study will turn its focus from the core synth voices toward working with FX by:
- establishing Busses to route signals from one place in the Server to another
- being explicit about the ordering of synths and busses on the Server, and being purposeful about syncing those changes
- using polls to send data from SuperCollider back to norns
part 1: shuttling signals
Let’s first demonstrate how Busses can be used to shuttle signals around the Server.
note: as you read, it’ll likely be helpful to pull up SuperCollider’s documentation on Busses.
formatting
In this study, we’ll demonstrate a slightly different formatting for arguments than the previous two chapters.
In rude mechanicals and skilled labor, we established all of our arguments at the start of the SynthDef. For example:
SynthDef("source", {
arg hz = 330, outMain = 0, panMain = 0, levelMain = 1;
var snd = LPF.ar(Saw.ar(hz), hz*4);
snd = snd * LagUD.ar(Impulse.ar(2), 0, 0.5);
Out.ar(outMain, Pan2.ar(snd, panMain) * levelMain);
}).add;
In this study, we’ll showcase a slightly different but totally-synonymous argument formatting, using .kr
(signifying a continuous control rate signal):
SynthDef("source", {
var snd = LPF.ar(Saw.ar(\hz.kr(330)), (\hz.kr(330)*8).clip(20,20000);
snd = snd * LagUD.ar(Impulse.ar(2), 0, 0.5);
Out.ar(\outMain.kr(0), Pan2.ar(snd, \panMain.kr(0)) * \levelMain.kr(1));
}).add;
This .kr
formatting quickly draws attention to all the places where an argument gets utilized in the code.
building some sends
In this example, we’ll send a source sound to two FX sends and a main output.
SC Bus exercise 1: building some sends
// SC Bus exercise 1: building some sends
// CMD + ENTER / CTRL + ENTER from here to run the code
(
// create a Dictionary of synths:
~synths = Dictionary.new;
// create a Dictionary of audio busses:
~busses = Dictionary.new;
~busses[\mainOut] = Bus.audio(server: Server.default, numChannels: 2);
~busses[\delaySend] = Bus.audio(server: Server.default, numChannels: 2);
~busses[\reverbSend] = Bus.audio(server: Server.default, numChannels: 2);
// alias our Server:
s = Server.default;
// make a Routine, so that we can sync changes to the Server
Routine{
// define our source sound:
SynthDef("source", {
var snd = LPF.ar(Saw.ar(\hz.kr(330)), (\hz.kr(330)*8).clip(20,20000));
snd = snd * LagUD.ar(Impulse.ar(2), 0, 2);
Out.ar(\outMain.kr, (snd * \levelMain.kr(1)).dup); // .dup = send stereo signal
Out.ar(\outSend1.kr, (snd * \levelSend1.kr(0)).dup);
Out.ar(\outSend2.kr, (snd * \levelSend2.kr(0)).dup);
}).add;
// define our delay:
SynthDef("delay", {
Out.ar(\out.kr, CombC.ar(In.ar(\in.kr, 2),1.0,0.2,3.2));
}).add;
// define our reverb:
SynthDef("reverb", {
var sig = In.ar(\in.kr, 2);
Out.ar(\out.kr, FreeVerb2.ar(sig[0], sig[1], 1.0, 0.7, 0.2, 1.5));
}).add;
// define our main output:
SynthDef("main", {
Out.ar(\out.kr, In.ar(\in.kr, 2));
}).add;
// we sync the Server here so that the common SynthDefs above
// are present on the Server when requested below
s.sync;
// build our source and pass it arguments:
~synths[\source] = Synth.new("source", [
\outMain, ~busses[\mainOut], // connecting to the mainOut bus
\outSend1, ~busses[\delaySend], // connecting to the delaySend bus
\outSend2, ~busses[\reverbSend] // connecting to the reverbSend bus
]);
// build our delay AFTER our source
// and pass it arguments:
~synths[\delay] = Synth.after(~synths[\source], "delay", [
\in, ~busses[\delaySend], // input = the delaySend bus
\out, ~busses[\mainOut] // output = the mainOut bus
]);
// build our reverb AFTER our delay
// and pass it arguments:
~synths[\reverb] = Synth.after(~synths[\delay], "reverb", [
\in, ~busses[\reverbSend], // input = the reverbSend bus
\out, ~busses[\mainOut] // output = the mainOut bus
]);
// build our main output AFTER our reverb
// and pass it arguments:
~synths[\main] = Synth.after(~synths[\reverb], "main", [
\in, ~busses[\mainOut], // input = the mainOut bus
\out, 0 // output = the default output device
]);
}.play;
)
After executing the code, you should hear a regular plucky note at 330 Hz. We can control its send levels via these commands:
// send to main:
~synths[\source].set(\levelMain,1);
// send to delay:
~synths[\source].set(\levelSend1,1);
// send to reverb:
~synths[\source].set(\levelSend2,1);
// don't send to main:
~synths[\source].set(\levelMain,0);
// don't send to delay:
~synths[\source].set(\levelSend1,0);
// don't send to reverb:
~synths[\source].set(\levelSend2,0);
Class file
It’d be nice to extend our sketch by:
- building it into a full Class file
- using Groups to organize our synths / nodes
- adding panning to each send (dry source, delay, reverb)
- establishing a few commands for control over level, panning, and the Hertz of the played note
Of note in this example:
- rather than force our source sound into stereo with
.dup
(as in our previous example), we’ll simply feed it into a two channel panning UGen - we’ll use
s.sync
to make sure our common SynthDefs are present on the Server when requested - we’ll use a Group (assigned var
g
) to control the order of execution, which will include specifyingtarget:g
andaddAction
’s for each of our synths. If we look at our Node Tree when our synth is playing, we’ll see:sourceBlip
(main synth)patch_pan
(reverb send)reverb
(reverb synth)patch_pan
(delay send)delay
(delay synth)patch_pan
(dry send)patch_main
(final stage output)
- instead of adding a panning control to each stage’s synth, we’ll use panning to specify the stereo placement of the source as its sent into each FX stage
FXBusDemo.sc
// SC Bus exercise 2
// busses in a class with panning and commands
FXBusDemo {
var <synths;
var <busses;
var <g;
*new {
^super.new.init();
}
init {
var s = Server.default;
synths = Dictionary.new;
busses = Dictionary.new;
Routine {
// in this demo, source bus is mono / FX are stereo:
busses[\source] = Bus.audio(s, 1);
busses[\main_out] = Bus.audio(s, 2);
busses[\reverb_send] = Bus.audio(s, 2);
busses[\delay_send] = Bus.audio(s, 2);
// define our patch synths, to control stereo field:
SynthDef.new(\patch_pan, {
Out.ar(\out.kr, Pan2.ar(In.ar(\in.kr), \pan.kr(0), \level.kr(1)));
}).send(s);
SynthDef.new(\patch_main, {
Out.ar(\out.kr, In.ar(\in.kr, 2) * \level.kr(1));
}).send(s);
// define our main synth:
SynthDef.new(\sourceBlip, {
var snd = LPF.ar(Saw.ar(\hz.kr(330)), (\hz.kr(330)*8).clip(20,20000));
snd = snd * LagUD.ar(Impulse.ar(2), 0, 2);
Out.ar(\out.kr, snd * \level.kr(0.5));
}).send(s);
// we'll sync here so that the common SynthDefs above
// are present on the Server when requested:
s.sync;
// add a group to order our synths / nodes:
g = Group.new(s);
// instantiate our main synth:
synths[\source] = Synth.new(\sourceBlip,
target:g, addAction:\addToHead, args:[
\out, busses[\source]
]);
synths[\dry] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\main_out],
\level, 1.0
]);
synths[\delay_send] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\delay_send],
\level, 0.0
]);
synths[\reverb_send] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\reverb_send],
\level, 0.0
]);
synths[\delay] = SynthDef.new(\delay, {
arg in, out, level=1;
Out.ar(out, DelayC.ar(In.ar(in, 2), 1.0, 0.2, level));
}).play(target:synths[\delay_send], addAction:\addAfter, args:[
\in, busses[\delay_send], \out, busses[\main_out]
]);
synths[\reverb] = SynthDef.new(\reverb, {
arg in, out, level=1;
Out.ar(out, FreeVerb.ar(In.ar(in, 2), 1.0, 0.9, 0.1, level));
}).play(target:synths[\reverb_send], addAction:\addAfter, args:[
\in, busses[\reverb_send], \out, busses[\main_out]
]);
synths[\main_out] = Synth.new(\patch_main,
target:g, addAction:\addToTail, args: [
\in, busses[\main_out], \out, 0
]);
}.play;
}
setLevel { arg key, val;
synths[key].set(\level, val);
}
setPan { arg key, val;
synths[key].set(\pan, val);
}
setHz { arg val;
synths[\source].set(\hz, val);
}
// IMPORTANT: free Server resources and nodes when done!
free {
g.free;
busses.do({arg bus; bus.free;});
}
}
To move forward, we’ll need to save this Class definition in a place on our non-norns computer where SuperCollider can find it. We’ve covered this process in skilled labor, so we won’t repeat those steps here.
Now, to have your class definition useable in SuperCollider, recompile the class library via Language > Recompile Class Library
.
instantiate the class
When the library recompiles, we should be able to instantiate the FXBusDemo
Class and its associated methods like any other class in SuperCollider. To try it out, open a blank SuperCollider file and type and live-execute (Ctrl-Enter on Windows/Linux or CMD-RETURN on macOS) the following lines:
// take note of the server nodes that print:
s.queryAllNodes;
// execute this line to start up the FXBusDemo:
x = FXBusDemo.new();
// take another look at the server:
s.queryAllNodes;
// you should see a group present with 'sourceBlip', 'patch_pan', etc
// execute one cluster at a time:
x.setLevel(\delay_send,0.6);
x.setLevel(\reverb_send,0.6);
x.setPan(\dry,1);
x.setPan(\delay_send,-1);
x.setLevel(\dry, 0);
x.setPan(\reverb_send,1);
x.setHz(330/3);
x.setHz(330*0.75);
side-quest: adding a DJ-style isolator
nb. many thanks to Ezra for their expertise with and examples for this topic!
Adventures in reproducing hardware are very rewarding in SuperCollider – they allow us to concretize our understanding of the devices we’d like to model and expand our understanding of DSP theory. So, before we move into polls, let’s round out our final audio stage with a DJ-style isolator.
An isolator is a very handy tool for creative mixing. It allows you to selectively cut or boost “low”, “mid” and “high” bands within an input signal. Most importantly, it has a flat response – when all three bands are at 0dB, the isolator should not color the input signal.
To keep things simple, we’ll use LPF
and HPF
, which are non-resonant 2nd-order Butterworth filters. However, if we naively mix a lowpass and highpass Butterworth at the same FC, we get a +3db bump at the filter cutoff. To avoid this, we’ll cascade two 2nd order Butterworths – this gets us a Linkwitz-Riley filter, which is a standard building block for crossovers. So, we’ll take a lowpass and highpass L-R filter at same frequency, with a mid section, and their sum will have a flat magnitude response.
Here’s an example of this architecture:
// white noise source, watch your ears!
(
z = {
var src = WhiteNoise.ar;
var fc1 = \fc1.kr(600);
var fc2 = \fc2.kr(1800);
var ampLo = \ampLo.kr(1);
var ampMid = \ampMid.kr(1);
var ampHi = \ampHi.kr(1);
var lo = LPF.ar(LPF.ar(src, fc1), fc1) * ampLo;
var mid = HPF.ar(HPF.ar(LPF.ar(LPF.ar(src, fc2), fc2), fc1), fc1) * ampMid;
var hi = HPF.ar(HPF.ar(src, fc2), fc2) * ampHi;
Out.ar(\out.kr(0), ((lo + mid + hi) * \amp.kr(0.2)).dup);
}.play(s, \addToTail);
)
// controls:
z.set(\ampLo,0);
z.set(\ampMid,0);
z.set(\ampHi,0);
z.set(\ampLo,1);
z.set(\ampMid,1);
z.set(\ampHi,1);
To add this functionality to FXBusDemo.sc
, we’ll adjust our \patch_main
SynthDef (line 31):
SynthDef.new(\patch_main, {
var src = In.ar(\in.kr, 2);
var fc1 = \fc1.kr(600);
var fc2 = \fc2.kr(1800);
var ampLo = \ampLo.kr(1);
var ampMid = \ampMid.kr(1);
var ampHi = \ampHi.kr(1);
var lo = LPF.ar(LPF.ar(src, fc1), fc1) * ampLo;
var mid = HPF.ar(HPF.ar(LPF.ar(LPF.ar(src, fc2), fc2), fc1), fc1) * ampMid;
var hi = HPF.ar(HPF.ar(src, fc2), fc2) * ampHi;
var mix = lo + mid + hi;
Out.ar(\out.kr, mix * \level.kr(1));
}).send(s);
And to control it, we’ll add a setMain
command (after setHz
):
setMain { arg key, val;
synths[\main_out].set(key, val);
}
Our new FXBusDemo.sc
// SC Bus exercise 3
// adding an isolator
FXBusDemo {
var <synths;
var <busses;
var <g;
// NEW: add an optional 'hz' argument to control
// the pitch of the synth at instantiation:
*new {
arg hz = 330;
^super.new.init(hz);
}
init {
// NEW: add an optional 'hz' argument to control
// the pitch of the synth at instantiation:
arg hz = 330;
var s = Server.default;
synths = Dictionary.new;
busses = Dictionary.new;
Routine {
// in this demo, source bus is mono / FX are stereo:
busses[\source] = Bus.audio(s, 1);
busses[\main_out] = Bus.audio(s, 2);
busses[\reverb_send] = Bus.audio(s, 2);
busses[\delay_send] = Bus.audio(s, 2);
// define our patch synths, to control stereo field:
SynthDef.new(\patch_pan, {
Out.ar(\out.kr, Pan2.ar(In.ar(\in.kr), \pan.kr(0), \level.kr(1)));
}).send(s);
// NEW: build an isolator into our main output:
SynthDef.new(\patch_main, {
var src = In.ar(\in.kr, 2);
var fc1 = \fc1.kr(600);
var fc2 = \fc2.kr(1800);
var ampLo = \ampLo.kr(1);
var ampMid = \ampMid.kr(1);
var ampHi = \ampHi.kr(1);
var lo = LPF.ar(LPF.ar(src, fc1), fc1) * ampLo;
var mid = HPF.ar(HPF.ar(LPF.ar(LPF.ar(src, fc2), fc2), fc1), fc1) * ampMid;
var hi = HPF.ar(HPF.ar(src, fc2), fc2) * ampHi;
var mix = lo + mid + hi;
Out.ar(\out.kr, mix * \level.kr(1));
}).send(s);
// define our main synth:
SynthDef.new(\sourceBlip, {
var snd = LPF.ar(Saw.ar(\hz.kr(hz)), (\hz.kr(hz)*8).clip(20,20000));
snd = snd * LagUD.ar(Impulse.ar(2), 0, 2);
Out.ar(\out.kr, snd * \level.kr(0.5));
}).send(s);
// we'll sync here so that the common SynthDefs above
// are present on the Server when requested:
s.sync;
// add a group to order our synths / nodes:
g = Group.new(s);
// instantiate our main synth:
synths[\source] = Synth.new(\sourceBlip,
target:g, addAction:\addToHead, args:[
\out, busses[\source]
]);
synths[\dry] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\main_out],
\level, 1.0
]);
synths[\delay_send] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\delay_send],
\level, 0.0
]);
synths[\reverb_send] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\reverb_send],
\level, 0.0
]);
synths[\delay] = SynthDef.new(\delay, {
arg in, out, level=1;
Out.ar(out, DelayC.ar(In.ar(in, 2), 1.0, 0.2, level));
}).play(target:synths[\delay_send], addAction:\addAfter, args:[
\in, busses[\delay_send], \out, busses[\main_out]
]);
synths[\reverb] = SynthDef.new(\reverb, {
arg in, out, level=1;
Out.ar(out, FreeVerb.ar(In.ar(in, 2), 1.0, 0.9, 0.1, level));
}).play(target:synths[\reverb_send], addAction:\addAfter, args:[
\in, busses[\reverb_send], \out, busses[\main_out]
]);
synths[\main_out] = Synth.new(\patch_main,
target:g, addAction:\addToTail, args: [
\in, busses[\main_out], \out, 0
]);
}.play;
}
setLevel { arg key, val;
synths[key].set(\level, val);
}
setPan { arg key, val;
synths[key].set(\pan, val);
}
setHz { arg val;
synths[\source].set(\hz, val);
}
// NEW: add controls for our main_out synth:
setMain { arg key, val;
synths[\main_out].set(key, val);
}
// IMPORTANT: free Server resources and nodes when done!
free {
g.free;
busses.do({arg bus; bus.free;});
}
}
Recompile the class library via Language > Recompile Class Library
and run:
// start the synth:
(
Routine{
x = FXBusDemo.new(330*0.75);
x.setLevel(\delay_send,0.6);
x.setLevel(\reverb_send,0.6);
x.setPan(\delay_send,-1);
x.setPan(\reverb_send,1);
}.play;
)
// control the isolator:
x.setMain(\ampLo,0);
x.setMain(\ampMid,0);
x.setMain(\ampHi,0);
x.setMain(\ampLo,1);
x.setMain(\ampMid,1);
x.setMain(\ampHi,1);
part 2: turn on the engine
As in our previous studies, we’ll now construct a norns engine from this SuperCollider Class file.
building our engine file
Just for review: a norns engine an instance of the built-in CroneEngine Class, which gives a standardized structure to shuttle meaningful commands and their values between Supercollider and Lua.
Engine_FXBusDemo.sc
Engine_FXBusDemo : CroneEngine {
// All norns engines follow the 'Engine_MySynthName' convention above
// NEW: select a variable to invoke FXBusDemo with
var kernel;
*new { arg context, doneCallback;
^super.new(context, doneCallback);
}
alloc { // allocate memory to the following:
// NEW: since FXBusDemo is now a supercollider Class,
// we can just construct an instance of it
kernel = FXBusDemo.new();
// NEW: build an 'engine.set_level(synth,val)' command
this.addCommand(\set_level, "sf", { arg msg;
var voiceKey = msg[1].asSymbol;
var freq = msg[2].asFloat;
kernel.setLevel(voiceKey,freq);
});
// NEW: build an 'engine.set_pan(synth,val)' command
this.addCommand(\set_pan, "sf", { arg msg;
var voiceKey = msg[1].asSymbol;
var freq = msg[2].asFloat;
kernel.setPan(voiceKey,freq);
});
// NEW: build an 'engine.set_hz(val)' command
this.addCommand(\set_hz, "f", { arg msg;
var freq = msg[1].asFloat;
kernel.setHz(freq);
});
// NEW: build an 'engine.set_main(key,val)' command
this.addCommand(\set_main, "sf", { arg msg;
var key = msg[1].asSymbol;
var val = msg[2].asFloat;
kernel.setMain(key,val);
});
} // alloc
// NEW: when the script releases the engine,
// free Server resources and nodes!
// IMPORTANT
free {
kernel.free;
} // free
} // CroneEngine
building our Lua file
Let’s create a script which engages our FXBusDemo
engine and builds some norns parameters to control it.
engine-study-3.lua
-- norns engine study 3: Busses
engine.name = "FXBusDemo"
local formatters = require("formatters")
function init()
local cs_amp = controlspec.new(0, 2, "lin", 0.001, 1, nil, 1 / 200)
local cs_fc1 = controlspec.new(20, 20000, "exp", 0, 600, "Hz")
local cs_fc2 = controlspec.new(20, 20000, "exp", 0, 1800, "Hz")
local cs_pan = controlspec.new(-1, 1, "lin", 0.001, 0, nil, 1 / 200)
local frm_percent = function(param)
return ((param:get() * 100) .. "%")
end
params:add({
type = "separator",
id = "levels_separator",
name = "levels",
})
params:add({
type = "control",
id = "dry_level",
name = "dry level",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_level("dry", x)
end,
})
params:add({
type = "control",
id = "delay_level",
name = "delay level",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_level("delay_send", x)
end,
})
params:add({
type = "control",
id = "reverb_level",
name = "reverb level",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_level("reverb_send", x)
end,
})
params:add({
type = "separator",
id = "pan_separator",
name = "panning",
})
params:add({
type = "control",
id = "dry_pan",
name = "dry",
controlspec = cs_pan,
formatter = formatters.bipolar_as_pan_widget,
action = function(x)
engine.set_pan("dry", x)
end,
})
params:add({
type = "control",
id = "delay_pan",
name = "delay",
controlspec = cs_pan,
formatter = formatters.bipolar_as_pan_widget,
action = function(x)
engine.set_pan("delay_send", x)
end,
})
params:add({
type = "control",
id = "reverb_pan",
name = "reverb",
controlspec = cs_pan,
formatter = formatters.bipolar_as_pan_widget,
action = function(x)
engine.set_pan("reverb_send", x)
end,
})
params:add({
type = "separator",
id = "main_eq_separator",
name = "main EQ",
})
params:add({
type = "control",
id = "eq_lo",
name = "lo",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_main("ampLo", x)
end,
})
params:add({
type = "control",
id = "eq_mid",
name = "mid",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_main("ampMid", x)
end,
})
params:add({
type = "control",
id = "eq_hi",
name = "hi",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_main("ampHi", x)
end,
})
params:add({
type = "control",
id = "fc1",
name = "lo freq",
controlspec = cs_fc1,
action = function(x)
engine.set_main("fc1", x)
end,
})
params:add({
type = "control",
id = "fc2",
name = "hi freq",
controlspec = cs_fc2,
action = function(x)
engine.set_main("fc2", x)
end,
})
params:set("delay_level", 0)
params:set("reverb_level", 0)
params:bang()
end
bring it all onto norns
Let’s get our SuperCollider and Lua files onto norns and test things out:
- connect to norns via one of the transfer methods
- create a folder inside of
code
namedengine-study-3
- create a folder inside of
engine-study-3
namedlib
- under
engine-study-3
, import a copy ofengine-study-3.lua
- under
engine-study-3/lib
, import copies ofFXBusDemo.sc
andEngine_FXBusDemo.sc
- once they’re imported, use
SYSTEM > RESTART
on norns to recompile its SuperCollider library and get the Lua layer synced with the new engine files!
Alright, take a break! You’ve done a lot of typing and experimenting for one sitting. We’ll see you back here soon.
part 3: polls
So far, our studies have all been focused on sending data from Lua to SuperCollider, using engine commands. We can also go the other direction, sending values from SuperCollider to Lua, using engine polls.
Polls report basic data from the audio subsystem, for use within a script. We can use them to trigger script events based on incoming amplitude, or capture the pitch and match it with a synth engine. See study 5 for additional examples.
For the purposes of this study, we’ll:
- measure the spectral brightness of our final stage output
- measure the amplitude of our final stage output
- send those values to Lua for visualization on the norns screen
FFT and amplitude
We’ll use SuperCollider’s Fast Fourier Transform (FFT) tools for analyzing our final signal for brightness, and the Amplitude
UGen to measure the main output level.
Returning to our FXBusDemo.sc
Class file, we’ll do the following:
- add an
\analysis
audio bus and send our main output to it - add a
\brightness
control bus - add an
\amp
control bus - build a SynthDef using the
SpecCentroid
UGen - send our brightness analysis to a Lua-accessible poll
Here’s our final FXBusDemo.sc
file
FXBusDemo.sc
// SC Bus exercise 4: polls
// sending brightness + amplitude analysis to Lua
FXBusDemo {
var <synths;
var <busses;
var <g;
*new {
^super.new.init();
}
init {
var s = Server.default;
synths = Dictionary.new;
busses = Dictionary.new;
Routine {
busses[\source] = Bus.audio(s, 1);
busses[\main_out] = Bus.audio(s, 2);
busses[\reverb_send] = Bus.audio(s, 2);
busses[\delay_send] = Bus.audio(s, 2);
// NEW: add an analysis audio bus:
busses[\analysis] = Bus.audio(s, 2);
// NEW: define control Busses for our Lua polls
busses[\brightness] = Bus.control(s);
busses[\amp] = Bus.control(s);
SynthDef.new(\patch_pan, {
Out.ar(\out.kr, Pan2.ar(In.ar(\in.kr), \pan.kr(0), \level.kr(1)));
}).send(s);
SynthDef.new(\patch_main, {
var src = In.ar(\in.kr, 2);
var fc1 = \fc1.kr(600);
var fc2 = \fc2.kr(1800);
var ampLo = \ampLo.kr(1);
var ampMid = \ampMid.kr(1);
var ampHi = \ampHi.kr(1);
var lo = LPF.ar(LPF.ar(src, fc1), fc1) * ampLo;
var mid = HPF.ar(HPF.ar(LPF.ar(LPF.ar(src, fc2), fc2), fc1), fc1) * ampMid;
var hi = HPF.ar(HPF.ar(src, fc2), fc2) * ampHi;
var mix = lo + mid + hi;
Out.ar(\out.kr, mix * \level.kr(1));
}).send(s);
// define our source synth:
SynthDef.new(\sourceBlip, {
var snd = LPF.ar(Saw.ar(\hz.kr(330)), \fchz.kr(800).clip(20,20000));
snd = snd * LagUD.ar(Impulse.ar(0.3), 0, 10);
Out.ar(\out.kr, snd * \level.kr(0.5));
}).send(s);
// we're syncing here so that the SynthDefs above
// is present on the Server when requested
s.sync;
// add a group to order our synths / nodes:
g = Group.new(s);
// instantiate our main synth:
synths[\source] = Synth.new(\sourceBlip,
target:g, addAction:\addToHead, args:[
\out, busses[\source]
]);
synths[\dry] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\main_out],
\level, 1.0
]);
synths[\delay_send] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\delay_send],
\level, 0.0
]);
synths[\reverb_send] = Synth.new(\patch_pan,
target:synths[\source], addAction:\addAfter, args:[
\in, busses[\source],
\out, busses[\reverb_send],
\level, 0.0
]);
synths[\delay] = SynthDef.new(\delay, {
arg in, out, dtime=0.2, level=1;
Out.ar(out, DelayC.ar(In.ar(in, 2), 1.0, dtime, level));
}).play(target:synths[\delay_send], addAction:\addAfter, args:[
\in, busses[\delay_send], \out, busses[\main_out]
]);
synths[\reverb] = SynthDef.new(\reverb, {
arg in, out, level=1;
Out.ar(out, FreeVerb.ar(In.ar(in, 2), 1.0, 0.9, 0.1, level));
}).play(target:synths[\reverb_send], addAction:\addAfter, args:[
\in, busses[\reverb_send], \out, busses[\main_out]
]);
synths[\main_out] = Synth.new(\patch_main,
target:g, addAction:\addToTail, args: [
// NEW: send main out to analysis bus
\in, busses[\main_out], \out, busses[\analysis]
]);
// NEW: build a brightness tracker
synths[\brightness] = SynthDef.new(\brightnessTracker, {
arg in, out, brightOut, ampOut;
var src = In.ar(in, 2);
var mixed = Mix.new([src[0],src[1]]);
var amp = Amplitude.kr(mixed);
var chain = FFT(LocalBuf(2048), mixed);
var brightness = SpecCentroid.kr(chain);
// send the output out:
Out.ar(out, src);
// send the brightness to a control bus:
Out.kr(brightOut, brightness);
// send the amp to a control bus:
Out.kr(ampOut, amp);
}).play(target:g, addAction:\addToTail, args: [
\in, busses[\analysis],
\out, 0,
\brightOut, busses[\brightness].index,
\ampOut, busses[\amp].index
]);
}.play;
}
setLevel { arg key, val;
synths[key].set(\level, val);
}
setPan { arg key, val;
synths[key].set(\pan, val);
}
// NEW: add controls for our source synth voice
setSynth { arg key, val;
synths[\source].set(key, val);
}
// NEW: add control for our delay time
setDelayTime{ arg val;
synths[\delay].set(\dtime, val.min(1));
}
setMain { arg key, val;
synths[\main_out].set(key, val);
}
// IMPORTANT: free Server resources and nodes when done!
free {
g.free;
busses.do({arg bus; bus.free;});
}
}
adding polls to our engine
Returning to our Engine_FXBusDemo.sc
file, we’ll do the following:
- use
this.addPoll
to add our brightness and amplitude polls (see thepoll
extended reference for additional information) - use SuperCollider’s
.getSynchronous
method to grab the value of thebusses[\brightness]
andbusses[\amp]
control busses
Here’s our final Engine_FXBusDemo.sc
file
Engine_FXBusDemo.sc
Engine_FXBusDemo : CroneEngine {
// All norns engines follow the 'Engine_MySynthName' convention above
var kernel;
*new { arg context, doneCallback;
^super.new(context, doneCallback);
}
alloc { // allocate memory to the following:
kernel = FXBusDemo.new(Crone.server);
this.addCommand(\set_level, "sf", { arg msg;
var voiceKey = msg[1].asSymbol;
var val = msg[2].asFloat;
kernel.setLevel(voiceKey,val);
});
this.addCommand(\set_pan, "sf", { arg msg;
var voiceKey = msg[1].asSymbol;
var val = msg[2].asFloat;
kernel.setPan(voiceKey,val);
});
// NEW: add control over synth
this.addCommand(\set_synth, "sf", { arg msg;
var attribute = msg[1].asSymbol;
var val = msg[2].asFloat;
kernel.setSynth(attribute,val);
});
// NEW: add control over delay time
this.addCommand(\set_delay_time, "f", { arg msg;
var val = msg[1].asFloat;
kernel.setDelayTime(val);
});
this.addCommand(\set_main, "sf", { arg msg;
var key = msg[1].asSymbol;
var val = msg[2].asFloat;
kernel.setMain(key,val);
});
// NEW: add brightness poll
this.addPoll(\brightness_poll, {
var spectral = kernel.busses[\brightness].getSynchronous;
spectral
});
// NEW: add amp poll
this.addPoll(\amp_poll, {
var amp = kernel.busses[\amp].getSynchronous;
amp
});
} // alloc
// IMPORTANT
free {
kernel.free;
} // free
} // CroneEngine
adding polls to our Lua script
Returning to our engine-study-3.lua
file, we’ll do the following:
- invoke our Lua handlers for the SuperCollider polls
- draw a circle to the screen based on the synth’s brightness and amplitude
- add LFO’s to control our synth voice’s filter cutoff value
Here’s our final engine-study-3.lua
file
engine-study-3.lua
-- *transit authority*
-- SuperCollider engine study 3
-- monome.org
engine.name = "FXBusDemo"
local formatters = require("formatters")
-- NEW: add LFO for additional movement:
local lfo = require("lib/lfo")
-- NEW: add sequins to sequence hz values
_s = require("sequins")
hz_vals = _s({ 300, 400, 400 / 3, 100, 300 / 2, 300 / 1.5 })
random_offset = { 0.5, 1.5, 2, 3, 1, 0.75 }
-- NEW: add screen redraw variables
local bright = 1
local rad = 2
local screen_dirty = true
local hz = 330
function clock.tempo_change_handler(x)
engine.set_delay_time(clock.get_beat_sec() / 2)
end
function init()
-- NEW: invoke our brightness poll //
brightness = poll.set("brightness_poll")
brightness.callback = function(val)
bright = util.round(util.linlin(20, 20000, 1, 15, val))
screen_dirty = true
end
brightness.time = 1 / 60
brightness:start()
-- // brightness poll
-- NEW: invoke our amp poll //
amp = poll.set("amp_poll")
amp.callback = function(val)
rad = util.round(util.linlin(0, 1, 2, 120, val))
screen_dirty = true
end
amp.time = 1 / 30
amp:start()
-- // amp poll
-- NEW: redraw at 60fps //
redraw_timer = metro.init(function()
if screen_dirty then
redraw()
screen_dirty = false
end
end, 1 / 60, -1)
redraw_timer:start()
-- // redraw
-- NEW: synth controls //
params:add({
type = "separator",
id = "synth_separator",
name = "synth",
})
params:add({
type = "control",
id = "fchz",
name = "filter hz",
controlspec = controlspec.FREQ,
action = function(x)
if fchzLFO.enabled == 0 then
engine.set_synth("fchz", x)
end
fchz_raw = params:get_raw("fchz")
end,
})
-- // synth controls
local cs_amp = controlspec.new(0, 2, "lin", 0.001, 1, nil, 1 / 200)
local cs_fc1 = controlspec.new(20, 20000, "exp", 0, 600, "Hz")
local cs_fc2 = controlspec.new(20, 20000, "exp", 0, 1800, "Hz")
local cs_pan = controlspec.new(-1, 1, "lin", 0.001, 0, nil, 1 / 200)
local frm_percent = function(param)
return ((param:get() * 100) .. "%")
end
params:add({
type = "separator",
id = "levels_separator",
name = "levels",
})
params:add({
type = "control",
id = "dry_level",
name = "dry level",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_level("dry", x)
end,
})
params:add({
type = "control",
id = "delay_level",
name = "delay level",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_level("delay_send", x)
end,
})
params:add({
type = "control",
id = "reverb_level",
name = "reverb level",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_level("reverb_send", x)
end,
})
params:add({
type = "separator",
id = "pan_separator",
name = "panning",
})
params:add({
type = "control",
id = "dry_pan",
name = "dry",
controlspec = cs_pan,
formatter = formatters.bipolar_as_pan_widget,
action = function(x)
engine.set_pan("dry", x)
end,
})
params:add({
type = "control",
id = "delay_pan",
name = "delay",
controlspec = cs_pan,
formatter = formatters.bipolar_as_pan_widget,
action = function(x)
engine.set_pan("delay_send", x)
end,
})
params:add({
type = "control",
id = "reverb_pan",
name = "reverb",
controlspec = cs_pan,
formatter = formatters.bipolar_as_pan_widget,
action = function(x)
engine.set_pan("reverb_send", x)
end,
})
params:add({
type = "separator",
id = "main_eq_separator",
name = "main EQ",
})
params:add({
type = "control",
id = "eq_lo",
name = "lo",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_main("ampLo", x)
end,
})
params:add({
type = "control",
id = "eq_mid",
name = "mid",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_main("ampMid", x)
end,
})
params:add({
type = "control",
id = "eq_hi",
name = "hi",
controlspec = cs_amp,
formatter = frm_percent,
action = function(x)
engine.set_main("ampHi", x)
end,
})
params:add({
type = "control",
id = "fc1",
name = "lo freq",
controlspec = cs_fc1,
action = function(x)
engine.set_main("fc1", x)
end,
})
params:add({
type = "control",
id = "fc2",
name = "hi freq",
controlspec = cs_fc2,
action = function(x)
engine.set_main("fc2", x)
end,
})
-- NEW: add 'fchz' LFO
fchz_spec = params:lookup_param("fchz").controlspec
fchzLFO = lfo:add({
shape = "sine", -- shape
min = -1, -- min
max = 1, -- max
depth = 0.6, -- depth (0 to 1)
mode = "clocked", -- mode
period = 1 / 3, -- period (in 'clocked' mode, represents 4/4 bars)
baseline = "center",
action = function()
engine.set_synth("fchz", calculate_bipolar_lfo_movement(fchzLFO, "fchz"))
end,
})
fchzLFO:add_params("myLFO", "lfo")
params:hide("lfo_min_myLFO")
params:hide("lfo_max_myLFO")
_menu.rebuild_params()
startup_actions = clock.run(function()
clock.sleep(0.1)
params:set("delay_level", 1)
params:set("reverb_level", 0)
params:set("fchz", 1500)
params:bang()
-- NEW: sequins clock
sequence = clock.run(function()
while true do
engine.set_synth("hz", hz_vals() * random_offset[math.random(#random_offset)])
clock.sync(1 / 4)
end
end)
fchzLFO:start() -- start our LFO, complements ':stop()'
end)
end
function calculate_bipolar_lfo_movement(lfoID, paramID)
if lfoID:get("depth") > 0 then
return fchz_spec:map(lfoID:get("scaled") / 2 + fchz_raw)
else
return fchz_spec:map(fchz_raw)
end
end
-- NEW: draw to screen //
function redraw()
screen.clear()
screen.level(bright)
screen.circle(64, 32, rad)
screen.fill()
screen.update()
end
-- // draw to screen
further
Download the final versions of this study’s files here.
If you feel prepared to explore both SuperCollider and Lua more deeply (and hopefully you do!), here are a few jumping-off points to extend this study:
- customize the on-screen animation
- explore additional Analysis UGens
- build your own FX processing chains
- swap the
sourceBlip
synth withMoonshine
To continue exploring and creating new synthesis engines for norns, we highly recommend:
- Zack Scholl’s incredible resources for SuperCollider and norns explorations:
- Zack’s #supercollider blog entries
- Eli Fieldsteel’s fantastic YouTube series
- Nathan Ho’s collected SuperCollider tips
acknowledgements
The FXBusDemo
engine was written by Ezra Buchla and Dan Derks for monome.org.
This study’s text was initiated by Dan Derks.