Link search Menu Expand Document

rude mechanicals

norns engine study 1: building a synth, creating an engine, scripting with engines on norns

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 + UI interactions are defined).

This study is meant to provide orientation for engine development on norns, using SuperCollider. We assume that you’ve already got a bit of familiarity with SuperCollider – if not, be sure to check out learning SuperCollider for helpful learning 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:

  1. 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.
  2. 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.
  3. 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.
  4. Use a Limiter on the master bus.
  5. Type in gain amounts as e.g. * -60.dbamp instead of * 0.001.

Now, let’s do a little norns housekeeping before digging into SuperCollider:

  • create a folder inside of code named engine_study
  • create a folder inside of engine_study named lib

part 1: starting from scratch in SuperCollider

We’ll start by building a basic synth definition in SuperCollider, which we’ll then port into norns later on. Open SuperCollider on your non-norns computer and create a new SuperCollider file.

make a sound

Type this text into a blank SuperCollider file:

(
SynthDef("Moonshine", {
	// let's build a raw sound:
	var pulse = Pulse.ar(freq: 300);
	var saw = Saw.ar(freq: 300);
	var sub = Pulse.ar(freq: 300/2);
	var noise = WhiteNoise.ar(mul: 0.1);
	var mix = Mix.ar([pulse,saw,sub,noise]);

	// and generate an envelope:
	var envelope = Env.perc(attackTime: 0.3, releaseTime: 2).kr(doneAction: 2);
	
	// put the raw sound through a resonant filter
	//  and modulate the filter with the envelope
	var filter = MoogFF.ar(in: mix, freq: 6000 * envelope, gain: 3);

	// our filtered signal is then multiplied by our envelope, panned to center:
	var signal = Pan2.ar(filter*envelope,0);

	// let's send our signal to the output:
	Out.ar(0,signal);
	
	}).add;
)

Navigate to the closing parenthesis and press CMD-RETURN (Mac) / CTRL-ENTER (Win) to evaluate the text. You won’t hear sound yet, since this only adds the synth definition to our current SuperCollider session.

To trigger the synth and make sound, assign it to a variable and evaluate:

x = Synth("Moonshine");

You should hear a buzzy tone swept by a filter as our SynthDef plays a 300hz note through our three wave generator UGens (alongside a white noise UGen) and generates an envelope which controls the synth’s amplitude but also modulates the cutoff frequency of a final-stage resonant filter.

build and modify arguments

If we were to port this sketch as a norns engine and assigned a key to trigger the synth, it’d eventually become stale because the pitch, the envelope’s attack and release, the noise level, the starting filter cutoff, even the sub-oscillator’s division – these are all static values. So, let’s introduce a few arguments to our SynthDef to modify these elements with each execution:

(
SynthDef("Moonshine", {
	arg freq = 330, sub_div = 5, noise_level = 0.1,
	cutoff = 8000, resonance = 3,
	attack = 0.3, release = 0.4,
	amp = 1, pan = 0, out = 0;
	
	var pulse = Pulse.ar(freq: freq);
	var saw = Saw.ar(freq: freq);
	var sub = Pulse.ar(freq: freq/sub_div);
	var noise = WhiteNoise.ar(mul: noise_level);
	var mix = Mix.ar([pulse,saw,sub,noise]);

	// and generate an envelope:
	var envelope = Env.perc(attackTime: attack, releaseTime: release, level: amp).kr(doneAction: 2);
	
	// put the raw sound through a resonant filter
	//  and modulate the filter with the envelope
	var filter = MoogFF.ar(in: mix, freq: cutoff * envelope, gain: resonance);

	// our filtered signal is then multiplied by our envelope, panned to center:
	var signal = Pan2.ar(filter*envelope,pan);

	// let's send our signal to the output:
	Out.ar(out,signal);
	
	}).add;
)

Now, we can simply execute the default values with:

x = Synth("Moonshine");

Or we can modify the values by addressing these arguments:

x = Synth("Moonshine",[\freq,300, \cutoff,2000, \resonance,3.7, \release,3]);

Now, we have a neat little synth + a way to send it commands – let’s get this SynthDef shaped into a norns engine!

part 2: building an engine for norns

Rather than attempt to poetically guide you through discovery, it seems helpful to present the norns engine boilerplate first.

boilerplate

