Towards a Functional / Declarative Node Management

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

  1. Embrace Immutability: Create new states rather than modifying existing ones
  2. Validate Early: Catch parameter issues before they become runtime problems
  3. Separate Concerns: Keep state management, validation, and transformation logic distinct
  4. Design for Change: Structure code to accommodate future modifications
  5. Think in Transformations: View synth management as a series of data transformations/filtering

[TO BE CONTINUET]

4 Likes

One potential issue is that the intermediate collections don’t get removed by the compiler and you can quickly reach performance limits with fast iteration times or when low latency is needed.

There are also a few places in the code where you have used mutable state, why?

Hum… this could be improved with a lazy collection implementation for synths, with caching system.

What do you think?

LazySynthCollection {
    var <synths, <pendingOps;
    classvar parameterCache;
    
    *initClass {
        parameterCache = Dictionary.new;
    }
    
    *new { |synths|
        ^super.new.init(synths);
    }
      // Cache for parameter queries
    init { |inSynths|
        synths = inSynths ?? { List[] };
        pendingOps = List[];
    }

Those are all very interesting, and there are a few options to improve all of that.

But… But there’s still a fundamental tension between:

  1. True immutability
  2. Real-time audio performance
  3. SuperCollider’s server-based architecture

We can’t completely solve all three. We will always be “hybrid”.

I think I “accidentally” (actually, not) did something in “Design 2” that also handled node trees just using the language (old discussions here). To my eyes, it looks like a simple solution (or the initial concept).

True, but you have to know why and where to use mutable things. That’s why I asked why you’ve used them - I wasn’t being critical of their use, but of their unexplained use? What secret piece of knowledge do you process (that the reader might not, as this is a tutorial) that lets you decided when it’s the right decision.


I think you could make the params creation immutable easily by returning an [[synth, params], … ] And reconstructing the event.

I wonder if you could use a fold over inf for the main routine with list comprehension (not too sure on what you can do with that)?

1 Like

@jordan Yo, Supercollider streams can get kinda close to that:

[ BTW, LC in sclang is coded with Routines internally ]

See those changes:

//instead of ~manage

~nextState = { |state|
    var valid = state.synths.select { |synth| 
        ~valid.(state.params[synth.nodeID]) 
    };
    
    state.synths.difference(valid).do { |synth|
        ["Removing", synth.nodeID].postln;
        synth.set(\gate, 0);
    };
    
    valid.do { |synth|
        var param = ~transform.(state.params[synth.nodeID]);
        ["New  ", synth.nodeID, param].postln;
        synth.set(*param.asPairs);
        state.params[synth.nodeID] = param;
    };
    
    (synths: valid, params: state.params)
};

~manage just changes things directly. ~nextState makes a whole new copy with the changes, making it easier to see precisely what changed.

In the second version, ~nextState keeps making new copies, each passed along to be used by the supercollider Stream system. One state flows into the next, over and over, usw.

s.waitForBoot {
    var initialState = ~init.value;
    Routine({
        loop {
            initialState = ~nextState.(initialState);
            10.0.rand.wait;
            initialState.yield;
        }
    }).play;
};

The main difference is that new creates a proper stream of states that could be used by other parts of SuperCollider’s stream, while original just loops and updates.

Consequences::

  • creates an infinite stream of state transformations
  • Maintains functional programming principles by treating state changes as transformations
  • Works within SuperCollider’s stream while keeping code functional

I think we have a “hybrid pearl” here… BINGO? OR FAIL?

1 Like

I don’t have the background to comment on the substance of this (though as someone not having the background, I do find it useful!), but here’s a very superficial (read: ultimately unimportant) quibble: “paradigm shift” sounds hyperbolic to me and mildly confusing; suggestion: replace with something more neutral like “Two paradigms: Imp vs. Decl”.

Reason (very much off topic): to my limited knowledge, declarative programming is an older paradigm than imperative programming, and the fact that both remain in use somewhat conflicts with the Kuhnian idea that motivates some of the “paradigm shift” talk, where one paradigm is replaced by another (his example is physics, seems like this is the only discipline where this actually works? What do I know…) Anyway, a programming paradigm isn’t the same thing as a Kuhnian paradigm, let’s keep the docs hype free (I’m assuming that this is meant to go into docs ultimately). Sorry for the OT again, just ignore if you disagree.

1 Like

Hello @girthrub

You do have a good background, sir! And it is a pleasure to correspond with you!

Declarative programming concepts predate imperative programming in many ways (e.g., lambda calculus, mathematical logic, etc).

There is a very famous paper by John Backus (creator of FORTRAN, a.k.a. GOD at the time) about a related topic: Can programming be liberated from the von Neumann style?: a functional style and its algebra of programs

The observation that Kuhn’s concept of paradigm shifts is more applicable to physics than programming is insightful.

And you are right. It’s such a strong word for things that are theoretically equivalent. Thank you for that;

I will change it now.

Why did you apologize twice for being “off-topic” when their point about terminology precision in documentation is relevant? Don’t downplay such precise feedback as “very superficial” and “ultimately unimportant” when good documentation terminology is paramount!

Thank you

No. I am not thinking of this as “official” documentation. Here, it is already good.

1 Like

@jordan

Yes, you touched on a good point. Some things influence each other at the same time. Also, some code suggests immutability but doesn’t do everything. All this is fine since we are demonstrating an idea. But to test the idea seriously, we need to note some things.

For example, this looks more like a functional style, but SC Synths., Dictionaries (params), shallow copies, etc

~state = ~state.updateParams(someSynth.nodeID, (freq: 880));
~state = ~state.withoutSynth(someSynth);
~state = ~state.select { |synth, params| ... };
~state = ~state.collect { |synth, params| ... };

A class could deliver some genuine immutability.

Synths remain mutable because they represent real-time audio processes. This creates a kind of “managed mutability” pattern where:

  1. The state container is immutable
  2. The parameter mappings are immutable
  3. But the actual synths must maintain a mutable state on the server

This can leave us in a new situation: using immutable containers to manage necessarily mutable audio processes.

But I think it would compromise performance and push too much the poor GC.

My feeling is that it is not worth it. But never tested. We should, right?

@jordan Here is a version with much less object creation and intermediate collection. I think the original one makes it easier to explore and chain things, but that’s a personal choice.

I guess that (running on my machine) it would make sense with speedy updates (low-latency situations) and many more synths. The original is more straightforward to extend and modify (chaining transformations) and (I think) less likely to introduce bugs with development/composing.

This one has one pass (efficiency, but you would easily add more to add a feature) instead of multiple select/reject operations (easy to experiment with).


Now we can compare the performance:


(
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].isNumber and:
        params[\amp].isNumber and:
        params[\freq].inclusivelyBetween(20, 750) and:
        params[\amp].inclusivelyBetween(0, 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)
    )
};


