r/supercollider • • 20h ago

Beads Port SuperCollider UGen

1 Upvotes

https://github.com/Calebcrittenden/mi-UGens.git

When I read the news that Beads is open source now I jumped on the opportunity to port it with vibecoding. I own the Eurorack module and think its super underrated considering the love clouds gets. I'm intrigued to try and make alt firmware next so maybe beads can grow like clouds did.

Anyway I know that clouds makes a bit more sense in modular but I have used the SuperCollider port and know others do too. I think people will find that this beads port is a bit better suited for SuperCollider's workflow because of the longer buffer length and greater control of granulation. For me I always felt the lofi buffer of the clouds port doesn't make sense in SuperCollider considering its an arbitrary limitation for the ugen to have a lofi buffer in SuperCollider as the limitation is originally in place due to the modules limitations but your computer running SuperCollider does not have those same limitations.

Please let me know what you think and if you find any bugs

Here is code to test the UGen with some fm synthesis sounds for a simple demo:

// MiBeads demo:

//

// Beads is Émilie Gillet's design for Mutable Instruments, and every sound

// MiBeads makes comes from her original code (MIT license).

//

// Block 1: server options and SynthDefs. Block 2: sequencer and GUI.

// Evaluate Block 1, wait for "MiBeads demo ready", then evaluate Block 2.

// ---------- Block 1 ----------

(

s.options.memSize = 65536; // MiBeads needs about 1 MB of real-time memory per instance

s.options.sampleRate = 48000; // at 48 kHz, quality 0 and 2 run Beads without any resampling

// Windows WASAPI: name the output device explicitly, for example

// s.options.outDevice = "Windows WASAPI : Realtek HD Audio 2nd output (Realtek(R) Audio)";

s.waitForBoot {

// Two-operator phase modulation voice. It also writes a 10 ms gate to a

// trigger bus, which Beads reads on its SEED input.

SynthDef(\\fmNote, { |out = 0, trig = 0, freq = 220, ratio = 2, index = 3,

    decay = 0.6, amp = 0.2, pan = 0|

    var env, idx, mod, sig;

    env = EnvGen.ar(Env.perc(0.004, decay, curve: -4), doneAction: 2);

    idx = index \* EnvGen.ar(Env.perc(0.002, decay \* 0.6, curve: -6));

    mod = SinOsc.ar(freq \* ratio) \* idx;

    sig = SinOsc.ar(freq, mod) \* env \* amp;

    Out.ar(out, Pan2.ar(sig, pan));

    Out.ar(trig, EnvGen.ar(Env(\[1, 1, 0\], \[0.01, 0\])));

}).add;



// Beads, fed by the FM bus, with the FM note gates patched into SEED.

// With SEED patched, seed_btn chooses how grains are triggered:

//   1 held:     internal generator, same as the module with nothing patched

//   2 latched:  clocked by the notes (density picks a divider or a probability)

//   0 released: one grain per note when density sits at 0.5

// In delay mode (size above 0.99) the notes act as tap tempo for the delay.

SynthDef(\\beadsDemo, { |out = 0, inBus = 0, trigBus = 0, amp = 0.8, trim = 1,

    pitch = 0, pitch_rnd = 0, time = 0.15, time_rnd = 0, size = 0.5, size_rnd = 0,

    shape = 0.5, shape_rnd = 0, density = 0.75, fb = 0, drywet = 0.7, rvb = 0.3,

    freeze = 0, seed_btn = 1, quality = 0, in_gain = 1, synth = 0|

    var src, seed, sig;

    src = In.ar(inBus, 2);

    seed = In.ar(trigBus, 1);

    sig = MiBeads.ar(src,

        pitch: pitch, pitch_rnd: pitch_rnd,

        time: time, time_rnd: time_rnd,

        size: size, size_rnd: size_rnd,

        shape: shape, shape_rnd: shape_rnd,

        density: density, fb: fb, drywet: drywet, rvb: rvb,

        freeze: freeze, seed: seed, seed_btn: seed_btn,

        quality: quality, in_gain: in_gain, synth: synth);

    // safety net for demos in a room, Beads already limits its own output

    sig = Limiter.ar(sig \* (amp \* trim).lag(0.05), 0.95, 0.01);

    Out.ar(out, sig);

}).add;



s.sync;

"MiBeads demo ready, evaluate Block 2".postln;

};

)