Engine_MySynthName : CroneEngine {

	// ** add your variables here **

	// This is called when the engine is actually loaded by a script.
	// You can assume it will be called in a Routine,
	//  and you can use .sync and .wait methods.
	alloc { // allocate memory to the following:
		
		// ** add your SynthDefs here **
				
		// if you need your SynthDef to be available before commands are sent,
		//  sync with the server by ** uncommenting the following line **:
		// Server.default.sync;
		
		// ** add your commands here **
		
	}

}

Moonshine engine

Following the structure above, let’s build a norns engine from the synth definition we were previously working on in SuperCollider.

The end goal is to generate a SuperCollider Class File named Engine_Moonshine.sc, which will contain our engine definition.

We can do this either in SuperCollider (be sure to save as a Class File!) and import it into our code > engine_study > lib folder, or we can create the file directly in maiden (just use maiden’s file renaming feature to rename the file Engine_Moonshine.sc).

Engine_Moonshine : CroneEngine {
// All norns engines follow the 'Engine_MySynthName' convention above

	var params;

	alloc { // allocate memory to the following:

		// add SynthDefs
		SynthDef("Moonshine", {
			arg out = 0,
			freq, sub_div, noise_level,
			cutoff, resonance,
			attack, release,
			amp, pan;

			var pulse = Pulse.ar(freq: freq);
			var saw = Saw.ar(freq: freq);
			var sub = Pulse.ar(freq: freq/sub_div);
			var noise = WhiteNoise.ar(mul: noise_level);
			var mix = Mix.ar([pulse,saw,sub,noise]);

			var envelope = Env.perc(attackTime: attack, releaseTime: release, level: amp).kr(doneAction: 2);
			var filter = MoogFF.ar(in: mix, freq: cutoff * envelope, gain: resonance);

			var signal = Pan2.ar(filter*envelope,pan);

			Out.ar(out,signal);

		}).add;

  // We don't need to sync with the server in this example,
  //   because were not actually doing anything that depends on the SynthDef being available,
  //   so let's leave this commented:
  // Server.default.sync;
		
  // let's create an Dictionary (an unordered associative collection)
  //   to store parameter values, initialized to defaults.
		params = Dictionary.newFrom([
			\sub_div, 2,
			\noise_level, 0.1,
			\cutoff, 8000,
			\resonance, 3,
			\attack, 0,
			\release, 0.4,
			\amp, 0.5,
			\pan, 0;
		]);

  // "Commands" are how the Lua interpreter controls the engine.
  // The format string is analogous to an OSC message format string,
  //   and the 'msg' argument contains data.

  // We'll just loop over the keys of the dictionary, 
  //   and add a command for each one, which updates corresponding value:
		params.keysDo({ arg key;
			this.addCommand(key, "f", { arg msg;
				params[key] = msg[1];
			});
		});

  // This is faster than (but similar to) individually defining each command, eg:
		// this.addCommand("amp", "f", { arg msg;
		//	  amp = msg[1];
		// });

  // The "hz" command, however, requires a new syntax!
  // ".getPairs" flattens the dictionary to alternating key,value array
  //   and "++" concatenates it:
		this.addCommand("hz", "f", { arg msg;
			Synth.new("Moonshine", [\freq, msg[1]] ++ params.getPairs)
		});

	}

}

Once the Engine_Moonshine.sc file is under code > engine_study > lib, we’ll want to restart matron + SuperCollider together, via SYSTEM > RESTART.

commands

Building commands are where things really get fun, because commands surface dynamic controls to the Lua layer. Once you identify what you’d like to control from your script, command construction is pretty straightforward.

We could define our commands for each timbral parameter individually, eg:

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

Where:

  • "release" is the name we’d like Lua to use to reference this command
  • "f" defines the type of argument we expect ("f" means float, but we could also use "i" for integers or "s" for strings)
    • commands can also accept many arguments (and argument types) at once, eg. "ifffff"
    • we’re using single float commands for this example because it’s easier for an introduction
  • arg msg lets SuperCollider know that the incoming argument is a message to unpack
  • release = msg[1] assigns the SynthDef’s release to the value of the first message
    • in our case, we only have one message – a "f" (float)

But we have 8 timbral parameters, which means we’ll basically iterate the same structure over and over again, just changing names.

Rather than needlessly repeating lines of similar code with small differences, our Moonshine engine utilizes SuperCollider’s Dictionary object to build an associative array which holds each of our parameter commands (and a starting default value):