~initState = {
    var maxSize = 200;  
    (
        synths: List[],
        params: Dictionary.new,
        validSynths: List.new(maxSize),  
        removeSynths: List.new(maxSize)  
    )
};

~manage = { |state|
    var temp = state.synths;
    state.synths = state.validSynths;
    state.validSynths = temp;
    
    state.synths.clear;
    state.removeSynths.clear;
    
    state.validSynths.do { |synth|
        var params = state.params[synth.nodeID];
        if(~validate.(params)) {
            state.synths.add(synth);
        } {
            state.removeSynths.add(synth);
        };
    };
    
    state.removeSynths.do { |synth|
        ["Removing", synth.nodeID].postln;
        synth.set(\gate, 0);
        state.params.removeAt(synth.nodeID);
    };
    
    state.synths.do { |synth|
        var param = ~transform.(state.params[synth.nodeID]);
        ["New  ", synth.nodeID, param].postln;
        synth.set(*param.asPairs);
        state.params[synth.nodeID] = param;
    };
    
    state
};

~init = {
    var state = ~initState.value;

    200.do {
        var param = ~makeParam.value;
        var synth = Synth(\sine, param.asPairs);
        state.synths.add(synth);
        state.params[synth.nodeID] = param;
    };

    state
};
)

(
fork {
    var state = ~init.value;
    Routine({
        inf.do {
            state = ~manage.(state);
            rrand(0.01, 1.0).wait;
        }
    }).play;
};
)