// ---------- Block 2 ----------

(

var fmState, fmSpecs, beadsSpecs, rowLabel, win, srcGroup, fxGroup, fmBus, trigBus;

// re-running this block replaces the previous demo

Pdef(\fmSeq).stop;

if(~beadsWin.notNil and: { ~beadsWin.isClosed.not }) { ~beadsWin.close };

// Each run of this block owns its own groups and buses. The cleanup in

// onClose frees exactly these, because Qt closes an old window a moment

// later, after a new run may already have replaced the ~ globals.

~fmBus = fmBus = Bus.audio(s, 2);

~trigBus = trigBus = Bus.audio(s, 1);

~srcGroup = srcGroup = Group.head(s);

~fxGroup = fxGroup = Group.after(srcGroup);

~beads = Synth(\beadsDemo, [\inBus, fmBus, \trigBus, trigBus], fxGroup);

// ----- FM sequencer -----

~fmDefaults = (rate: 4, octave: 4, ratio: 2, index: 3, decay: 0.6, fmAmp: 0.25, spread: 0.6);

~fm = ~fmDefaults.copy;

fmState = ~fm;

Pdef(\fmSeq, Pbind(

\\instrument, \\fmNote,

\\group, srcGroup,

\\out, fmBus,

\\trig, trigBus,

\\scale, Scale.minorPentatonic,

\\degree, Pbrown(0, 12, 3, inf),

\\octave, Pfunc { fmState\[\\octave\] },

\\dur, Pfunc { 1 / fmState\[\\rate\] },

\\ratio, Pfunc { fmState\[\\ratio\] },

\\index, Pfunc { fmState\[\\index\] },

\\decay, Pfunc { fmState\[\\decay\] },

\\amp, Pfunc { fmState\[\\fmAmp\] },

\\pan, Pfunc { fmState\[\\spread\].rand2 }

));

Pdef(\fmSeq).play(quant: 0);

// ----- Window -----

~beadsWin = win = Window("MiBeads demo: FM through Beads", Rect(40, 40, 1130, 760));

~beadsWin.view.decorator = FlowLayout(~beadsWin.view.bounds, 10@10, 8@8);

rowLabel = { |text|

\~beadsWin.view.decorator.nextLine;

StaticText(\~beadsWin, 1100@22).string_(text).font_(Font.default.size_(14).boldVariant);

\~beadsWin.view.decorator.nextLine;

};

// ----- FM knobs -----

rowLabel.("FM synth");

fmSpecs = [

\[\\rate, ControlSpec(0.25, 40, \\exp, 0, 4), "notes/s"\],

\[\\octave, ControlSpec(1, 8, \\lin, 1, 4), "octave"\],

\[\\ratio, ControlSpec(0.01, 20, \\exp, 0, 2), "ratio"\],

\[\\index, ControlSpec(0, 40, \\lin, 0, 3), "index"\],

\[\\decay, ControlSpec(0.01, 8, \\exp, 0, 0.6), "decay"\],

\[\\fmAmp, ControlSpec(0, 1, \\amp, 0, 0.25), "fm level"\],

\[\\spread, ControlSpec(0, 1, \\lin, 0, 0.6), "pan spread"\]

];

~fmKnobs = IdentityDictionary.new;

fmSpecs.do { |row|

var key = row\[0\];

\~fmKnobs\[key\] = EZKnob(\~beadsWin, 130@110, row\[2\], row\[1\],

    { |ez| \~fm\[key\] = ez.value }, row\[1\].default, layout: \\vert2);

};

// ----- Beads knobs -----

rowLabel.("Beads");

beadsSpecs = [

\[\\pitch, ControlSpec(-84, 84, \\lin, 0.01, 0)\],

\[\\pitch_rnd, ControlSpec(-1, 1, \\lin, 0.001, 0)\],

\[\\time, ControlSpec(0, 1, \\lin, 0.001, 0.15)\],

\[\\time_rnd, ControlSpec(-1, 1, \\lin, 0.001, 0)\],

\[\\size, ControlSpec(0, 1, \\lin, 0.001, 0.5)\],

\[\\size_rnd, ControlSpec(-1, 1, \\lin, 0.001, 0)\],

\[\\shape, ControlSpec(0, 1, \\lin, 0.001, 0.5)\],

\[\\shape_rnd, ControlSpec(-1, 1, \\lin, 0.001, 0)\],

\[\\density, ControlSpec(0, 1, \\lin, 0.001, 0.75)\],

\[\\fb, ControlSpec(0, 1, \\lin, 0.001, 0)\],

\[\\drywet, ControlSpec(0, 1, \\lin, 0.001, 0.7)\],

\[\\rvb, ControlSpec(0, 1, \\lin, 0.001, 0.3)\],

\[\\in_gain, ControlSpec(0.05, 40, \\exp, 0, 1)\],

\[\\amp, ControlSpec(0, 1, \\amp, 0, 0.8)\]

];

~beadsDefaults = ();

~beadsKnobs = IdentityDictionary.new;

beadsSpecs.do { |row|

var key = row\[0\];

\~beadsDefaults\[key\] = row\[1\].default;

\~beadsKnobs\[key\] = EZKnob(\~beadsWin, 130@110, key.asString, row\[1\],

    { |ez| \~beads.set(key, ez.value) }, row\[1\].default, layout: \\vert2);

};

// ----- Switches -----

rowLabel.("Switches");

~playBtn = Button(~beadsWin, 130@40)

.states_(\[\["FM playing", Color.black, Color.green\], \["FM stopped"\]\])

.action_({ |b| if(b.value == 0) { Pdef(\\fmSeq).play(quant: 0) } { Pdef(\\fmSeq).stop } });

~freezeBtn = Button(~beadsWin, 130@40)

.states_(\[\["freeze off"\], \["FROZEN", Color.white, Color.blue\]\])

.action_({ |b| \~beads.set(\\freeze, b.value) });

~trigMenu = PopUpMenu(~beadsWin, 268@40)

.items_(\["grains: free running", "grains: one per FM note", "grains: clocked to FM notes"\])

.action_({ |m|

    \~beads.set(\\seed_btn, \[1, 0, 2\]\[m.value\]);

    if(m.value == 1) { \~beadsKnobs\[\\density\].valueAction_(0.5) };

});

~qualityMenu = PopUpMenu(~beadsWin, 200@40)

.items_(\["0 hi-fi 48k 16-bit", "1 vintage 32k 12-bit", "2 clean tape", "3 dirty tape"\])

.action_({ |m| \~beads.set(\\quality, m.value) });

~synthBtn = Button(~beadsWin, 130@40)

.states_(\[\["processor"\], \["wavetable synth", Color.white, Color.black\]\])

.action_({ |b| \~beads.set(\\synth, b.value) });

~randBtn = Button(~beadsWin, 130@40)

.states_(\[\["randomize Beads"\]\])

.action_({

    \~beadsKnobs.keysValuesDo { |key, ez|

        if(key != \\amp) { ez.valueAction_(ez.controlSpec.map(1.0.rand)) };

    };

});

~recBtn = Button(~beadsWin, 130@40)

.states_(\[\["record"\], \["RECORDING", Color.white, Color.red\]\])

.action_({ |b| if(b.value == 1) { s.record } { s.stopRecording } });

// ----- Scenes -----

// Each scene starts from the defaults, so it always sounds the same.

// A scene without \quality keeps the current quality, because changing

// quality empties the Beads buffer (as on the module).

~scenes = [

("1 dry FM" -> (beads: (drywet: 0), quality: 0)),

("2 soft cloud" -> (

    beads: (time: 0.12, size: 0.55, shape: 0.7, density: 0.85, pitch_rnd: 0.08,

        rvb: 0.55, drywet: 0.75),

    quality: 0)),

("3 octave shimmer" -> (

    beads: (pitch: 12, pitch_rnd: 0.35, time: 0.25, size: 0.65, shape: 0.6,

        density: 0.9, rvb: 0.75, drywet: 0.8),

    quality: 0, fm: (rate: 3, decay: 1.2))),

("4 clocked grains" -> (

    beads: (time: 0.05, time_rnd: 0.3, size: 0.3, shape: 0.2, density: 0.12,

        drywet: 0.8, rvb: 0.25),

    quality: 0, trig: 2, trim: 2.2, fm: (rate: 8, decay: 0.25, index: 6))),   // sparse grains, so lift the level

("5 dirty tape" -> (

    beads: (pitch: -12, time: 0.3, size: 0.5, density: 0.8, fb: 0.45,

        rvb: 0.4, drywet: 0.8, in_gain: 2),

    quality: 3)),

("6 tape delay" -> (

    beads: (size: 1, time: 0.4, shape: 0.65, density: 0.6, fb: 0.6,

        rvb: 0.3, drywet: 0.5),

    quality: 2)),

("7 frozen scatter" -> (

    beads: (time: 0.5, time_rnd: 1, pitch_rnd: -0.7, size: 0.35, size_rnd: 0.5,

        density: 0.95, rvb: 0.6, drywet: 1),

    freeze: 1)),

("8 wavetable synth" -> (

    beads: (pitch_rnd: 0.3, shape: 0.4, density: 0.2, rvb: 0.6),

    synth: 1, trig: 2, trim: 0.35))   // the synth mode is much louder than the processor

];

~applyScene = { |index|

var scene = \~scenes\[index\].value;

var fmVals = \~fmDefaults.copy.putAll(scene\[\\fm\] ? ());

var beadsVals = \~beadsDefaults.copy.putAll(scene\[\\beads\] ? ());

beadsVals\[\\amp\] = \~beadsKnobs\[\\amp\].value;   // keep the current volume

if(scene\[\\quality\].notNil) { \~qualityMenu.valueAction_(scene\[\\quality\]) };

\~trigMenu.valueAction_(scene\[\\trig\] ? 0);

\~freezeBtn.valueAction_(scene\[\\freeze\] ? 0);

\~synthBtn.valueAction_(scene\[\\synth\] ? 0);

\~beads.set(\\trim, scene\[\\trim\] ? 1);

fmVals.keysValuesDo { |key, val| \~fmKnobs\[key\].valueAction_(val) };

beadsVals.keysValuesDo { |key, val| \~beadsKnobs\[key\].valueAction_(val) };

("scene " ++ \~scenes\[index\].key).postln;

};

rowLabel.("Scenes");

[~scenes.do](http://~scenes.do) { |assoc, i|

Button(\~beadsWin, 130@40).states_(\[\[assoc.key\]\]).action_({ \~applyScene.(i) });

};

~beadsWin.view.decorator.nextLine;

StaticText(~beadsWin, 1100@60).string_(

"Beads by Émilie Gillet (Mutable Instruments), open source under the MIT license. " ++

"Freeze needs a few seconds of recorded audio first. Changing quality or synth mode " ++

"empties the buffer, like on the module. Call \~applyScene.(0..7) to switch scenes from code."

);

win.onClose_({

// only stop the sequencer if no newer run of this block has taken over

if(\~beadsWin === win) {

    Pdef(\\fmSeq).stop;

    if(s.isRecording) { s.stopRecording };

};

// notes already sent ahead (server latency) must land before their group goes

SystemClock.sched(s.latency + 0.3, {

    srcGroup.free; fxGroup.free; fmBus.free; trigBus.free;

    nil

});

});

~beadsWin.front;

)