Towards a Functional Synth Management
From Loops to Declarative Programming
Hopefully the first in a series
Introduction
Managing synthesizers in SuperCollider presents unique challenges, mainly when dealing with multiple synths simultaneously. While traditional imperative programming with loops has been the go-to solution for many developers, it often leads to subtle bugs, race conditions, and difficult-to-maintain code. This article introduces two functional programming designs that can transform how synths are managed.
Imperative and Declarative Approaches
Consider how we typically think about managing a collection of synths. The imperative approach might initially feel natural: “iterate through the list, check each synth, and maybe remove some.” However, this intuitive approach hides significant complexity.
The Traditional Approach: A Formula for Intricacy
When using imperative loops, you might find yourself wrestling with questions like:
- “How do I handle shifting indices when removing elements?”
- “What happens if I modify the collection while iterating?”
- “How do I ensure I process each element exactly once?”
These concerns often lead to brittle code that’s difficult to debug and prone to subtle errors.
The Declarative Alternative: Clarity Through Abstraction
Functional programming offers a more refined solution. Instead of manually managing iteration and state, we can express our intent directly:
“Here’s my collection of synths. Keep the ones that meet these criteria, transform them according to these rules, and clean up the rest.”
This approach isn’t just more concise—it’s easier to reason about.
DESIGN 1: Filtering with select
~synths = ~synths.select { |synth|
var keepSynth = 2.rand == 1;
if(keepSynth.not) { synth.free };
keepSynth
};
This simple design encapsulates a strong idea: treating synth management as a filtering operation and transformation.
Why This?
1 Predictable Behavior: Each synth’s fate is determined independently
2. Stateless Processing: No need to track iteration state or indices
3. Composable Design: Easily combine with other functional operations
Example
SynthDef(\crystalBell, { |freq=400, amp=0.1, pan=0|
var sig = SinOsc.ar(freq, 0, amp);
Out.ar(0, Pan2.ar(sig, pan));
}).add;
~ensemble = Array.fill(10, {
Synth(\crystalBell, [
\freq, exprand(200, 800),
\amp, 0.1
]);
});
~ensemble = ~ensemble.select { |synth|
var survive = 0.7.coin;
if(survive) {
synth.set(\freq, exprand(300, 600));
synth.set(\amp, exprand(0.05, 0.15));
} {
synth.free;
};
survive
}.collect { |synth|
synth.set(\pan, rrand(-0.8, 0.8));
synth
};
DESIGN 2: Structured State Management
The second design introduces state management. This way is particularly useful for complex projects.
State Architecture
(
synths: List[], // Active synth collection
parameters: Dictionary.new // Parameter mappings by synth ID
)
Example
SynthDef(\sine, { |freq=400, amp=0.1, pan=0, gate=1|
var sig, env, wet;
sig = Mix.ar([
SinOsc.ar(freq, 0, 0.3),
SinOsc.ar(freq * 2.02, 0, 0.2),
SinOsc.ar(freq * 0.501, 0, 0.15)
]);
env = EnvGen.kr(Env.asr(2.9, 1, 1.3), Lag.kr(gate, 0.4), doneAction: 2);
sig = sig * env * Lag.kr(amp, 0.1);
sig = LPF.ar(sig, freq * 4);
sig = sig * (1 + LFNoise2.kr(0.5, 0.1));
wet = CombC.ar(sig, 0.3, LFNoise1.kr(0.2).range(0.1, 0.3), 2) *
LFNoise2.kr(0.1).range(0.2, 0.5);
sig = (sig + (wet * 0.3)) * 0.7;
Out.ar(0, Pan2.ar(sig, pan));
}).add;
~boundaries = (
freq: (min: 80, max: 8000),
amp: (min: 0.002, max: 0.08),
pan: (min: -0.8, max: 0.8),
prob: 0.8
);
~validate = { |params|
params.notNil and: {
params[\freq].notNil and:
params[\amp].notNil and:
{params[\freq] < 750} and:
{params[\amp] < 0.13} and:
~boundaries.prob.coin
}
};
~transform = { |params|
(
freq: (params[\freq] * rrand(0.5, 2.0))
.clip(~boundaries.freq.min, ~boundaries.freq.max),
amp: (params[\amp] * rrand(0.9, 1.1))
.clip(~boundaries.amp.min, ~boundaries.amp.max),
pan: if(params[\pan].notNil) {
params[\pan] * ~boundaries.pan.min
} { 0 }
)
};
~makeParam = {
(
freq: exprand(100, 400),
amp: exprand(~boundaries.amp.min, ~boundaries.amp.max),
pan: rrand(~boundaries.pan.min, ~boundaries.pan.max)
)
};
// Initialize system with many synths
~init = {
var synths = List[];
var params = Dictionary.new;
30.do {
var param = ~makeParam.value;
var synth = Synth(\sine, param.asPairs);
synths.add(synth);
params[synth.nodeID] = param;
};
(synths: synths, params: params)
};
~manage = { |synths, params|
var valid = synths.select { |synth|
~validate.(params[synth.nodeID])
};
// Remove invalid synths
synths.reject { |synth|
valid.any { |validSynth| validSynth.nodeID == synth.nodeID }
}.do { |synth|
["Removing", synth.nodeID].postln;
synth.set(\gate, 0);
};
// Update valid synths
valid.do { |synth|
var param = ~transform.(params[synth.nodeID]);
["New ", synth.nodeID, param].postln;
synth.set(*param.asPairs);
params[synth.nodeID] = param;
};
(synths: valid, params: params)
};
s.waitForBoot {
var state = ~init.value;
Routine({
inf.do {
state = ~manage.(state.synths, state.params);
10.0.rand.wait;
}
}).play;
};
Best Practices for Functional Synth Management
- Embrace Immutability: Create new states rather than modifying existing ones
- Validate Early: Catch parameter issues before they become runtime problems
- Separate Concerns: Keep state management, validation, and transformation logic distinct
- Design for Change: Structure code to accommodate future modifications
- Think in Transformations: View synth management as a series of data transformations/filtering
[TO BE CONTINUET]