This implementation is a hybrid approach to node management, combining client-side tracking with server-side verification. Node status is monitored through both the isPlaying method and supplemental server queries that periodically check the actual state of all nodes.


s.options.memSize = 65536;


s.waitForBoot {
    var state, waitTime = 1;
    var nodeStatusDict = Dictionary.new;
    OSCdef.freeAll; 
    
    OSCdef(\nodeTracker, { |msg|
        var nodeID = msg[1];
        nodeStatusDict[nodeID] = if(msg[2] == 0) { 0 } { 1 };
    }, '/n_info');
    
    ~nodeExists = { |nodeID|
        if(nodeID.isNil) { false } {
            var node = nodeID.asNodeID;
            node.isPlaying
        }
    };
    
    
    
    SynthDef(\koe, { |freq=400, amp=0.1, pan=0, gate=1|
        var sig, env, wet, ringMod;
        
        sig = Mix.ar([
            SinOsc.ar(freq, 0, 0.25),
            SinOsc.ar(freq * 1.414, 0, 0.2),
            SinOsc.ar(freq * 0.501, 0, 0.15),
            Pulse.ar(freq * 0.5, 0.3, 0.1)
        ]);
        
        ringMod = sig * SinOsc.ar(freq * 0.25, 0, 0.3);
        sig = sig + (ringMod * 0.6);
        
        env = EnvGen.kr(
            Env.new([0, 0.7, 1, 0.8, 0], [0.3, 0.7, 1.0, 0.7], [2, 0, -3, -2]), 
            gate,
            doneAction: 2
        );
        
        sig = sig * env * amp;
        
        sig = LPF.ar(sig, freq * 4);
        sig = BPF.ar(sig, freq * 2.3, 0.3, 0.2) + (sig * 0.8);
        
        sig = sig * (1 + LFNoise2.kr(0.5, 0.1));
        
        wet = CombC.ar(sig, 0.2, 
            LFNoise1.kr(0.2).range(0.1, 0.18), 
            LFNoise1.kr(0.1).range(1.0, 1.8)
        ) * LFNoise2.kr(0.1).range(0.2, 0.4);
        
        sig = (sig + (wet * 0.3)) * 0.7;
        
        Out.ar(0, Pan2.ar(sig, pan));
    }).add;
    
    s.sync; 
    
    ~boundaries = (
        freq: (min: 55, max: 880), 
        amp: (min: 0.002, max: 0.06),
        pan: (min: -0.8, max: 0.8),
        prob: 0.83
    );
    
    ~validate = { |params|
        params.notNil and: {
            params[\freq].notNil and: 
            params[\amp].notNil and: 
            {params[\freq].inclusivelyBetween(~boundaries.freq.min, ~boundaries.freq.max)} and:
            {params[\amp] < 0.1} and:  
            ~boundaries.prob.coin       
        }
    };
    
    ~transform = { |params|
        var series = [1, 9/8, 5/4, 4/3, 3/2, 5/3, 2, 0.5, 0.75, 0.667];
        var freqMod = series.choose;
        
        (
            freq: (params[\freq] * freqMod)
            .clip(~boundaries.freq.min, ~boundaries.freq.max),
            amp: (params[\amp] * [0.7, 0.8, 0.9, 1.0, 1.1].choose)
            .clip(~boundaries.amp.min, ~boundaries.amp.max),
            pan: if(params[\pan].notNil) { 
                params[\pan] * [-1, -0.5, 0, 0.5, 1].choose
            } { 0 },
            gate: 1
        )
    };
    
    ~makeParam = {
        var baseFreq = [55, 110, 165, 220, 330, 440].choose;
        
        (
            freq: baseFreq,
            amp: [0.01, 0.02, 0.03, 0.04].choose,  
            pan: [-0.8, -0.4, 0, 0.4, 0.8].choose,
            gate: 1
        )
    };
    
    ~init = {
        var synths = List[];
        var params = Dictionary.new;
        
        6.do {
            var param = ~makeParam.value;
            var synth = Synth(\koe, param.asPairs);
            
            synths.add(synth);
            params[synth.nodeID] = param;
            nodeStatusDict[synth.nodeID] = 1;  // Mark as active
            
            ["Created synth with ID:", synth.nodeID, "freq:", param[\freq]].postln;
        };
        
        (synths: synths, params: params)
    };
    
    ~manage = { |oldState|
        var oldSynths = oldState.synths;
        var oldParams = oldState.params;
        var newSynths = List[];
        var newParams = Dictionary.new;
        var nodesToUpdate, nodesToRemove;
        
        nodesToUpdate = List[];
        nodesToRemove = List[];
        
        oldSynths.do { |synth|
            var nodeID = synth.nodeID;
            var param = oldParams[nodeID];
            
            if(param.notNil) {

                if(synth.isPlaying) {
                    if(~validate.(param)) {
                        nodesToUpdate.add(nodeID);
                    }  {
                        nodesToRemove.add(nodeID);
                    }
                }  {
       
                    nodesToRemove.add(nodeID);
                }
            }
        };
        

        nodesToRemove.do { |nodeID|
            var synth = oldSynths.detect { |s| s.nodeID == nodeID };
            if(synth.notNil) {
                ["Removing", nodeID].postln;
                
   
                if(synth.isPlaying) {
                    synth.set(\gate, 0);
                };
                

                nodeStatusDict[nodeID] = 0;
            };
        };
        
        nodesToUpdate.do { |nodeID|
            var synth = oldSynths.detect { |s| s.nodeID == nodeID };
            var oldParam = oldParams[nodeID];
            
            if(synth.notNil && oldParam.notNil && synth.isPlaying) {
                var newParam = ~transform.(oldParam);
                
                ["Updating", nodeID, "freq:", newParam[\freq], "Pan:", newParam[\pan]].postln;
                
                 synth.set(*newParam.asPairs);
                

                newSynths.add(synth);
                newParams[nodeID] = newParam;
            };
        };
        
        if(newSynths.size < 4) { 
            (4 - newSynths.size).do {
                var param = ~makeParam.value;
                var synth;
                
                try {
                    synth = Synth(\koe, param.asPairs);
                    
                    ["Added new synth", synth.nodeID, "freq:", param[\freq], "pan:", param[\pan]].postln;
                    newSynths.add(synth);
                    newParams[synth.nodeID] = param;
                    nodeStatusDict[synth.nodeID] = 1;  // Mark as active
                } { |error|
                    ["Error creating synth:", error.errorString].postln;
                };
            };
        };
        
        (synths: newSynths, params: newParams)
    };
    
    s.sync;
    
    state = ~init.value;
    
    ServerTree.add({ 
        ["Server rebooted, clearing state"].postln;
        nodeStatusDict.clear;
    });
    
    CmdPeriod.add({
        ["Cleaning up all synths"].postln;
        state.synths.do { |synth| 
            if(synth.notNil) { synth.free }
        };
        nodeStatusDict.clear;
    });
    
    Routine({
        var i = 0;
        var running = true;
        
        while { running } {
            i = i + 1;
            
            waitTime = [1, 2, 3, 5].choose.reciprocal * rrand(0.3, 0.7);
            
            ("Iteration " + i + ", " + state.synths.size + " active synths").postln;
            
            try {
                state = ~manage.(state);
            } { |error|
                ["Error in management cycle:", error.errorString].postln;
                0.5.wait;
            };
            
            if(i % 50 == 0) {
      
                
                s.queryAllNodes;
            };
            
            ("Waiting " + waitTime + " seconds").postln;
            waitTime.wait;
        };
    }).play;
};

EDIT: Actually, this is an example of a self-adaptive system using an autonomic computing pattern, such as the MAPE-K. It maintains system stability through validation logic, transformation rules, and autonomous management that can recover from failures.

Second version:

  • Reduced Mutable State: Instead of directly changing your lists and dictionaries (like stirring a pot), I’ve made functions that take it and return completely new dishes. The ~manage function returns you to a fresh state rather than modifying what you gave it.
  • Higher-Order Functions: I’ve swapped out traditional “do this, then do that” loops for more elegant collection operations like collect and select.
  • Pure Functions: These always give you the exact thing when you use the same inputs. The ~processSynth function doesn’t reach out and change things - it just takes inputs and returns a predictable result object every time.
  • Recursive Structure: Rather than creating an infinite loop that runs forever, the ~run function now calls itself with updated information.
  • Immutability: By adding .freeze to arrays, I’ve essentially put a “DON’T TOUCH THIS” . This prevents accidental changes
  • Function Composition: broken the ~manage function into smaller, specialized tools like ~processSynth that work together -
  • Avoiding Side Effects: All functions now keep to themselves instead of reaching out to change things elsewhere
    Fixed a Bug in Adding New Synths The original code had a potential issue where the wrong parameters might be associated with new synths.
s.options.memSize = 65536;