params = Dictionary.newFrom([
	\sub_div, 2,
	\noise_level, 0.1,
	\cutoff, 8000,
	\resonance, 3,
	\attack, 0,
	\release, 0.4,
	\amp, 0.5,
	\pan, 0;
]);

Once we establish params as a Dictionary, we can iterate on it very easily using SuperCollider’s built in methods for iteration. keysDo allows us to iterate over the a Dictionary and evaluate a given function for each key. Since each of these timbral commands accept a single float and contribute a single argument to our SynthDef, we can save a lot of lines using this approach:

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

command focus: hz

Moonshine represents a useful design pattern – the 1-shot synth which bundles the current state of the timbral parameters as it plays a note. Our hz command is how it happens:

this.addCommand("hz", "f", { arg msg;
	Synth.new("Moonshine", [\freq, msg[1]] ++ params.getPairs)
});

The action of this command first passes the argument as the freq value for our Moonshine synth, but then it uses another Dictionary method (.getPairs) to succinctly bundle up our timbral parameters. Through an array concatenation method (++), one line of code can perform the following:

  • create a new Synth: Synth.new(
  • use the Moonshine SynthDef: "Moonshine",
  • and pass the incoming argument as the synth’s frequency: [\freq, msg[1]]
  • but also bundle that argument: ` ++ `
  • with every current timbral parameter value: params.getPairs)

What results is a command which accepts a single float for frequency and bundles the current state of all the other variables to instantiate a Moonshine voice.

using commands

Now that our Moonshine engine is installed on norns (and we’ve done a proper SYSTEM > RESTART), let’s use Lua to execute the commands we established in the previous exercise.

In maiden, navigate to the code > engine_study folder and create a new Lua file (make sure this file is created outside of the code > engine_study > lib folder!). Name it Moonshine.lua and enter the following text:

engine.name = 'Moonshine'
-- nb. single or double quotes doesn't matter, just don't mix + match pairs!

s = require 'sequins'
-- see https://monome.org/docs/norns/reference/lib/sequins for more info

function init()
  mults = s{1, 2.25, s{0.25, 1.5, 3.5, 2, 3, 0.75} } -- create a sequins of hz multiples
  playing = false
  base_hz = 200
  sequence = clock.run(
    function()
      while true do
        clock.sync(1/3)
        if playing then
          engine.hz(base_hz * mults() * math.random(2))
        end
      end
    end
  )
end

function key(n,z)
  if n == 3 and z == 1 then
    playing = not playing
    mults:reset() -- resets 'mults' index to 1
    redraw()
  end
end

function redraw()
  screen.clear()
  screen.move(64,32)
  screen.text(playing and "K3: turn off" or "K3: turn on")
  screen.update()
end

Now, if we run this script and press K3, our sequence will toggle on and off.

As far as our engine was concerned, though, we only used a single command – hz. This is what initiates sound in our SuperCollider code, but it does so by polling the state of a lot of other parameters: \pw,pw, \amp,amp, \cutoff,cutoff, \resonance,resonance, \release,release, \pan,pan. What if we want to modify one of these facets?

Since we set each of these up as commands in our SuperCollider code, we can simply execute changes to them in Lua (for this example, via maiden’s REPL):

>> engine.attack(0.1)
>> engine.cutoff(500)
>> engine.sub_div(3)

As our sequence plays and we execute these commands, you’ll notice the timbre of our synth mutate.

part 3: build a Lua library file for our engine

One of the conveniences of scripting in norns is the parameters system, which provides scripts with MIDI control + presets for free. While controlling our engine via the REPL’s command line is fun, it’s perhaps not the most effective means for all types of performances. But we don’t want to leave parameter initialization and engine maintenance up to the main script – if somebody builds a fantastic control scheme and simply wants to drop in our engine, we don’t want them to have to dig through SuperCollider code to figure out how to best address all of our different commands from Lua.

So, to cleanly + portably integrate our engine into the norns ecosystem, let’s build a companion Lua file for our engine which will bundle its commands as part of its use in a script!

We’ll name this file moonshine_engine.lua and we’ll want it to live in our code > engine_study > lib folder – so either create it there via maiden or import your externally-created moonshine_engine.lua file to that location:

local Moonshine = {}
local Formatters = require 'formatters'

