<iframe width="100%" height="315" src="https://www.youtube.com/embed/CE3KD_9QHqI" frameborder="0" allowfullscreen style="border:1px solid #1e1e1e; border-radius:4px; margin:16px 0;"> </iframe>
Soundb0ard Shell is my Unix shell inspired music making environment. Rather than being the shell around an operating system, it is a shell around an audio mixing desk with several music instruments - FM synth, subtractive synth, drum synth, granular looper, one-shot sample player, and image sounder (spectrogram synthesis from PNG images). You control the shell via a javascript-like programming language called Slang.
Once launched from the command line, you enter an interactive shell where you can type commands. The color scheme is designed for a dark terminal.
Once you have successfully followed the BUILD instructions, you launch it from the projet root with:
build/SbshAfter running build/Sbsh you should find yourself at an SB#> prompt where you can start typing. You're goto command is ps. In Unix this would be 'process status', for SBShell, it's more like 'program status' - it shows you mixer stats like volume and BPM; it shows you environment variables, which can be standard objects like numbers, strings and booleans, and also its special sauce -- sound generator objects like FMSynth, MiniSynth, DrumSynth, or Sampler; and it shows the running Processes. More about all of that below..
Slang is based upon Monkey and all monkey code should be valid. e.g.
// Integers & arithmetic expressions...
let version = 1 + (50 / 2) - (8 * 3);
// ... and strings
let name = "The SBShell Programming Language - Slang!";
// ... booleans
let audioProgrammingFun = true;
// ... arrays & hash maps
let pals = [{"name": "Kenny", "age": 53}, {"name": "Pat", "age": 51}];
// User-defined functions...
let getName = fn(person) { person["name"]; };
getName(pals[0]); // => "Kenny"
getName(pals[1]); // => "Pat"
print(len(pals)); // prints: 2
let fibonacci = fn(x) {
if (x == 0) {
0
} else {
if (x == 1) {
return 1;
} else {
fibonacci(x - 1) + fibonacci(x - 2);
}
}
};
fibonnaci(14); // => 377
// `newAdder` returns a closure that makes use of the free variables `a` and `b`:
let newAdder = fn(a, b) {
fn(c) { a + b + c };
};
// This constructs a new `adder` function:
let adder = newAdder(1, 2);
adder(8); // => 11
Ok! How do i make noise?!
// whats going on?
ps// you'll see various synths already created in the environment.
// such as 'dx', 'dx2', 'dx3' - these are instances of the DX Synth,
// 'mo' the MiniSynth, and 'sbdrum', 'sb2', both drum machine instances,
// plus several one-shot sample players with names like 'bd', bdd', 'cp', 'sn'
// This is my default setup but you can create as many of these sound
// generators as you like.Lets try a DX synth first.
All sound generators can be triggered via the note_on(..) function, which takes a sound generator for the first param and a MIDI note for the second param.
note_on(dx, 44);
// try a chord
note_on(dx, [44, 48, 51]);\ Um, ok, i have no idea what those numbers are?
/ Soundb0ard deals directly with MIDI numbers. There is a built-in guide you can print with midi_ref()
SB#> midi_ref()
SB#> Midi Note Reference
------------------------------------------------------------------------
- C:0 C#:1 D:2 D#:3 E:4 F:5 F#:6 G:7 G#:8 A:9 A#:10 B:11
0 C:12 C#:13 D:14 D#:15 E:16 F:17 F#:18 G:19 G#:20 A:21 A#:22 B:23
1 C:24 C#:25 D:26 D#:27 E:28 F:29 F#:30 G:31 G#:32 A:33 A#:34 B:35
2 C:36 C#:37 D:38 D#:39 E:40 F:41 F#:42 G:43 G#:44 A:45 A#:46 B:47
3 C:48 C#:49 D:50 D#:51 E:52 F:53 F#:54 G:55 G#:56 A:57 A#:58 B:59
4 C:60 C#:61 D:62 D#:63 E:64 F:65 F#:66 G:67 G#:68 A:69 A#:70 B:71
5 C:72 C#:73 D:74 D#:75 E:76 F:77 F#:78 G:79 G#:80 A:81 A#:82 B:83and lots of built in functions for using midi numbers:
SB#> notes_in_key(44)
SB#> [44, 46, 48, 49, 51, 53, 55, 56]
print_notes(notes_in_key(44))
SB#> Notes:[44, 46, 48, 49, 51, 53, 55, 56]
G# A# C C# D# F G G#
print_notes(notes_in_key(44, 1))
SB#> Notes:[44, 46, 47, 49, 51, 52, 54, 56]
G# A# B C# D# E F# G#
notes_in_chord(44, 44)
SB#> [44, 48, 51]
SB#> note_on(dx, notes_in_chord(44, 44))notes_in_chord(root, key, mod, scale) builds a chord from scale degrees in a key —
chord quality (major/minor/dim) comes automatically from where the root sits in the scale.
# mod: 0=triad 1=seventh 2=seventh_inv 3=root_inv 4=power 5=ninth
# scale: 0=major 1=nat_minor 2=harm_minor 3=mel_minor 4=phrygian
notes_in_chord(48, 36, 0) # C3 triad in key of C major → major triad
notes_in_chord(48, 36, 1) # C3 seventh in key of C major → Cmaj7
notes_in_chord(48, 36, 5) # C3 ninth in key of C major → Cmaj9
notes_in_chord(48, 45, 5, 1) # C3 ninth in key of A natural minor → Cm9chord_notes(root, type, mod) builds a chord from intervals only — no key needed. Useful when you know the chord but not the key:
# type: 0=major 1=minor 2=diminished 3=power 4=sus2 5=sus4
# mod: 0=triad 1=min7 2=maj7 3=inv_min7 4=inv_maj7
chord_notes(51) # D# major triad
SB#> [51, 55, 58]
chord_notes(51, 1) # D# minor triad
SB#> [51, 54, 58]
chord_notes(51, 1, 1) # D# minor 7
SB#> [51, 54, 58, 61]
chord_notes(56, 1, 1) # G# minor 7
SB#> [56, 59, 63, 66]
# Play a D# min7 > G# min7 progression:
note_on(dx, chord_notes(51, 1, 1), dur=1000)
note_on_at(dx, chord_notes(56, 1, 1), 1000, dur=1000)Try some other DX synth:
SB#> note_on(dx2, 24, dur=700) // C
SB#> note_on(dx2, 26, dur=1700)
SB#> note_on(dx3, 69, dur=200)
SB#> note_on(dx3, 74, dur=2700)View the DX parameters via:
SB#> info dx
## FM SZYNTHZZZZZZZizzzzer (LOUIS) ####
## Voice Section ####################################
algo:0 vol:0.80 pan:0.00 pitchbend:0
midi_osc:1 porta:0.00 pitchrange:12 op4fb:4.29
vel2att:0 note2dec:0 reset2zero:0 legato:0
op1out:95.00 op2out:70.00 op3out:50.00 op4out:0.00
## Operator 1 ####################################
o1wav: SINE(0) o1rat:1.00 o1det:0.00 o1sus:0
e1att:5.00 e1dec:4.00 e1sus:0.30 e1rel:200.00
op1freq:103.83
## Operator 2 ####################################
o2wav: SINE(0) o2rat:3.00 o2det:0.00 o2sus:0
e2att:11.00 e2dec:400.00 e2sus:1.00 e2rel:1003.33
op2freq:103.83
## Operator 3 ####################################
o3wav: SINE(0) o3rat:4.00 o3det:0.00 o3sus:0
e3att:100.00 e3dec:4.00 e3sus:0.20 e3rel:1003.33
op3freq:103.83
## Operator 4 ####################################
o4wav: SINE(0) o4rat:3.00 o4det:2.60 o4sus:0
e4att:5.00 e4dec:1743.61 e4sus:0.00 e4rel:803.67
op4freq:103.83
## LFO Routing ####################################
l1_wav:sine(0) l1_int:0.20 l1_rate:0.38
l1_dest1:dx_dest_none
l1_dest2:dx_dest_none
l1_dest3:dx_dest_none
l1_dest4:dx_dest_none
SB#>All the paramaters can be changed via the following syntax:
set <sound_generator_name>:<param_name> val;
e.g. set dx:o2rat 4.7
How the operators are connected is controlled via the algo. You can view an ASCII diagram of them via the command algoz():
SB#> algoz()
SB#> DX Algos:
0. 1. 2. 3. 4. 5. 6. 7.
4
|
3 3 4 3 4
| \ | | |
2 2 2 4 2 3 2 4 4 4
| | |/ |/ | | / | \ |
1 1 1 1 1__3 1__2__3 1__2__3 1__2__3__4The bottom row are the operators sent to the DCA. E.g. in algo 0, Op1 is being sent to the output, Op2 is modulating op1, op3 is modulating op2, op3 is mod'ing op4; whereas algo 7 all the operators are being sent to the output, i.e pure addictive synth.
// Trigger a kick drum on a drum synth (note 0)
note_on(sbdrum, 0);
// clap (note 2)
note_on(sbdrum, 2);
// what else?
info sbdrum;
// bd(0) // sd(1) // cp(2) // hh(3) // oh(4) ..
// play a long bass note on an FM Synth (midi 20 - G#)
note_on(dx, 20, dur = 5000);// Load a preset
load_preset(sbdrum, "TR808");
note_on(sbdrum, 0);
// Try different velocities (0-127)
note_on(sbdrum, 1, vel = 127); // Loud snare
note_on(sbdrum, 1, vel = 40); // Quiet ghost noteSoundB0ard has five types of sound generators:
Synthesized drums with 9 voices: kick, snare, closed hat, clap, open hat, 3 FM toms, and a laser zap.
let drums = drumsynth();
load_preset(drums, "TR808"); // Classic 808
load_preset(drums, "TR909"); // Punchy 909
load_preset(drums, "DILLA"); // Warm, lo-fi
list_presets(drums); // list full kit presets
list_presets(drums, "sd"); // list presets for one voice (bd/sd/hh/oh/cp/fm1/fm2/fm3/lz)
save_preset(drums, "NEWPRESETNAME"); // to save a new preset
// Save/load individual drum voices across presets:
// part names: bd / sd / hh / oh / cp / fm1 / fm2 / fm3 / lz
save_drum_part(drums, "MY_KIT", "bd"); // save just the kick from current settings
load_drum_part(drums, "TR808", "bd"); // load 808 kick into current kit
load_drum_part(drums, "TR909", "sd"); // swap in a 909 snare
// Drum voices (MIDI note numbers):
// 0 = Kick
// 1 = Snare
// 2 = Clap
// 3 = Closed Hi-Hat
// 4 = Open Hi-Hat
// 5-7 = FM Toms
// 8 = Laser4-operator FM synthesis for bells, bass, keys, and experimental sounds.
let dx1 = fmsynth();
load_preset(dx1, "BASS");
note_on(dx1, 36); // C1 - deep bass note
note_on(dx1, 60); // C3 - middle C
note_on(dx1, notes_in_chord(40, 36)); // play a E(40) chord in the key of C(36)
midi_ref(); // midi note reference and other info
Midi Notes:
- C:0 C#:1 D:2 D#:3 E:4 F:5 F#:6 G:7 G#:8 A:9 A#:10 B:11
0 C:12 C#:13 D:14 D#:15 E:16 F:17 F#:18 G:19 G#:20 A:21 A#:22 B:23
1 C:24 C#:25 D:26 D#:27 E:28 F:29 F#:30 G:31 G#:32 A:33 A#:34 B:35
2 C:36 C#:37 D:38 D#:39 E:40 F:41 F#:42 G:43 G#:44 A:45 A#:46 B:47
3 C:48 C#:49 D:50 D#:51 E:52 F:53 F#:54 G:55 G#:56 A:57 A#:58 B:59
4 C:60 C#:61 D:62 D#:63 E:64 F:65 F#:66 G:67 G#:68 A:69 A#:70 B:71
5 C:72 C#:73 D:74 D#:75 E:76 F:77 F#:78 G:79 G#:80 A:81 A#:82 B:83
Chord Progressions: I-IV-V, I-V-vi-IV, I-vi-IV-V, vi-ii-V-I vi-IV-I-V
Chord Mods: None(0), Seventh(1), Seventh Inv(2) Root Inv(3) Power(4)
Key Mods: None(0), Natural Minor(1), Harmonic Minor Inv(2) Melodic Minor(3) Phrygian(4)
Filters: LPF1, HPF1, LPF2, HPF2, BPF2, BSF2, LPF4, HPF4, BPF4
Major Scale: W W H W W W H // Minor Scale: W H W W H W W
SB#> notes_in_key(36)
SB#> [36, 38, 40, 41, 43, 45, 47, 48]
print_notes(notes_in_key(36))
SB#> Notes:[36, 38, 40, 41, 43, 45, 47, 48]
C D E F G A BClassic analog-style synthesis with oscillators and filters.
let synth = subsynth();
load_preset(synth, "PAD");
note_on(synth, notes_in_chord(24, 24), vel = 100, dur = 2000);Granular synthesis engine for looping, slicing and mangling audio samples.
let clavl = loop(perc/808clave.aif);
set clavl:len 8;set clavl:speed 2; // Playback speed multiplier
set clavl:pitch 1.5; // Pitch ratio (independent of speed)
set clavl:mode 0; // 0=LOOP, 1=STATIC, 2=SMUDGE
set clavl:len 4; // Loop length in bars
set clavl:poffset 4; // Pattern offset (0-15, in sixteenths)
set clavl:plooplen 8; // Pattern loop length (1-16 sixteenths)The Granular Looper has built-in rhythmic FX that remap, gate or pitch-shift the 16 sixteenth-note slices within a bar. Each FX is triggered with set and lasts for one bar, then playback returns to normal. Fire them from a computation for repeating use.
Slice Remap FX — these rearrange which sixteenth-note slice plays at each step:
set clavl:scramble 1; // Randomise slice order (anchors beat 1 of each group of 4)
set clavl:stutter 1; // Repeat slices with random holds — stuttery/glitchy
set clavl:reverse 1; // Play the whole buffer backwards for one bar
set clavl:speedulate 1; // Half-speed, double-speed, or mixed — randomly chosen
set clavl:slowdown 1; // Tape-stop deceleration — normal start, stutters to a halt
set clavl:repeat 1; // Beat repeat — locks onto a slice and repeats it (2x, 4x, 8x)
set clavl:strobe 1; // Alternates between an anchor slice and normal playbackGate FX — silences specific steps for a rhythmic chop (can stack with slice FX):
set clavl:gate 1; // Rhythmic gate — randomly picks off-beat, sparse, syncopated, or random patternPitch FX — per-step pitch shifting (can stack with slice FX and gate):
set clavl:pitch_ramp 1; // Smooth pitch ramp across the bar (up, down, or V-shape)
set clavl:octave_jump 1; // Random octave shifts per step (0.5x, 1x, 2x)
set clavl:pitch_staircase 1; // Chromatic staircase — steps up or down by semitonesThe looper uses an N-grain pool (up to 16 simultaneous grains). New grains are launched at regular intervals; grain overlap controls how many play simultaneously.
set clavl:grain_overlap 0.2; // Overlap between grains: 0.0-0.9 (default 0.2)
set clavl:grain_env 0; // Envelope shape: 0=Tukey (default), 1=Hann
set clavl:grains_per_sec 15; // Grain launch rate
set clavl:grain_dur_ms 80; // Grain duration in milliseconds
set clavl:grain_spray_ms 10; // Random position offset per grain (ms)
set clavl:quasi_grain_fudge 0; // Random duration variationgrain_overlap — fraction of each grain's duration that overlaps with the next:
0.0— no overlap, grains are back-to-back (possible small gaps)0.2— default, 20% overlap; clean loops, sound identical to previous behaviour0.5— 50% overlap; 2 grains always active, slightly smeared onset transients0.7–0.9— 3–10 grains active; dense granular cloud, drums lose punch, pads become washy
grain_env — amplitude envelope applied to each grain:
0= Tukey (default): flat top with cosine-tapered edges. Safe for loops — grains sum to constant volume at any overlap. Switch to Hann for classic granular texture.1= Hann: full bell curve, zero at both ends. Best at 50% overlap; creates the characteristic granular "shimmer". Good for pads/atmospherics, not drums.
Effect of increasing overlap:
// Loop/drum safe — sounds the same as before
set clavl:grain_overlap 0.2;
// Start to hear smearing — good for atmospheric pads
set clavl:grains_per_sec 8;
set clavl:grain_overlap 0.5;
set clavl:grain_env 1; // switch to Hann for texture
// Dense granular cloud
set clavl:grains_per_sec 20;
set clavl:grain_dur_ms 150;
set clavl:grain_overlap 0.8;
set clavl:grain_env 1;
set clavl:grain_spray_ms 30; // spray adds pitch shimmerExample: triggering FX from a computation
let fx_comp = comp()
{
setup()
{
let lp = loop(perc/808clave.aif);
set lp:len 4;
}
run()
{
// Scramble every 4th bar
if (count % 4 == 3) {
set lp:scramble 1;
}
// Gate + pitch ramp on bar 8
if (count % 8 == 7) {
set lp:gate 1;
set lp:pitch_ramp 1;
}
}
}Load multiple audio files into a single looper. Each becomes a buf[N] with its own per-buffer settings. One buffer is designated the primary — it drives loop timing, rhythmic FX, and pitch patterns. By default this is buf[0], but you can change it at any time.
let lp = loop(dloops/techNOBe.wav); // buf[0] — primary by default
add_buf(lp, "dloops/steveMUR.wav"); // buf[1]
add_buf(lp, "dloops/jazzloop.wav"); // buf[2]Use primary N to designate a different buffer as primary:
set lp:primary 1; // buf[1] is now the primary
set lp:primary 0; // back to buf[0]Per-buffer parameters — use buf[N]:param syntax to target a specific buffer:
set lp:buf[1]:pitch 1.5; // pitch ratio for buf[1]
set lp:buf[1]:speed 0.5; // playback speed multiplier
set lp:buf[1]:gain 0.8; // amplitude (0.0+, default 1.0)
set lp:buf[1]:distortion 0.3; // soft-clip distortion (0.0-1.0)
set lp:buf[1]:len 2; // loop length in bars
set lp:buf[1]:poffset 4; // pattern offset (0-15 sixteenths)
set lp:buf[1]:plooplen 8; // pattern loop length (1-16 sixteenths)
set lp:buf[1]:pinc 2; // pattern step incrementNote: granular engine params (grains_per_sec, grain_dur_ms, grain_overlap, etc.) are global — they apply to all buffers in the looper.
On each grain launch, shhh compares the RMS loudness of all loaded buffers over a short window and picks one source based on the mode. Inspired by Nnirror's https://nnirror.gumroad.com/l/shhh - which i found about through Nathan Ho's SuperCollider port!
set lp:shhh 1; // 1=quietest source, 2=loudest source, 0=off
set lp:shhh_window_ms 80; // RMS window in ms (0 = use grain duration)
set lp:shhh 0; // disable, back to normalshhh modes:
0— off (default): normal playback from primary buffer1— quietest: always pick the quieter source2— loudest: always pick the louder source
shhh and the buffer xfader are mutually exclusive — shhh takes priority when enabled.
Assign buffers to the left and right sides of an internal crossfader. The xfader uses a constant-power (cos/sin) law for smooth transitions. Operates independently from shhh mode.
set lp:xfl 0; // assign buf[0] to the Left side
set lp:xfr 1; // assign buf[1] to the Right side
set lp:xfpos -1; // full left (buf[0] only)
set lp:xfpos 0; // centre (equal mix)
set lp:xfpos 1; // full right (buf[1] only)
set lp:xfspeed 0.002; // ramp rate in position units per sample (default 0.002)
set lp:xfclear 1; // clear all assignments, reset position to centreMultiple buffers can be assigned to each side — all get the same crossfade gain:
set lp:xfl 0; // buf[0] on left
set lp:xfl 2; // buf[2] also on left
set lp:xfr 1; // buf[1] on right
// Automate a slow crossfade over a bar
sched(0, -1, 1, pp*16, "set lp:xfpos %");Route any sound generator through the same grain engine as loop() using add_fx:
let dx = fmsynth();
add_fx(dx, "granulate"); // or "gran" for short
// Dry/wet and all grain controls available as fx params:
set dx:fx0:wet 0.7
set dx:fx0:grain_overlap 0.5
set dx:fx0:grain_env 1 // Hann for shimmer
set dx:fx0:grains_per_sec 12
set dx:fx0:grain_dur_ms 120
set dx:fx0:grain_spray_ms 20 // position randomisation = pitch shimmerThe FX captures incoming audio into a 10-second ring buffer. Grains read from slightly behind the write head, so the input is always captured before it's granulated. The same grain_overlap / grain_env / grain_dur_ms / grain_spray_ms params work identically to loop().
Typical uses:
grain_env 1+ highgrain_spray_mson a pad → smeared, shimmering texture- Low
grains_per_sec+grain_env 0(Tukey) on a drum bus → subtle thickening without smear - Automate
wetin a computation to fade in/out the granular texture mid-performance
Load and play audio samples from the wavs/ directory:
Add your own samples and directories here.
// list all directories within `wavs/`
ls
// list contents of specific directory
ls bd// preview a sound
play bd/mawkick.aiff;
// Load a sample using the relative path
let kick = sample(bd/kick8.aif);
let snare = sample(sd/2snare.aif);
let sh = sample(perc/chezShaker.aiff);
// Samples are organized in directories:
// bd/ - bass drums
// sd/ - snares
// cp/ - claps
// ch/ - closed hats
// oh/ - open hats
// perc/ - percussion// Trigger a sample
note_on(kick, 1); // Note number ignored for samples
// Control playback
vol kick 0.8;
pan kick 0.2; // Pan right (range -1.0 to 1.0)
// Pitch shifting
set kick:pitch 1.5; // 1.5x speed (higher pitch)
set kick:pitch 0.5; // 0.5x speed (lower pitch)Treats a PNG image as a spectrogram: columns play left-to-right, rows map to frequency bins (log scale), and pixel brightness controls amplitude. Inspired by Photo Sounder / Nnirror's Facet.
let img = imagesounder("/tmp/sierpinski.png");
vol img 0.8;set img:speed 512 // samples per column — lower = faster playback (default 512)
set img:lo 80 // low frequency Hz (default 80)
set img:hi 8000 // high frequency Hz (default 8000)
set img:gain 0.3 // output level multiplier (default 0.3)Each column is RMS-normalised so sparse and dense columns play at consistent loudness. Frequency mapping is logarithmic (equal octave spacing per row). The image loops continuously; current position shows as col:N/W in ps.
# Sierpinski triangle — self-similar descending tones
from PIL import Image
img = Image.new("L", (1024, 1024), 0)
px = img.load()
for y in range(1024):
for x in range(1024):
px[x, y] = 255 if (x & y) == 0 else 0
img.save("/tmp/sierpinski.png")
# XOR stripes — shifting chord clusters
for y in range(1024):
for x in range(1024):
px[x, y] = 255 if (x ^ y) % 32 == 0 else 0
img.save("/tmp/xor.png")SBSynth is an 8-voice polyphonic synth with two modes: wavetable (cycles a loaded audio file as a waveform at note frequency) and sample (plays a file at different pitches relative to a root note, rompler-style). Both modes share the same ADSR envelope and Moog ladder filter.
let s = wavsynth();
// Load one or more buffers (wavs/ prefix is implicit)
add_buf(s, "waves/sine.wav"); // wavetable mode — best with single-cycle waveforms
add_buf(s, "perc/vocal.wav"); // sample mode — loads any audio fileset s:mode 0; // Wavetable: loops the buffer at the note's frequency (default)
set s:mode 1; // Sample: plays the file at pitches relative to root noteset s:attack 10; // ms (default 10)
set s:decay 200; // ms (default 100)
set s:sustain 0.7; // 0.0–1.0 (default 0.8)
set s:release 300; // ms (default 200)set s:cutoff 4000; // Hz — 18000 = open (default)
set s:q 3.0; // resonance 1.0–10.0 (default 1.0)set s:root 60; // MIDI note that plays at 1x speed (default 60 = C4)
set s:loop 0; // 0 = one-shot (default in sample mode), 1 = loopSBSynth is fully polyphonic — trigger chords, arpeggios, or anything:
// Single note
note_on(s, 60);
// Chord
note_on(s, 60); note_on(s, 64); note_on(s, 67);
// Scheduled arpeggio over 4 beats
let ref = midi_ref();
let arp = [ref["C4"], ref["E4"], ref["G4"], ref["B4"]];
play_array(s, arp);Load multiple waveforms and blend between them with morph:
let s = wavsynth();
add_buf(s, "waves/sine.wav");
add_buf(s, "waves/saw.wav");
add_buf(s, "waves/square.wav");
set s:morph 0.0; // pure sine
set s:morph 0.5; // blend sine→saw
set s:morph 1.0; // pure saw (when 2 bufs) or square (when 3)
// Automate morph with a slow LFO in a comp
let morph_comp = comp() {
for {} {
set s:morph (lfo(0.05));
}
};let s = wavsynth();
add_buf(s, "perc/vocal.wav");
set s:mode 1;
set s:root 60; // sample was recorded at C4
set s:loop 0; // one-shot
set s:attack 5;
set s:release 400;
// Play a minor chord rooted at the sample's original pitch
note_on(s, 60);
note_on(s, 63);
note_on(s, 67);When you use ps you only see an overview of a sound generator. In order to view all parameters and their settings use info(<sound_generator_name>) e.g.
info(dx);info(mo);// Basic syntax
note_on(<instrument_name>, <note_number>);
// e.g.
// With velocity (0-127, default 100)
note_on(dx, 20, vel = 80);
// With duration in midi ticks (default 240, i.e. one 16th note)
note_on(dx, 20, dur = 1000);
// With both
note_on(dx, 20, vel = 120, dur = 2000);Every sound generator has dozens of parameters you can tweak:
let drums = drumsynth();
// See all available parameters
info(drums);
// Set a parameter
set drums:bd_vol 1.0; // Kick volume
set drums:bd_decay 200; // Kick decay time
set drums:bd_pitch_env_range 12; // Kick pitch sweep depth (semitones)
// Kick noise transient layer:
set drums:bd_noise_en 1; // Enable (0=off by default)
set drums:bd_nvol 0.6; // Noise volume
set drums:bd_ntone 10000; // Noise filter cutoff (Hz)
set drums:bd_nq 1; // Noise filter Q
// Kick fast pitch spike (adds laser-style attack transient):
set drums:bd_pitch_env2_range 38; // Semitone spike depth (0=off)
set drums:bd_pitch_env2_attack 1; // Spike attack (ms)
set drums:bd_pitch_env2_decay 8; // Spike decay (ms) — keep short, 5-15ms
// Kick chirp (exponential freq sweep mixed into attack):
set drums:bd_chirp_en 1; // Enable
set drums:bd_chirp_start 3000; // Start frequency (Hz)
set drums:bd_chirp_end 200; // End frequency (Hz)
set drums:bd_chirp_decay 10; // Sweep duration (ms)
set drums:bd_chirp_amp 0.316; // Amplitude (-10dB=0.316, -20dB=0.1)
// Kick FM modulator (Operator-style — modulates carrier frequency):
set drums:bd_mod_en 1; // Enable
set drums:bd_mod_freq 140; // Modulator frequency (Hz) — try ratio of carrier
set drums:bd_mod_index 200; // FM depth in Hz at peak (50=subtle, 2000=aggressive)
set drums:bd_mod_decay 200; // FM envelope decay (ms) — shorter = punchier attack
// Snare pitch envelope (upward sweep on attack — great for snap and crack):
set drums:sd_pitch_eg_depth 8; // Semitones of upward pitch sweep (0 = off)
set drums:sd_pitch_eg_decay 25; // How fast the sweep falls back (ms)
// Snare hi oscillator ratio (inharmonic = more metallic/tense body):
set drums:sd_hi_ratio 2.3; // Default 2.0; try 2.3/3.5 for SC-style character
// Snare parallel saturation (adds harmonic richness without losing transient):
set drums:sd_psat_en 1; // Enable
set drums:sd_psat_drive 4.47; // Pre-gain before tanh (13dB=4.47, higher=more sat)
set drums:sd_psat_blend 0.316; // Wet blend level (-10dB=0.316, lower=subtler)
// Hi-Hat / Open Hat (hh/oh share the same params, prefix with hh_ or oh_):
// The oscillator bank runs through a BPF (body) then HPF (cut lows).
// BPF resonance (midf_q) is the main tonal knob — higher = more metallic sizzle.
set drums:hh_decay 10; // Short = closed hat tight snap; try 150-400 for open hat feel
set drums:hh_sqamp 0.5; // Square bank amplitude
set drums:hh_midf 8000; // BPF centre (Hz) — shapes the metallic body frequency
set drums:hh_midf_q 3; // BPF resonance — 1=broad/airy, 5+=tight/metallic ping
set drums:hh_hif 7000; // HPF cutoff — raises this to thin out the hat
set drums:oh_decay 200; // Open hat — needs much longer decay than closed
set drums:oh_midf_q 2; // Open hat often benefits from slightly wider BPF
// Hand Clap — four-voice layered clap (TR-808 style, cp):
// Four noise voices fire in rapid succession. Voice 4 has a long decay
// that creates a natural reverb-like tail, as in the original TR-808.
set drums:cp_v2_delay 12; // Delay before voice 2 fires (ms)
set drums:cp_v2_vol 0.7; // Volume of voice 2
set drums:cp_v2_attack 15; // Voice 2 noise envelope attack (ms)
set drums:cp_v2_decay 150; // Voice 2 noise envelope decay (ms)
set drums:cp_v3_delay 20; // Delay before voice 3 fires (ms)
set drums:cp_v3_vol 0.6; // Volume of voice 3
set drums:cp_v3_attack 10; // Voice 3 noise envelope attack (ms)
set drums:cp_v3_decay 200; // Voice 3 noise envelope decay (ms)
set drums:cp_v4_delay 30; // Delay before voice 4 fires (ms)
set drums:cp_v4_vol 0.5; // Volume of voice 4
set drums:cp_v4_attack 10; // Voice 4 noise envelope attack (ms)
set drums:cp_v4_decay 400; // Voice 4 decay — longer = more reverb-like tail
// Sidechain from a specific drum voice (rather than full mix):
add_fx(inst, "sidechain", drums, "bd"); // bd/sd/cp/hh/oh/fm1/fm2/fm3/lz// List available presets
list_presets(<instrument name>);
// e.g.
list_presets(drums);
// Load a built-in preset
load_preset(drums, "TR909");
// Save your tweaked settings
save_preset(drums, "MY_KICKS");
// Load your custom preset
load_preset(drums, "MY_KICKS");SoundB0ard runs on a global clock synced via Ableton Link.
MIDI ticks are the lingua franca of time in SBShell. One loop — one bar — is always 3840 MIDI ticks long, regardless of BPM. . Ticks are used in all of the timing-aware functions in the language. note_on_at(inst, note, tick) and note_off_at(inst, note, tick) schedule note events at an exact offset from when they are evaluated (e.g. immediately from an interactive session, or at count % 3840 when evaluated in a Computation). sched(when, start_val, end_val, time_taken, "cmd") fires a smoothly interpolated automation: when is the tick to start, start_val and end_val are the range to sweep, time_taken is how long the sweep lasts in ticks, and "cmd" is a command string where % is replaced with the current interpolated value on every tick — for example sched(0, 0.8, 0.2, pp*16, "vol dx %") schedules immediately (when==0) a dx fadeout out over one bar.
// Set tempo (also syncs with other Link-enabled apps)
bpm(120);
// See current BPM via `ps` output.Within SBShell time is addressable in Midi ticks.
One loop, i.e. one bar, is 3840 midi ticks long. No matter what the BPM is, the midi clock will adjust to fill the space.
The most commonly addressed time division is a 16th, and 3840 / 16 = 240. This value is used so often I have it saved as a variable pp - 'pulses per'. This variable is set within the startup.sb file, which you can view and adjust yourself.
// pp = 240 midi ticks (one 16th note)
// pp * 2 = 8th note (480 ticks)
// pp * 4 = quarter note (960 ticks)
// pp * 16 = one bar (3840 ticks)
// (at 120 BPM, pp ≈ 125ms — but pp is always 240 ticks regardless of tempo)
### Scheduling Notes with note_on_at()
// Schedule notes in musical time
note_on_at(drums, 0, 0); // On the 1
note_on_at(drums, 1, pp * 4); // Beat 2
note_on_at(drums, 0, pp * 8); // Beat 3
note_on_at(drums, 1, pp * 12); // Beat 4While note_on() plays immediately, note_on_at() schedules notes at specific times:
// Syntax: note_on_at(instrument, note, time_in_midi_ticks, vel=, dur=)
note_on_at(drums, 0, 0); // Now
note_on_at(drums, 1, pp * 4); // One beat later
note_on_at(drums, 2, pp * 2); // Half beat later
// With swing (offset timing)
let swing = 15; // midi_ticks
note_on_at(drums, 2, pp * 1 + swing);
note_on_at(drums, 2, pp * 3 - swing);SoundB0ard automatically syncs with other apps via Ableton Link:
I've found arrays to be the most useful holder for patterns.
// manually create an array of vals
let pat = [1, 2, 5, 0];
// access via idx
pat[0]
1
// length of array
len(pat);
4
// first value
head(pat);
1
// rest of array
tail(pat);
[2, 5, 0]
// last value
last(pat);
0
// create an empty array of 16 values
rand_array(16, 0, 0);
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// create an range array of 16 values between 0 and 4 inclusive
rand_array(16, 0, 4);
[0, 4, 3, 2, 0, 4, 1, 4, 0, 4, 0, 3, 0, 1, 3, 0]
### Basic Pattern Arrays
```javascript
// 16-step kick pattern (1 = hit, 0 = rest)
let kicks = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0];
// Classic 2 and 4 snare
let snares = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0];
// Hi-hat 8ths
let hats = [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0];let drums = drumsynth();
let kicks = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0];
for (let i = 0; i < 16; i++) {
if (kicks[i] == 1) {
print(kicks[i]);
note_on_at(drums, 0, i * pp);
}
}// Human-feeling velocity variations
let vels = [110, 95, 105, 100, 90, 108];
let vx = 0;
for (let i = 0; i < 16; i++) {
if (kicks[i] == 1) {
note_on_at(drums, 0, i * pp, vel = vels[vx]);
vx = incr(vx, 0, len(vels));
}
}for (let i = 0; i < 16; i++) {
let offs = 40;
if (i % 2 == 0) {
offs = 0;
}
note_on_at(drums, 3, i * pp + offs);
}// Euclidean rhythm generator
let pat = bjork(5, 16); // 5 hits distributed over 16 steps
// Returns: [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0]
// random kick pattern
rand_beat();Computations (comp()) allow you to live code rather than working directly in the repl, and allow you to create longer and more complex processes.
I find the best way to work is to have a split terminal screen, with an open SBShell repl on my left, and a text editor (vi) on my right.
The structure of a Computation is based on Processing and Arduino, wherein you have two functions, a setup() and a run(). Setup creates your environment and runs once, setting inital values, and run() is called once on every loop, i.e. once every 3840 midi ticks at the top of the bar.
Open a file in a text editor and create computations.
$ vi SBTraxx/DEMO1.sb
let my_comp = comp()
{
setup()
{
let pat = [1, 0, 1, 0, 1, 0, 1, 0];
let pidx = 0;
}
run()
{
print("Pat value:", pat[pidx]);
pidx = incr(pidx, 0, len(pat));
}
}Then within your SBShell window, monitor that file:
SB#> monitor("SBTraxx/DEMO1.sb");
// now you can run it once:
SB#> my_comp()
Pat value:1Or you can assign it to a process position and it will run continually until you reset the process.
SB#> p1 # my_comp
SB#> Pat value:1
Pat value:0
Pat value:1
Pat value:0let drums = drumsynth();
load_preset(drums, "TR808");
let kick_comp = comp()
{
setup()
{
let kicks = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0];
}
run()
{
for (let i = 0; i < 16; i++) {
if (kicks[i] == 1) {
note_on_at(drums, 0, i * pp, vel = 100);
}
}
}
}
p2 # kick_complet snare_comp = comp()
{
setup()
{
let s1 = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0];
let s2 = [0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0];
let s3 = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1];
}
run()
{
let spat = s1;
# every 4th bar
if (count % 4 == 3) {
spat = s2;
}
if (count % 8 == 7) {
spat = s3;
}
for (let i = 0; i < 16; i++) {
if (spat[i] == 1) {
note_on_at(drums, 1, i * pp, vel = 100 + rand(20));
}
}
}
}There is a global variable called 'count' which is incremented for every bar.
You can use this to make decisions over time, using a modulo operation, i.e. every 2nd bar would be count % 2 == 0 or on the last bar of an 8 bar loop would be count % 8 == 7.
run()
{
print("This is bar:", count);
// Do different things on different bars
if (count % 8 == 0) {
print("New 8-bar section!");
}
if (count == 16) {
// drop the beat
}
}let evolving_comp = comp()
{
setup()
{
let base_pattern = [1, 0, 0, 0, 1, 0, 0, 0];
let variation = 0;
}
run()
{
// Every 4 bars, add more randomness
if (count % 4 == 0) {
variation = variation + 5;
}
for (let i = 0; i < 16; i++) {
if (base_pattern[i % 8] == 1) {
// Randomly offset timing
let offset = rand(variation) - variation/2;
note_on_at(drums, 0, i * pp + offset);
}
}
}
}let drums = drumsynth();
let bass = fmsynth();
let pad = subsynth();
// Set volumes (0.0 to 1.0+)
vol drums 0.9;
vol bass 0.7;
vol pad 0.4;
// Pan in stereo field (-1.0 = left, 0 = center, 1.0 = right)
pan drums 0.0; // Center
pan bass -0.3; // Slightly left
pan pad 0.4; // RightRoute individual generators into the mixer-level delay, reverb, or DJ EQ (slot 2, still named "distort"). Note the argument order: send takes the effect first, set_send takes the generator first.
send("delay", dx) // send dx to delay at default level (0.4)
send("reverb", dx, 0.8) // send dx to reverb at 80%
send("reverb", [dx, sbdrum]) // send several generators at once
send("distort", dx, 0.5) // send dx to mixer slot 2 (DJ EQ) at 50%
set_send(dx, "delay", 0.6) // update dx delay send level to 60%
set_send(dx, "reverb", 0.0) // remove dx from reverb sendset_send accepts "delay"/"dly", "reverb"/"rev", "distort"/"dst" or 0/1/2.
ps shows each generator's active sends under its entry:
dx = FmSynth(OCWARF2) vol:0.80 pan:0.00 algo:0
sends: dly:0.40 rev:0.80
The ps header also shows a sends: sub-line under each mixer FX listing which generators route to it and at what level.
spectrum(gen, bands) takes a snapshot of what a sound generator is playing right now and returns its frequency content as an array of levels from 0.0 to 1.0, lowest frequencies first. Use it to drive other parameters from the music, e.g. push a delay's feedback up when the loop gets bright.
let s = spectrum(per, 8); // [low ... high], each 0.0-1.0
print(s);bandsis 8, 16 or 32 (default 16). Bands are log-spaced from about 86 Hz to 22 kHz, so each band covers roughly the same musical range.- Each level is the loudest frequency in that band: -60 dB or quieter is 0, a full-scale tone is 1.
- It analyses the last 512 samples (about 12 ms), so it reacts to whatever is sounding at that instant. Low frequencies are coarse at this size: the bottom band starts around 86 Hz.
- Returns all zeros if audio isn't running.
Call it from a computation to update a control once per bar:
let follow = comp()
{
setup()
{
}
run()
{
let s = spectrum(per, 8);
set mixer:delay:fb 20 + s[6] * 60; // brighter loop -> longer delay tail
}
}
p5 # follow;To see the spectrum, draw it with draw_array (see Signal Visualization). This comp redraws an 8-row analyzer pinned to the top of the terminal once per bar:
let analyzer = comp()
{
setup()
{
}
run()
{
draw_array(spectrum(per, 16), 8, "per", row=1);
}
}
p6 # analyzer;When you start computations, assign them to process IDs (p1-p99) to control them:
let kick_comp = comp() { /* ... */ }
let snare_comp = comp() { /* ... */ }
let hats_comp = comp() { /* ... */ }
// Assign to processes
p31 # kick_comp; // Start on process 31
p32 # snare_comp; // Start on process 32
p33 # hats_comp; // Start on process 33
// Stop a process
p32 # ""; // Stop snares
// Restart it
p32 # snare_comp; // Bring snares backA < process runs a command string on a timer, replacing % with a value. It's a quick way to automate a parameter without writing a computation:
p1 < <timer> <bars> "<values>" "<command with %>"| Timer | Values | Behaviour |
|---|---|---|
osc |
"start end" |
Sweeps from start to end over bars, then back again, forever |
ramp |
"start end" |
Sweeps from start to end over bars, then holds at end |
over |
"start end" |
Sweeps from start to end over bars, then jumps back to start and repeats |
every |
"v1 v2 ..." |
Runs the command once every bars bars, at the top of the bar, cycling through the values |
p1 < osc 8 "0.1 0.6" "vol hh %"; // hats breathe in and out
p2 < ramp 16 "0 0.8" "global_reverb %"; // slow reverb build
p3 < every 3 "1" "set per:stutter %"; // stutter the loop every 3rd bar
p4 < every 2 "1 2" "set per:pitch %"; // alternate pitch 1, 2, 1, 2... every 2 barsosc, ramp and over send the command on every MIDI tick, so they suit continuous parameters like volume, pan or filter cutoff. every fires once per N bars, so use it for one-shot actions like stutter, scramble or switching values. A new process starts at the top of the next bar, and every fires on that first bar. Stop a process with p1 $ "";.
let main_comp = comp()
{
setup()
{
let bar = 0;
}
run()
{
// Build up arrangement over time
if (bar == 0) {
p31 # kick_comp;
p32 # snare_comp;
}
if (bar == 4) {
p33 # hats_comp;
}
if (bar == 8) {
p34 # bass_comp;
}
if (bar == 16) {
// Drop everything except kick
p32 # "";
p33 # "";
p34 # "";
}
if (bar == 20) {
// Bring it all back
p32 # snare_comp;
p33 # hats_comp;
p34 # bass_comp;
}
print("Section bar:", bar);
bar++;
}
}SoundB0ard has full MIDI input support: play instruments live from a keyboard, record what you play, and convert the recording into arrays for use in patterns.
midi_init() // open first available MIDI device
midi_assign(dx) // route incoming notes to generator dx
midi_print() // toggle printing of all incoming MIDI events (debug)Bind any MIDI CC to a generator parameter:
midi_map(74, "cutoff") // CC74 controls filter cutoff
midi_map() // no args: print current mappingsmidi_bars(2) // set recording length in bars (default 2)
midi_rec() // start recording — loop auto-enables so you hear playback while recordingWhile in record mode the prompt changes to ⏺ REC> and a flashing dot appears in the status bar.
Keyboard shortcuts:
| Key | Action |
|---|---|
SPACE |
Toggle recording on/off |
` or ESC |
Exit record/loop mode, return to normal prompt |
midi_loop() // toggle looped playback
midi_stop() // stop playback entirely
midi_reset() // clear recording buffermidi_quantize(16) // snap all recorded notes to 16th-note grid
midi_fix(m) // hard-snap a MidiArray to 8th-note gridlet m = midi_dump() // get full recording (all bars) as MidiArray
let arr = midi2array(m) // convert to [notes_arrays, durs_arrays]
// arr[0] = array of per-bar note arrays
// arr[1] = array of per-bar duration arrays
let bar0_notes = arr[0][0] // 16-element note array for bar 0 (0 = rest)
let bar0_durs = arr[1][0] // 16-element duration array for bar 0 (ticks)
midi_at(m, 0) // MIDI note number at bar 0midi2note(60) // → "C4"
midi2freq(60) // → 261.63
freq2midi(440) // → 69
midi_ref() // print full note-name reference tablemidi_init()
let dx = fmsynth()
midi_assign(dx) // play dx from keyboard
midi_bars(2) // 2-bar loop (default)
midi_rec() // start — SPACE toggles rec, ` / ESC to exit
midi_quantize(16) // snap to 16ths
let m = midi_dump()
let arr = midi2array(m)
let notes = arr[0][0] // extract bar 0 note pattern
let durs = arr[1][0] // extract bar 0 duration patternThere are a number of FX that can be added to each sound generator, and there are also 3 mixer level FX that can routed to.
Use the add_fx(<soundgenerator name>, "<fx_name>") command to add effects to any instrument:
add_fx(sbdrum, "distort");
add_fx(dx, "delay");
add_fx(dx, "reverb");
// view the effects under the soundgenerator when you view `ps`:
ps
dx = FmSynth(mo_jazz) vol:0.80 pan:0.00 algo:7
fx0 Delay! ms:23 fb:0 rat:0 mx:0.5 mode:norm sync:0 sync_len:1/16
fx1 Reverb! predelayms:40 reverbtime:100 wetmx:20
// you can address any parameter via the syntax <soundgenerator_name>:fx<num>:<param_name>
// e.g.
set dx:fx0:ms 10;
set dx:fx1:predelayms 100;
// Multiple effects process in orderclear_fx removes all effects from one or more sound generators, so the next add_fx is fx0 again. With no arguments it clears every sound generator.
clear_fx(dx); // just dx
clear_fx(dx, dx3); // several at once
clear_fx(); // every sound generatorEffects are numbered in the order they were added, so set dx:fx2:freq only points at the right effect if dx had nothing on it before. When you load one track after another in a live set, or monitor() re-runs init() every time you save, effects from earlier runs pile up and shift the numbers. Start each track's init() with clear_fx to keep the numbering predictable:
let init = fn()
{
clear_fx(dx, dx3);
add_fx(dx, "delay"); // dx:fx0
add_fx(dx, "reverb"); // dx:fx1
add_fx(dx, "filter"); // dx:fx2
set dx:fx2:freq 80;
}Only clear the instruments the track sets up itself: anything from the previous track that is still playing keeps its effects.
add_fx(inst, "distort");
set inst:fx0:mode 0-3; // Algorithm selection
set inst:fx0:threshold 0.01-1.0; // Clipping point
set inst:fx0:drive 1.0-10.0; // Input gainModes:
- 0: HARD_CLIP - Brick wall limiter (classic)
- 1: SOFT_CLIP - Smooth tanh saturation
- 2: TUBE - Asymmetric tube warmth
- 3: FOLDBACK - Wavefold/foldback distortion
add_fx(inst, "waveshape");
set inst:fx0:k_pos 0.1-20; // Positive curve shaping
set inst:fx0:k_neg 0.1-20; // Negative curve shaping
set inst:fx0:stages 1-10; // Number of stages
set inst:fx0:invert 0-1; // Invert alternate stagesadd_fx(inst, "lofi"); // Also: bitcrush, decimate, lofi_crusher
set inst:fx0:bitdepth 1-16; // Bit depth
set inst:fx0:sample_hold 0.0-1.0; // Sample rate
set inst:fx0:destruct 0.0-1.0; // Destruction amountExamples:
- Vintage lo-fi:
bitdepth=12, sample_hold=0.9, destruct=0.0 - Extreme crush:
bitdepth=3, sample_hold=0.2, destruct=0.8
add_fx(inst, "delay");
set inst:fx0:ms 1-10000;
set inst:fx0:fb 0-100;
set inst:fx0:rat 0.1-0.9;
set inst:fx0:mx 0.0-1.0; // Wet/dry mix
set inst:fx0:mode 0;
set inst:fx0:sync 1;
set inst:fx0:sync_len 1;Chorus/flanger effects via modulated delay time.
add_fx(inst, "moddelay");
set inst:moddelay_time 0-1000; // Base delay (ms)
set inst:moddelay_feedback 0-99; // Feedback %
set inst:moddelay_mod_depth 0-100; // Modulation depth
set inst:moddelay_mod_rate 0.0-20.0; // Modulation rate (Hz)add_fx(inst, "reverb");
set inst:reverb_roomsize 0.0-1.0; // Room size
set inst:reverb_damping 0.0-1.0; // HF damping
set inst:reverb_wet 0.0-1.0; // Wet/dry mix
set inst:reverb_width 0.0-1.0; // Stereo widthAdvanced delay/reverb hybrid.
add_fx(inst, "transverb");
set inst:transverb_time 0-2000; // Delay time (ms)
set inst:transverb_feedback 0-99; // Feedback %
set inst:transverb_diffusion 0.0-1.0; // Diffusion amountLandmark-based waveform manipulation for creative sound design.
add_fx(inst, "sculptor"); // Also: geometer, waveform_sculptor
set inst:waveform_sculptor_window_size 256-4096; // Window size
set inst:waveform_sculptor_landmark_style 0-4; // Detection method
set inst:waveform_sculptor_interp_style 0-5; // Recreation method
set inst:waveform_sculptor_wet_mix 0.0-1.0; // Wet/dryLandmark Styles:
- 0: ExtNCross (extremities & zero-crossings)
- 1: Span (amplitude-based)
- 2: DyDx (derivative/slope)
- 3: Freq (regular frequency)
- 4: Random
Interpolation Styles:
- 0: Polygon (straight lines, lo-fi)
- 1: Wrongygon (backwards, harsh)
- 2: Sing (sine waves, tonal)
- 3: Reversi (reverse intervals)
- 4: Smoothie (smooth curves)
- 5: Pulse (just pulses)
Multi-stage diffusion/blur/reverb hybrid.
add_fx(inst, "diffuser"); // Also: nnirror
set inst:diffuser_wet 0.0-1.0; // Wet/dry mix
set inst:diffuser_size 0.0-1.0; // Size/length
set inst:diffuser_feedback 0.0-1.0; // Feedback
set inst:diffuser_unison 0.0-1.0; // Unison voices
set inst:diffuser_diffuse0 0.0-1.0; // Diffusion stage 1
set inst:diffuser_diffuse1 0.0-1.0; // Diffusion stage 2
set inst:diffuser_diffuse2 0.0-1.0; // Diffusion stage 3
set inst:diffuser_diffuse3 0.0-1.0; // Diffusion stage 4add_fx(inst, "compressor");
set inst:compressor_threshold -60-0; // Threshold (dB)
set inst:compressor_ratio 1.0-20.0; // Compression ratio
set inst:compressor_attack 0.1-500; // Attack (ms)
set inst:compressor_release 10-5000; // Release (ms)
set inst:compressor_knee_width 0-20; // Knee width (dB)add_fx(inst, filter);
set inst:basicfilter_cutoff 20-20000; // Cutoff (Hz)
set inst:basicfilter_resonance 0.0-10.0; // Resonance/QFilter with LFO modulation.
add_fx(inst, modfilter);
set inst:modfilter_cutoff 20-20000; // Base cutoff (Hz)
set inst:modfilter_resonance 0.0-10.0; // Resonance/Q
set inst:modfilter_mod_depth 0-5000; // Modulation depth (Hz)
set inst:modfilter_mod_rate 0.0-20.0; // Modulation rate (Hz)A classic DJ mixer EQ: a high-pass filter on the low end and a low-pass filter on the high end. Both bands start fully open (no filtering). Raise lo to cut bass; lower hi to cut treble. Useful for live filtering, transitions, and layering.
add_fx(inst, "djeq");
// lo: HPF cutoff — default 80Hz (wide open, bass passes through)
// Raise this to roll off bass:
set inst:fx0:lo 500; // Cut sub and bass frequencies
set inst:fx0:lo 2000; // Aggressive bass cut
// hi: LPF cutoff — default 18000Hz (wide open, treble passes through)
// Lower this to roll off treble:
set inst:fx0:hi 8000; // Gentle treble cut
set inst:fx0:hi 2000; // Dark/filtered soundExamples:
// Filter a drum bus for a build-up/drop
add_fx(sbdrum, "djeq");
set sbdrum:fx0:lo 80; // Open (full bass)
set sbdrum:fx0:hi 18000; // Open (full treble)
// Automate a low cut sweeping in over 4 bars for a breakdown
sched(0, 80, 2000, pp*64, "set sbdrum:fx0:lo %");
// Quick mid-only filter (cut both bass and treble)
set sbdrum:fx0:lo 400;
set sbdrum:fx0:hi 6000;
// Use from a comp to filter on specific bars
let eq_comp = comp()
{
setup()
{
add_fx(sbdrum, "djeq");
}
run()
{
// Open on bar 1, filtered breakdown on bar 5
if (count % 8 == 0) {
set sbdrum:fx0:lo 80;
set sbdrum:fx0:hi 18000;
}
if (count % 8 == 4) {
set sbdrum:fx0:lo 600;
set sbdrum:fx0:hi 5000;
}
}
}Multiple effects are processed in the order they're added:
let lead = fmsynth();
add_fx(lead, "distort"); // 1st: Distortion
add_fx(lead, "moddelay"); // 2nd: Modulated delay
add_fx(lead, "reverb"); // 3rd: ReverbGlobal sends route the entire dry mix into the mixer-level reverb and delay — on top of any per-generator sends. Use them for whole-mix effects like a big reverb wash or a feedback swell.
global_reverb(0.5) // send 50% of dry mix into global reverb (0.0-1.0)
global_delay(0.3) // send 30% of dry mix into global delay (0.0-1.0)
global_distort(0.2) // send 20% of dry mix into global distortion (0.0-1.0)
global_reverb_fb(0.6) // feed reverb output back into reverb (0.0-0.98)
global_delay_fb(0.5) // feed delay output back into delay (0.0-0.98)
global_distort_fb(0.4) // feed distort output back into distort (0.0-0.98)ps shows all active global sends and per-FX routing in the header block.
Feedback adds a blooming, self-reinforcing tail — keep it below 0.98 to avoid runaway buildup. Setting both send and feedback to 0 turns the global send off silently.
Reverb swoosh effect — slowly pull everything into a reverb cloud and back:
sched(0, 0.0, 1.0, pp*32, "global_reverb %") // fade in over 2 bars
global_reverb_fb(0.65) // bloom the tail
sched(pp*32, 1.0, 0.0, pp*32, "global_reverb %") // fade back out
global_reverb_fb(0.0) // clean tailSoundB0ard has a full programming language built in.
// Declare with let
let x = 5;
let name = "kick";
let pattern = [1, 0, 1, 0];
// Variable scope:
// - Top-level: global, persists across commands
// - Inside setup(): persists for that computation
// - Inside run(): local to that barReserved words: Don't use keywords (let, if, for, etc.) or process IDs (p1, p2, p31, etc.) as variable names!
if (count % 4 == 0) {
print("New 4-bar section");
}
if (count < 8) {
// Intro
} else if (count < 16) {
// Verse
} else {
// Chorus
}// For loop
for (let i = 0; i < 16; i++) {
note_on_at(drums, 2, i * pp);
}// Random
rand(10); // Random 0-9
rand(20) - 10; // Random -10 to 9
// Increment with wrapping
let x = 0;
x = incr(x, 0, 5); // Increments 0->1->2->3->4->0->1...
// Decrement with wrapping
let offset = 0;
offset = rincr(offset, -40, 40); // Decrements, wrapping at min
// Drunk walk (+1 or -1 randomly, with wrapping)
offset = dincr(offset, -40, 40);
// Array operations
len(array); // Length
push(arr, val); // Add element
min(a, b); // Minimum
max(a, b); // Maximum
// Euclidean rhythms
bjork(5, 16); // 5 hits over 16 steps
// L-system sequence expansion
lsys_expand(axiom, rules, generations);lsys_expand(axiom, rules, generations) generates a melodic sequence using a Lindenmayer system — a rewriting system that recursively expands symbols according to production rules. Each generation multiplies the length by the rule width, producing self-similar sequences that feel varied without being random.
- axiom — starting array of symbol integers (note indices into a chord or scale array)
- rules — array of arrays;
rules[i]is the expansion for symboli - generations — how many times to apply the rules (3–4 is typical; capped at 6)
- returns — flat array of ints; use
% len(seq)to fit any bar length
// Build a Cmaj9 chord — 5 notes gives the richest L-system variety
let notes = notes_in_chord(48, 36, 5); // [C3, E3, G3, B3, D4]
// indices: 0 1 2 3 4
// Rules: each index expands to a 3-note subsequence
let rules = [
[0, 2, 4], // root → root, 5th, 9th
[1, 3, 0], // 3rd → 3rd, 7th, root
[2, 4, 1], // 5th → 5th, 9th, 3rd
[3, 0, 2], // 7th → 7th, root, 5th
[4, 1, 3], // 9th → 9th, 3rd, 7th
];
// Gen 3 → 27 notes; gen 4 → 81 notes
let seq = lsys_expand([0], rules, 3);
let arp = comp() {
setup() {
let rate = pp; // 16th notes — try pp*2 for 8ths
}
run() {
for (let i = 0; i < 16; i++) {
note_on_at(dx, notes[seq[i % len(seq)]], i * rate, dur=180);
}
}
}
p1 # arp;Variations:
// Start from two notes — expands more rapidly
let seq = lsys_expand([0, 4], rules, 3);
// Change chord each bar — same structure, new pitches
run() {
let notes = notes_in_chord(48 + (count % 4) * 5, 36, 5);
for (let i = 0; i < 16; i++) {
note_on_at(dx, notes[seq[i % len(seq)]], i * pp, dur=160);
}
}
// 8th-note arp over 2 bars
for (let i = 0; i < 16; i++) {
note_on_at(dx, notes[seq[i % len(seq)]], i * pp * 2, dur=350);
}Tips:
- More chord tones = richer rules. 9th chords (mod=5) give 5 symbols; triads give 3.
- Rules with wider expansions (4 notes each) grow faster — gen 3 gives 64 notes.
seqis just an array — reuse it for velocity or parameter automation, not just pitch.
// Print to console
print("Value:", x);
print("Bar:", count, "Pattern:", pattern);
// Check instrument state
info(drums);A phasor is a cyclic ramp signal that counts from 0.0 to 1.0 over a fixed number of MIDI ticks, then wraps back to 0 and repeats. It's the core primitive for LFO-style automation, sub-oscillators, and rhythmically synced parameter sweeps.
let ph = phasor(steps);
steps is the cycle length in MIDI ticks. At the default resolution (PPBAR = 3840 ticks per bar):
steps |
Speed |
|---|---|
3840 |
One bar |
1920 |
Half bar |
960 |
Quarter bar (one beat at 120 bpm) |
7680 |
Two bars |
3840 * 1.5 |
Dotted bar |
Phasors created in setup() persist for the lifetime of the comp. Phasors created in run() reset each bar.
Call signal_from(ph) inside a scheduled for loop to read the phase value at each tick:
comp my_comp {
setup() {
let ph = phasor(3840);
}
run() {
for i in range(0, pplooplen) {
let sig = signal_from(ph);
set sbs:morph sig at=i; // automate morph from 0→1 each bar
}
}
}
change_steps(ph, new_steps);
Changes the cycle length without resetting the phase — useful for live tempo changes or harmonic retuning:
change_steps(ph, 3840 * 2); // slow to 2-bar cycle
change_steps(ph, 3840 / 3); // speed up to 1/3 bar
reset(ph); // snap phase back to 0.0
These closures are defined in startup.sb so they are always available. Each factory call returns a new independent stateful function — closures capture their own private variables that persist across every run() call.
let ramp2slope_factory = fn() {
let history_val = 0; // captured in closure — persists forever
fn(newval) {
let delta = newval - history_val;
history_val = newval;
if (delta < -0.5) { return 1 + delta; } // handle 1→0 wrap-around
if (delta > 0.5) { return 1 - delta; }
return delta;
}
}Returns fn(sig) that computes the per-tick rate of change of the ramp. Normally returns ~1/steps per tick (e.g. ~0.00026 for a 3840-tick phasor). The wrap-around correction means when the ramp resets from 1.0 to 0.0, you get a small positive value instead of a large negative spike. Used internally by ramp_div_factory.
let slope = ramp2slope_factory();
let ds = slope(sig); // ~0.00026/tick; near 0 at wrap boundarylet ramp_div_factory = fn() {
let normalize_ramp = ramp2slope_factory(); // captures its own slope fn
let accum = 0; // phase accumulator — persists
return fn(sig_val, ratio) {
let val = normalize_ramp(sig_val) / ratio;
accum = accum + val;
if (accum >= 1) { accum = accum - 1; }
if (accum < 0) { accum = accum + 1; }
return accum;
}
}Returns fn(sig, ratio) that runs a new 0→1 ramp at ratio of the master ramp speed. Each tick: the master's slope is divided by ratio and added to accum. When accum crosses 1 it wraps — that wrap is the trigger event. Because accum lives in setup() it carries phase across bar boundaries.
| ratio | cycles/bar | ticks/cycle | effect |
|---|---|---|---|
1/3 |
3 | 1280 | 3 evenly-spaced triggers |
1/5 |
5 | 768 | 5 even triggers |
3/8 |
2.67 | 1440 | phase drifts — pattern cycles over 3 bars |
3/15 |
5 | 768 | equivalent to 1/5 |
Irrational or non-integer ratios (e.g. 3/8) produce patterns that shift across consecutive bars before repeating. This gives euclidean-like rhythmic variation without a discrete grid.
let div1 = ramp_div_factory(); // each call is independent
let div2 = ramp_div_factory();
// inside run() for loop:
let s1 = div1(r, 1/3); // 3 even triggers per bar
let s2 = div2(r, 3/8); // ~2.67/bar, evolves over 3 barslet ramp2trigger_factory = fn() {
let history_val = 0;
fn(newval) {
let delta = abs(newval - history_val);
history_val = newval;
if (delta > 0.5) { return true; }
return false;
}
}Returns fn(sig) that returns true exactly once per ramp cycle, at the moment the accumulator wraps from ~1.0 back to ~0.0. The threshold of 0.5 catches any jump larger than half the ramp range — which only happens on a wrap. Combine with ramp_div to fire notes at any rhythmic subdivision.
let trig = ramp2trigger_factory();
if (trig(sub)) { note_on_at(sg, 60, i, vel=80, dur=120); }Phasors at integer ratios of a base period create harmonic relationships. Pass the ratio as a multiplier to phasor():
let base = 3840;
let ph1 = phasor(base); // fundamental
let ph2 = phasor(base * 2/3); // 3/2 ratio (perfect fifth relationship)
let ph3 = phasor(base / 2); // octave (double speed)
Read multiple phasors in a for loop and fan notes across the stereo field:
comp stereo_spread {
setup() {
let phs = [phasor(3840), phasor(1920), phasor(960)];
}
run() {
for i in range(0, pplooplen) {
let sig = signal_from(phs[0]);
pan sbs sig * 2 - 1 at=i; // -1.0 to +1.0
}
}
}
Combine with draw_plot to see the signal live:
for i in range(0, pplooplen) {
let sig = signal_from(ph);
draw_plot(sig, 80, "ph", at=i, row=1);
}
SoundB0ard has two built-in functions for visualizing signals and values in the terminal in real time: draw_bar (horizontal bar graph) and draw_plot (rolling sparkline). Both update in-place without scrolling, and can be scheduled tick-accurately inside for loops using at=.
Renders a coloured bar proportional to a value between 0.0 and 1.0. Green below 0.5, yellow below 0.8, red at 0.8+.
// Immediate — updates in place on the current line
draw_bar(val); // Minimal
draw_bar(val, 60, "vol"); // Width 60, labelled "vol"
// Scheduled — fire at tick i inside a for loop
for (let i = 0; i < pplooplen; i++) {
let v = signal_from(my_phasor);
draw_bar(v, 80, "ramp", at = i);
}Maintains a rolling buffer per label and renders it as a Unicode sparkline (▁▂▃▄▅▆▇█). Each call appends the latest value; old values scroll left off the edge.
// Immediate
draw_plot(val, 80, "lfo");
// Scheduled — visualize a signal at every stride tick across a bar
let plot_width = 120;
let stride = 3840 / plot_width;
for (let i = 0; i < 3840; i++) {
let sig = signal_from(my_phasor);
if (i % stride == 0) {
draw_plot(sig, plot_width, "phasor", at = i);
}
}Draws a whole array of 0.0-1.0 values at once, one cell per element, e.g. the output of spectrum(). The first number is the height in rows: 1 (the default) gives a one-line sparkline with each cell coloured by its level; more rows give a level-meter style bar chart that's green at the bottom, yellow higher up and red at the top. Values outside 0-1 are clamped and non-numbers count as 0.
draw_array([0.1, 0.5, 0.9, 0.3]); // one line
draw_array(spectrum(dx, 16), 8, "dx", row=1); // 8-row analyzer pinned at the top
draw_array(spectrum(sbdrum, 8), 4, "drums", row=10); // a second one below itIt takes the same row= and at= as draw_plot. With row=N it takes up rows N to N+height-1, so leave that much space between stacked displays. Without row= it redraws in place at the cursor.
Both functions accept a row=N keyword argument that pins the output to N lines above the cursor. This lets you stack multiple visualizations without them overwriting each other.
// Three signals displayed simultaneously on separate rows
for (let i = 0; i < 3840; i++) {
let sig1 = signal_from(ph1);
let sig2 = signal_from(ph2);
let sig3 = ramp_div1(sig1, 1/4);
if (i % stride == 0) {
draw_plot(sig1, plot_width, "raw", at = i, row = 1);
draw_plot(sig2, plot_width, "div2", at = i, row = 2);
draw_plot(sig3, plot_width, "div4", at = i, row = 3);
}
}This is the pattern from SBTraxx/phazor_example.sb. A master bar-length ramp drives two dividers; a third divider is modulated by the second (FM-style ratio control). Triggers fire notes; draw_plot shows all four signals stacked on fixed rows.
let phazor_draw = comp()
{
setup()
{
let fullramp = phasor(3840); // master 1-bar ramp
let ramp_div1 = ramp_div_factory();
let ramp_div2 = ramp_div_factory();
let ramp_div3 = ramp_div_factory();
let ramp2tri1 = ramp2trigger_factory();
let ramp2tri2 = ramp2trigger_factory();
let plot_width = 80; // match terminal width
let stride = 3840 / plot_width; // 48 ticks per column
}
run()
{
for (let i = 0; i < 3840; i++) {
let r = signal_from(fullramp);
let s1 = ramp_div1(r, 3/8); // ~2.67 cycles/bar; drifts
let s2 = ramp_div2(r, 3/15); // 5 cycles/bar (= 1/5)
let mod_ratio = max(s2, 0.001); // avoid div/0
let s3 = ramp_div3(s1, mod_ratio); // s1 speed modulated by s2
if (ramp2tri1(s1)) { note_on_at(sg, 60, i, vel=80, dur=120); }
if (ramp2tri2(s2)) { note_on_at(sg2, 64, i, vel=60, dur=80); }
if (i % stride == 0) {
draw_plot(r, plot_width, "r ", at=i, row=1);
draw_plot(s1, plot_width, "s1 ", at=i, row=2);
draw_plot(s2, plot_width, "s2 ", at=i, row=3);
draw_plot(s3, plot_width, "s3 ", at=i, row=4);
}
}
}
}Key points:
- Each
ramp_div_factory()call is independent — they share the master ramp as input but hold separate accumulators. ramp2trigger_factoryfires when the divider output wraps (delta > 0.5), not on the master ramp.draw_plotwithat=iandrow=Npins each signal to a fixed terminal row.Ctrl-Lparks the cursor below all active rows automatically.- Width 80 with
stride = 3840/80 = 48means one column per 48 ticks — a full bar maps cleanly to the terminal width.
Tips:
- Leave at least
row=+ 1 blank lines at the bottom of your terminal before the prompt so the display doesn't overlap it - Labels are included in the sparkline line so keep them the same length (pad with spaces) for alignment
draw_baris ideal for slow-changing values (volume, filter cutoff);draw_plotis better for signals that evolve over a bar
Move beyond the REPL and write reusable scripts.
Create files with .sb extension in the SBTraxx/ directory:
my_beat.sb:
# This is a comment
let drums = drumsynth();
load_preset(drums, "TR808");
let kick_comp = comp()
{
setup()
{
let kicks = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0];
}
run()
{
for (let i = 0; i < 16; i++) {
if (kicks[i] == 1) {
note_on_at(drums, 0, i * pp, vel = 100);
}
}
}
}Then from the command shell, start monitoring the file:
SB#> monitor("SBTraxx/my_beat.sb");This imports all the contents, so your variables, Computations and functions are availble. You can then either run it once with it's name.
SB#> kick_comp();or you can assign it to one of the Process slots with:
p1 # kick_comp;This will evaluate the run() function at the start of every bar. Now that you're monitoring the file, you can edit and resave the file, and it will automatically be reloaded.
SoundB0ard/
├── SBTraxx/ # Your tracks and patterns
│ ├── my_beat.sb
│ └── bass_lines.sb
├── settings/ # Presets
│ ├── drumpresets.dat
│ └── BEAT_STYLE_DRUM_KITS.txt
└── wavs/ # Sample library
├── bd/
├── sd/
└── vox/
Edit startup.sb in the root directory - it runs automatically on launch:
// startup.sb - auto-loads on startup
print("Welcome to my SoundB0ard setup!");
bpm(120);
// Load your default instruments
let drums = drumsynth();
load_preset(drums, "TR808");// Sound Generators
drumsynth() // Drum machine
dxsynth() // FM synth
subsynth() // Subtractive synth
loop("path") // Granular looper
sample("path") // Load audio sample
// Playback
note_on(inst, note, vel, dur)
note_on_at(inst, note, time, vel, dur)
// Parameters
set inst:param value
set inst:fx0:param value
info(inst)
// Presets
load_preset(inst, "NAME")
save_preset(inst, "NAME")
list_presets(inst)
// Mixing
vol inst value
pan inst value
send("delay", inst, level) // Route generator to mixer delay (default 0.4)
send("reverb", inst, level) // Route generator to mixer reverb
send("distort", inst, level) // Route generator to mixer slot 2 (DJ EQ)
set_send(inst, fx, level) // Update send level after routing
global_reverb(amt) // Global reverb send (0.0-1.0)
global_delay(amt) // Global delay send (0.0-1.0)
global_distort(amt) // Global distortion send (0.0-1.0)
global_reverb_fb(amt) // Global reverb feedback (0.0-0.98)
global_delay_fb(amt) // Global delay feedback (0.0-0.98)
global_distort_fb(amt) // Global distortion feedback (0.0-0.98)
// MIDI
midi_init() // Open MIDI device
midi_assign(inst) // Route MIDI notes to instrument
midi_bars(n) // Set recording length in bars (default 2)
midi_rec() // Toggle recording (auto-enables loop)
midi_quantize(16) // Snap recording to 16th-note grid
midi_dump() // Get recording as MidiArray
midi2array(m) // Convert to [notes_arrays, durs_arrays]
// Timing
bpm(tempo)
pp // Pulses per 16th note
// Patterns
comp() { setup() {} run() {} }
p31 # comp_name
count // Current bar numberBasic Four-on-Floor:
let kicks = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];2 and 4 Snare:
let snares = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0];Eighth-Note Hats:
let hats = [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0];Euclidean Rhythm (5 hits in 16 steps):
let pattern = bjork(5, 16);Ctrl+D - Exit SoundB0ard
↑/↓ - Command history
// MIDI record mode (enter with midi_rec()):
SPACE - Toggle recording on/off (prompt shows ⏺ REC> when active)
` or ESC - Exit record/loop mode, return to normal prompt
wavs/
├── bd/ - Bass drums (kick8.aif, wuk77.aiff, etc.)
├── sd/ - Snares (2snare.aif, etc.)
├── cp/ - Claps (clap17.aif, kNackr.aiff, etc.)
├── ch/ - Closed hi-hats (2hat.aif, etc.)
├── oh/ - Open hi-hats
├── perc/ - Percussion (uus.wav, chezShaker.aiff, etc.)
├── vox/ - Vocals and voice samples
└── noises/ - Sound effects (powerup.wav, etc.)
Type help <command> for any built-in function's usage, arguments and an example (e.g. help sched), and help commands to list them all by category. help on its own lists the broader topics.
Functions by category:
- Arrays: len, append, bjork
- Math: min, max, abs, pow, sqrt
- Random: rand, rincr
- Control: incr, dincr
- Timing: note_on, note_on_at, bpm
- Sound: drum, sample, dxsynth, subsynth
- Effects: set, getparam
- File I/O: run, track, save_preset, load_preset
- Debug: print, info
- If you got this far, wow! Hit me up!