s.waitForBoot {
    /*
     * CONSTANTs
     */
    ~oscillatorRatios = [1, 1.414, 0.501, 0.5].freeze;  // Frequency ratios for oscillators
    ~oscillatorLevels = [0.25, 0.2, 0.15, 0.1].freeze;  // Amplitude levels for oscillators
    ~envLevels = [0, 0.7, 1, 0.8, 0].freeze;            // Envelope level points
    ~envTimes = [0.3, 0.7, 1.0, 0.7].freeze;            // Envelope time points
    ~envCurves = [2, 0, -3, -2].freeze;                 // Envelope curve shapes - positive=exp, negative=log
    ~filterRatio = 4;                                    // LPF cutoff ratio
    ~bandFilterRatio = 2.3;                             // BPF center frequency ratio
    ~bandFilterRq = 0.3;                                // BPF bandwidth (Q factor)
    ~bandFilterAmp = 0.2;                               // BPF amplitude
    ~dryLevel = 0.8;                                    // Dry signal level after filtering
    ~reverbMix = 0.3;                                   // Wet/dry mix for reverb effect
    ~masterLevel = 0.7;                                 // Master output level

    SynthDef(\koe, { |freq=400, amp=0.1, pan=0, gate=1|
        var sig, env, wet, ringMod;
        
        sig = Mix.ar([
            SinOsc.ar(freq * ~oscillatorRatios[0], 0, ~oscillatorLevels[0]),
            SinOsc.ar(freq * ~oscillatorRatios[1], 0, ~oscillatorLevels[1]),
            SinOsc.ar(freq * ~oscillatorRatios[2], 0, ~oscillatorLevels[2]),
            Pulse.ar(freq * ~oscillatorRatios[3], 0.3, ~oscillatorLevels[3])
        ]);
        
        ringMod = sig * SinOsc.ar(freq * 0.25, 0, 0.3);
        sig = sig + (ringMod * 0.6);
        
        env = EnvGen.kr(
            Env.new(~envLevels, ~envTimes, ~envCurves), 
            gate,
            doneAction: 2  
        );
        
        sig = sig * env * amp;
        
        sig = LPF.ar(sig, freq * ~filterRatio);
        sig = BPF.ar(sig, freq * ~bandFilterRatio, ~bandFilterRq, ~bandFilterAmp) + (sig * ~dryLevel);
        
        sig = sig * (1 + LFNoise2.kr(0.5, 0.1));
        
        wet = CombC.ar(sig, 0.2, 
            LFNoise1.kr(0.2).range(0.1, 0.18), 
            LFNoise1.kr(0.1).range(1.0, 1.8)
        ) * LFNoise2.kr(0.1).range(0.2, 0.4);
        
        sig = (sig + (wet * ~reverbMix)) * ~masterLevel;
        
        Out.ar(0, Pan2.ar(sig, pan));
    }).add;
    
    s.sync;
    
    /*
     * These values define the behavioral aspects of the system:
     * - How many synths are maintained
     * - Parameter boundaries for musical coherence
     * - Margins for validation / prevent edge cases
     */
    ~minSynths = 4;                        // Minimum number of active synths
    ~queryInterval = 50;                   // Node query interval
    ~boundaryMargin = 0.001;               // Safety margin for boundary checks
    
    // -- System boundaries for parameter validation
    // -- Includes range limits and probability threshold 
    ~boundaries = (
        freq: (min: 55, max: 880),       
        amp: (min: 0.002, max: 0.06),    
        pan: (min: -0.8, max: 0.8),      
        prob: 0.83                         // Probability of continuation vs regeneration
    ).freeze;
    
    /*
     * VALIDATION
     * 
     * Ensures generated parameters stay within musical and technical bounds.
     * The validation includes probabilistic elements to create variation
     * while maintaining overall coherence.
     */
    // -- Returns: Boolean - true if parameters are valid
    ~validate = { |params|
        var freqValid, ampValid, result;
        
        // First check for nil params
        if(params.isNil) {
            ["Parameter validation failed: nil params"].postln;
            false;
        };
        
        // Check frequency bounds
        freqValid = params[\freq].notNil and: {
            params[\freq] >= (~boundaries.freq.min - ~boundaryMargin) and: 
            params[\freq] <= (~boundaries.freq.max + ~boundaryMargin)
        };
        
        if(freqValid.not) {
            ["Invalid frequency:", params[\freq], "not in range", ~boundaries.freq.min, "to", ~boundaries.freq.max].postln;
        };
        
        // Check amplitude bounds
        ampValid = params[\amp].notNil and: {
            params[\amp] >= (~boundaries.amp.min - ~boundaryMargin) and:
            params[\amp] < 0.1
        };
        
        if(ampValid.not) {
            ["Invalid amplitude:", params[\amp], "not in valid range"].postln;
        };
        
        // Extra: probability threshold
        result = freqValid and: ampValid and: ~boundaries.prob.coin;
        result;
    };
    
    /*
     * PARAMETER TRANSFORMATION
     * 
     * Transforms parameters based on musical relationships.
     */
    // -- Returns: Dictionary - New parameter values after transformation
    ~transform = { |params|
        var series = [1, 9/8, 5/4, 4/3, 3/2, 5/3, 2, 0.5, 0.75, 0.667].freeze;  
        var ampFactors = [0.7, 0.8, 0.9, 1.0, 1.1].freeze;                      
        var panFactors = [-1, -0.5, 0, 0.5, 1].freeze;                         
        var freqMod, newFreq, newAmp, newPan;
        
        // Select frequency modifier from musical intervals
        freqMod = series.choose;
        
        // Calculate new frequency with boundary constraints
        newFreq = (params[\freq] * freqMod)
            .clip(~boundaries.freq.min, ~boundaries.freq.max);
            
        // Calculate new amplitude with boundary constraints
        newAmp = (params[\amp] * ampFactors.choose)
            .clip(~boundaries.amp.min, ~boundaries.amp.max);
            
        // Calculate new pan position
        newPan = if(params[\pan].notNil) { 
            (params[\pan] * panFactors.choose)
                .clip(~boundaries.pan.min, ~boundaries.pan.max)
        } { 0 };
        
        // Return new parameter set
        (
            freq: newFreq,
            amp: newAmp,
            pan: newPan,
            gate: 1
        )
    };
    
    /*
     * PARAMETER GENERATION
     * 
     */
    // -- Returns: Dictionary - Parameter set with random initial values
    ~makeParam = {
        var baseFreqs = [55, 110, 165, 220, 330, 440].freeze;  
        var amps = [0.01, 0.02, 0.03, 0.04].freeze;            // Initial amplitude values
        var pans = [-0.8, -0.4, 0, 0.4, 0.8].freeze;           // Initial pan positions
        
        (
            freq: baseFreqs.choose,
            amp: amps.choose,
            pan: pans.choose,
            gate: 1
        )
    };
    
    /*
     * SYNTH CREATION
     * 
     * Creates new synth instances with error handling.
     * The try/catch pattern prevents system crashes if
     * a synth fails to instantiate for any reason.
     */
    // -- Returns: Synth - New synth instance or nil if creation failed
    ~createSynth = { |param|
        var synth = try {
            Synth(\koe, param.asPairs)
        } { |error|
            ["Error creating synth:", error.errorString].postln;
            nil
        };
        
        if(synth.notNil) {
            ["Created synth with ID:", synth.nodeID, 
             "freq:", param[\freq], "pan:", param[\pan]].postln;
        };
        
        synth
    };
    
    // -- Checks if a node exists and is still playing
    // -- node: Node to check
    // -- Returns: Boolean - true if node exists and is playing
    ~nodeExists = { |node|
        node.notNil and: { node.isPlaying }
    };
    
    /*
     * INITIALIZATION
     * 
     * Creates the initial set of synths and parameters.
     */
    // -- Returns: Dictionary - Initial state with synths and parameters
    ~init = {
        var synths = 6.collect { ~createSynth.(~makeParam.value) }
                      .select(_.notNil); 
        var params = Dictionary.newFrom(
            synths.collect { |synth| [synth.nodeID, ~makeParam.value] }.flatten
        );
        
        (synths: synths, params: params)
    };
    
    /*
     * PROCESSING
     * 
     * Core function that processes each synth on each iteration.
     */
    // -- Returns: Dictionary - Result with updated synth, params and action taken
    ~processSynth = { |synth, param|
        if(~nodeExists.(synth)) {
            if(~validate.(param)) {
                var newParam = ~transform.(param);
                ["Updating", synth.nodeID, "freq:", newParam[\freq], "Pan:", newParam[\pan]].postln;
                synth.set(*newParam.asPairs);
                (synth: synth, param: newParam, action: \update)
            } {
                ["Removing", synth.nodeID].postln;
                synth.set(\gate, 0);
                (synth: nil, param: nil, action: \remove)
            }
        } {
            (synth: nil, param: nil, action: \remove)
        }
    };
    
    /*
     * MANAGEMENT
     * 
     */
    // -- Manages the system state by updating existing synths
    // -- and creating new ones as needed to maintain minimum count
    // -- state: Current system state
    // -- Returns: Dictionary - Updated system state
    ~manage = { |state|
        var results, newSynths, newParams;
        
        results = state.synths.collect { |synth|
            var param = state.params[synth.nodeID];
            ~processSynth.(synth, param)
        };
        
        newSynths = results.select { |result| result.action == \update }
                           .collect { |result| result.synth };
        
        newParams = Dictionary.newFrom(
            results.select { |result| result.action == \update }
                   .collect { |result| [result.synth.nodeID, result.param] }.flatten
        );
        
        if(newSynths.size < ~minSynths) {
            var countToAdd = ~minSynths - newSynths.size;
            var addParams = countToAdd.collect { ~makeParam.value };
            var addedSynths = addParams.collect { |param| ~createSynth.(param) }
                                      .select(_.notNil);
            
            // Add new synths to collection
            newSynths = newSynths ++ addedSynths;
            
            // Store parameters for new synths
            addedSynths.do { |synth, i|
                newParams[synth.nodeID] = addParams[i];
            };
        };
        
        (synths: newSynths, params: newParams)
    };
    
    s.sync;
    
    /*
     * MAIN LOOP
     * 
     * Recursive function that drives the system.
     */
    // -- Main recursive function that runs the system
    // -- currentState: Current system state
    // -- iteration: Current iteration count
    ~run = { |currentState, iteration=1, previousWaitTime=1|
        fork {
            var newState, waitTime;
            
            ("Iteration " + iteration + ", " + currentState.synths.size + " active synths").postln;
            
            try {
                newState = ~manage.(currentState);
            } { |error|
                ["Error in management cycle:", error.errorString].postln;
                newState = currentState;
            };
            
            // Periodically query nodes for system monitoring
            if(iteration % ~queryInterval == 0) {
                s.queryAllNodes;
            };
            
            // Calculate variable wait time for organic rhythm
            waitTime = [1, 2, 3, 5].choose.reciprocal * rrand(0.3, 0.7);
            ("Waiting " + waitTime + " seconds").postln;
            
            waitTime.wait;
            
            ~run.(newState, iteration + 1, waitTime);
        }
    };
  
    // TODO
    // Add cleanup handlers for server reboot
    ServerTree.add({ 
        ["Server rebooted, clearing state"].postln;
    });

    // TODO
    // Add cleanup handlers for cmd-period
    CmdPeriod.add({
        ["Cleaning up all synths"].postln;
    });
    
    // Start the system with initial state
    ~run.(~init.value);
};
1 Like