-- first, we'll collect all of our commands into norns-friendly ranges
local specs = {
  ["amp"] = controlspec.new(0, 2, "lin", 0, 1, ""),
  ["sub_div"] = controlspec.new(1, 10, "lin", 1, 2, ""),
  ["noise_level"] = controlspec.new(0, 1, "lin", 0, 0.3, ""),
  ["cutoff"] = controlspec.new(0.1, 20000, 'exp', 0, 1300, "Hz"),
  ["resonance"] = controlspec.new(0, 4, "lin", 0, 2, ""),
  ["attack"] = controlspec.new(0.003, 8, "exp", 0, 0, "s"),
  ["release"] = controlspec.new(0.003, 8, "exp", 0, 1, "s"),
  ["pan"] = controlspec.PAN
}

-- this table establishes an order for parameter initialization:
local param_names = {"amp","sub_div","noise_level","cutoff","resonance","attack","release","pan"}

-- initialize parameters:
function Moonshine.add_params()
  params:add_group("Moonshine",#param_names)

  for i = 1,#param_names do
    local p_name = param_names[i]
    params:add{
      type = "control",
      id = "Moonshine_"..p_name,
      name = p_name,
      controlspec = specs[p_name],
      formatter = p_name == "pan" and Formatters.bipolar_as_pan_widget or nil,
      -- every time a parameter changes, we'll send it to the SuperCollider engine:
      action = function(x) engine[p_name](x) end
    }
  end
  
  params:bang()
end

-- a single-purpose triggering command fire a note
function Moonshine.trig(hz)
  if hz ~= nil then
    engine.hz(hz)
  end
end

 -- we return these engine-specific Lua functions back to the host script:
return Moonshine

import + initialize

Now that our engine’s timbral commands are all self-contained as norns parameters, a host script can import + initialize Moonshine very easily:

-- SC engine study: import + initialize

engine.name = 'Moonshine' -- assign the engine to this script's run

-- our engine's Lua file is assigned a script-scope variable:
moonshine = include('engine_study/lib/moonshine_engine')
s = require 'sequins'

function init()
  moonshine.add_params() -- the script adds params via the `.add params()` function
  mults = s{1, 2.25, s{0.25, 1.5, 3.5, 2, 3, 0.75} }
  playing = false
  sequence = clock.run(
    function()
      while true do
        clock.sync(1/3)
        if playing then
          moonshine.trig(200 * mults() * math.random(2))
        end
      end
    end
  )
end

function key(n,z)
  if n == 3 and z == 1 then
    playing = not playing
    mults:reset()
    redraw()
  end
end

function redraw()
  screen.clear()
  screen.move(64,32)
  screen.text(playing and "K3: turn off" or "K3: turn on")
  screen.update()
end

script-scope variable + path

Whenever we include a library’s functions in a norns script, we need to assign it to a script-scope variable in order to use it. From the code snippet above:

moonshine = include('engine_study/lib/moonshine_engine')

Remember: in our moonshine_engine.lua file, we wrapped all of the engine-specific functions into a Moonshine table, which we then return at the end. So when we use include, we assign those functions to the script-scope variable moonshine, which then has access to those same functions. To confirm, run the SC engine study: import + initialize code snippet and execute the following on the command line:

>> tab.print(moonshine)

Which returns:

add_params	function: 0x3f3860
trig	function: 0x4fa410

The path we provide the include function is also very important – it specifies where the compiler should look to round out a script’s functionality. In order for any script to find and initialize our Moonshine’s Lua file, we provide the exact path (eg. 'engine_study/lib/moonshine_engine').

readying distribution

To distribute our Moonshine engine to others, we could use the following architecture:

moonshine/
  lib/
    Engine_Moonshine.sc
    moonshine_engine.lua
  moonshine.lua

the example script

While we’ve made it easier for another script to use our Moonshine engine by providing a companion Lua library file, we can ensure success + legibility by including a simple example script (in the filetree above, this is the moonshine.lua file) which clarifies usage.

The SC engine study: import + initialize snippet above is a fine starting point:

  • it models the proper engine naming
  • it confirms the library file’s path
  • it shows how to add the engine’s timbral parameters as part of the script initialization
  • it shows how to trigger the engine

further

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 the Moonshine engine:

  • show parameter values on the screen
  • create an on-norns interaction for parameter manipulation in the main script UI
  • swap out the sequins sequencer for external MIDI control
  • create a separate envelope for filter cutoff modulation
  • add a mechanism to control the individual level of each of the 3 voices

To continue exploring + creating new synthesis engines for norns, we highly recommend: