A lightweight OSC transport layer for communicating with SuperCollider from Elixir.
Waveform provides low-level OSC messaging, node/group management, and a simple API for triggering synths. Perfect for live coding, algorithmic composition, and building custom audio applications on top of SuperCollider.
Requirements:
- Elixir 1.17 or later
- Erlang/OTP 27 or later
- SuperCollider 3.x
macOS:
brew install supercolliderLinux:
# Debian/Ubuntu
sudo apt-get install supercollider
# Arch
sudo pacman -S supercolliderWindows: Download from supercollider.github.io
If you want to use Waveform's pattern scheduler with SuperDirt (TidalCycles-style live coding):
-
Install the SuperDirt Quark (one-time setup):
Open SuperCollider IDE and run:
Quarks.install("SuperDirt"); thisProcess.recompile;
-
Install Dirt-Samples (optional but recommended):
Dirt-Samples provides 217 sample banks (1800+ audio files) including drum machines, percussion, synths, and instruments. Waveform includes an automated installer:
mix waveform.install_samples
This will:
- Download ~200MB of samples
- Install them to the correct location for your OS
- Verify the installation
- Provide next steps
Note: Waveform is pre-configured with optimal buffer settings (4096 buffers) to support all Dirt-Samples. No additional configuration needed!
-
Start SuperDirt (automatic):
Waveform automatically starts SuperDirt with the correct configuration when you use
Helpers.ensure_superdirt_ready(). The samples are loaded automatically from the installed Dirt-Samples directory.alias Waveform.Helpers Helpers.ensure_superdirt_ready()
-
Install sc3-plugins (optional, for additional synths):
The sc3-plugins provide additional synthesizers like
superpiano,supermandolin,supergong, and more. These are physical modeling synths that extend SuperDirt's capabilities beyond sample playback.Installation:
# macOS brew install sc3-plugins # Linux (Debian/Ubuntu) sudo apt-get install sc3-plugins # Arch Linux sudo pacman -S sc3-plugins # Windows # Download from https://supercollider.github.io/sc3-plugins/
After installation, restart SuperCollider and Waveform to use these synths.
Note: The demos in this repository use samples from Dirt-Samples by default and don't require sc3-plugins. If you want to experiment with synths like
superpiano, install sc3-plugins and seetest_superpiano.exsfor an example.
If SuperCollider is installed in a non-standard location, set the SCLANG_PATH environment variable:
export SCLANG_PATH=/path/to/sclangAfter installing SuperCollider (and optionally SuperDirt), run:
mix waveform.doctorThis will verify that your system is properly configured, including checking for SuperDirt if you plan to use pattern-based features.
- OSC Transport: Send and receive OSC messages to/from SuperCollider
- Process Management: Automatically manages the
sclangprocess - Node & Group Management: Track synth nodes and organize them into groups
- Simple API: Minimal, focused API for triggering synths
- SuperDirt Integration: TidalCycles-compatible sample playback and effects
- Pattern Scheduler: High-precision continuous pattern playback with cycle-based timing
- Hot-Swappable Patterns: Change patterns while they're playing without stopping
- MIDI Output: Send patterns to hardware synths, DAWs, or any MIDI device
- MIDI Input: Receive notes and CC from controllers, route to SuperDirt or custom handlers
- MIDI Clock: Sync with external gear as master or slave (24 PPQ)
- Multi-Output: Route patterns to SuperCollider, MIDI, or both simultaneously
Add waveform to your dependencies in mix.exs:
def deps do
[
{:waveform, "~> 0.2.0"}
]
endThen run:
mix deps.get
mix waveform.doctor # Verify SuperCollider is installed# Start your application (Waveform starts automatically)
# The sclang process and SuperCollider server will boot
# Trigger a synth (assumes you have a synth named "default" loaded)
alias Waveform.Synth
Synth.trigger("default", note: 60, amp: 0.5)Waveform does not include any built-in synth definitions. You need to define synths in SuperCollider first.
You can define synths in several ways:
Option 1: Define in SuperCollider directly
alias Waveform.Lang
Lang.send_command("""
SynthDef(\\saw, { |freq=440, amp=0.1, out=0|
Out.ar(out, Saw.ar(freq, amp))
}).add;
""")Option 2: Load from a file
# Place your .scsyndef files in a directory
OSC.load_synthdef_dir("/path/to/synthdefs")Option 3: Use SuperDirt
For TidalCycles-style live coding, load SuperDirt which includes many synths and samples. See the SuperDirt integration section below.
Once you have synths defined, trigger them with:
alias Waveform.Synth
# Basic synth trigger with parameters
Synth.trigger("saw", note: 60, amp: 0.5, cutoff: 1000)
# Specify node and group IDs manually
Synth.trigger("kick", [amp: 0.8], node_id: 1001, group_id: 1)
# Use the convenience play/2 function
Synth.play(60, synth: "piano", amp: 0.6)For more control, use the OSC module directly:
alias Waveform.OSC
# Send raw OSC commands
OSC.new_synth("my-synth", node_id, :head, group_id, [:freq, 440, :amp, 0.5])
# Create a new group
OSC.new_group(group_id, :tail, parent_group_id)
# Delete a group and all its nodes
OSC.delete_group(group_id)
# Load synth definitions from a directory (if you have custom synthdefs)
OSC.load_synthdef_dir("/path/to/your/synthdefs")alias Waveform.OSC.Node
alias Waveform.OSC.Group
# Get the next available node ID
%{id: node_id} = Node.next_synth_node()
# Get a process-specific group
%{id: group_id} = Group.synth_group(self())
# Create a named group
group = Group.chord_group("my-chord")You can send arbitrary SuperCollider code to the sclang interpreter:
alias Waveform.Lang
Lang.send_command("""
SynthDef(\\simple, { |freq=440, amp=0.1|
Out.ar(0, SinOsc.ar(freq, 0, amp))
}).add;
""")Waveform starts a supervision tree with these processes:
- Waveform.Lang - Manages the
sclangprocess - Waveform.OSC - Handles OSC message transport
- Waveform.OSC.Node.ID - Allocates unique node IDs (Agent)
- Waveform.OSC.Node - Tracks node lifecycle
- Waveform.OSC.Group - Manages groups
Mix.install([
{:waveform, "~> 0.2.0"}
])
alias Waveform.Synth
# Define a pattern
notes = [60, 64, 67, 72]
# Play the pattern
Task.async(fn ->
Stream.cycle(notes)
|> Enum.each(fn note ->
Synth.play(note, synth: "default", amp: 0.5)
Process.sleep(250)
end)
end)Waveform is designed to be a foundation for higher-level pattern languages (like TidalCycles or Strudel):
defmodule MyPatternEngine do
alias Waveform.Synth
def schedule_event(%Event{time: time, synth: synth, params: params}) do
Process.send_after(self(), {:trigger, synth, params}, time)
end
def handle_info({:trigger, synth, params}, state) do
Synth.trigger(synth, params)
{:noreply, state}
end
endWaveform includes built-in SuperDirt integration and a high-precision pattern scheduler that works directly with UzuPattern for TidalCycles-style live coding.
Prerequisites: Make sure SuperDirt is installed and loaded (see Prerequisites).
Basic SuperDirt playback:
alias Waveform.SuperDirt
# Start SuperDirt in SuperCollider
Waveform.Lang.send_command("SuperDirt.start;")
# Trigger individual samples
SuperDirt.play(s: "bd") # Bass drum
SuperDirt.play(s: "sn", n: 2, gain: 0.8) # Snare variant 2
SuperDirt.play(s: "cp", room: 0.5, size: 0.8) # Clap with reverbContinuous pattern playback with UzuPattern:
The PatternScheduler works directly with %UzuPattern.Pattern{} structs. Parse mini-notation strings and schedule them for playback:
alias Waveform.PatternScheduler
alias UzuPattern.Pattern
# Set tempo (0.5625 = 135 BPM)
PatternScheduler.set_cps(0.5625)
# Parse and schedule a drum pattern
drums = UzuPattern.parse("bd cp sn cp")
PatternScheduler.schedule_pattern(:drums, drums)
# Add a hi-hat pattern
hats = UzuPattern.parse("hh*8")
PatternScheduler.schedule_pattern(:hats, hats)
# Hot-swap the drum pattern while it's playing
new_drums = UzuPattern.parse("bd bd:1 sn bd:2")
PatternScheduler.update_pattern(:drums, new_drums)
# Change tempo on the fly
PatternScheduler.set_cps(0.75) # Speed up to 180 BPM
# Stop a specific pattern
PatternScheduler.stop_pattern(:hats)
# Emergency stop all patterns
PatternScheduler.hush()Pattern transformations:
Apply UzuPattern transformations before scheduling:
# Speed up the pattern (2x = 8 events per cycle)
drums = UzuPattern.parse("bd sd hh cp")
|> Pattern.fast(2)
PatternScheduler.schedule_pattern(:drums, drums)
# Slow down (half speed)
ambient = UzuPattern.parse("pad:1 pad:2 pad:3 pad:4")
|> Pattern.slow(2)
PatternScheduler.schedule_pattern(:ambient, ambient)
# Reverse the pattern
reversed = UzuPattern.parse("bd sd hh cp")
|> Pattern.rev()
PatternScheduler.schedule_pattern(:reversed, reversed)
# Apply transformation every N cycles
evolving = UzuPattern.parse("bd sd hh cp")
|> Pattern.every(4, &Pattern.rev/1)
PatternScheduler.schedule_pattern(:evolving, evolving)
# Stack multiple patterns (polyrhythm)
poly = Pattern.stack([
UzuPattern.parse("bd ~ bd ~"),
UzuPattern.parse("~ cp ~ cp"),
UzuPattern.parse("hh*8")
])
PatternScheduler.schedule_pattern(:poly, poly)Signal patterns for modulation:
Use signal patterns (sine, saw, rand) to modulate parameters:
alias UzuPattern.Pattern.{Effects, Signal}
# Filter sweep with sine wave
pattern = UzuPattern.parse("bd sd hh cp")
|> Effects.lpf(Signal.sine() |> Signal.range(200, 2000))
PatternScheduler.schedule_pattern(:sweep, pattern)
# Random panning
pattern = UzuPattern.parse("hh*8")
|> Effects.pan(Signal.rand() |> Signal.range(-1, 1))
PatternScheduler.schedule_pattern(:random_pan, pattern)Waveform supports MIDI output as a parallel audio destination to SuperCollider. Send patterns to hardware synths, DAWs, or any MIDI-capable software.
Basic MIDI playback:
alias Waveform.MIDI
# List available MIDI ports
MIDI.Port.list_outputs()
# Send a single note
MIDI.play(note: 60, velocity: 80, channel: 1)
# With automatic note-off after 500ms
MIDI.play(note: 60, velocity: 80, duration_ms: 500)
# Control change and program change
MIDI.control_change(1, 64, 1) # Modulation wheel to 64
MIDI.program_change(5, 1) # Change to program 5
# Raw note on/off
MIDI.note_on(60, 100, 1)
MIDI.note_off(60, 1)
# Panic - all notes off
MIDI.all_notes_off()Pattern scheduling with MIDI output:
alias Waveform.PatternScheduler
# Set tempo
PatternScheduler.set_cps(0.5) # 120 BPM
# Parse a melody pattern (numbers are interpreted as MIDI notes)
melody = UzuPattern.parse("60 64 67 72")
# Schedule with MIDI output
PatternScheduler.schedule_pattern(:melody, melody,
output: :midi,
midi_channel: 1
)
# Send to BOTH SuperCollider and MIDI simultaneously
PatternScheduler.schedule_pattern(:hybrid, melody,
output: [:superdirt, :midi],
midi_channel: 1
)Multi-port routing:
Configure port aliases for easy routing to multiple MIDI destinations:
# config/config.exs
config :waveform,
midi_ports: %{
drums: "IAC Driver Bus 1",
synth: "USB MIDI Device",
hardware: "MIDI Out 1"
}# Use aliases in patterns
bass = UzuPattern.parse("36 48 36 48")
PatternScheduler.schedule_pattern(:bass, bass,
output: :midi,
midi_port: :synth,
midi_channel: 2
)Receive MIDI input from controllers, keyboards, or other MIDI devices. Route notes directly to SuperDirt for live playing, or register custom handlers for advanced control.
Basic MIDI input:
alias Waveform.MIDI.Input
# List available input ports
Input.list_ports()
# Start listening to a port
Input.listen("USB MIDI Keyboard")
# Or listen to all available ports
Input.listen_all()
# Register a handler for note events
Input.on_note(fn event ->
IO.inspect(event)
# %{type: :note_on, note: 60, velocity: 100, channel: 1}
end)
# Register a handler for CC (control change) events
Input.on_cc(fn %{cc: cc, value: v} ->
IO.puts("CC #{cc} = #{v}")
end)
# Stop listening
Input.stop()Route MIDI to SuperDirt for live playing:
# Play piano samples with incoming notes
# Velocity is automatically mapped to gain
Input.route_to_superdirt(s: "piano")
# Use a different sample with reduced volume
Input.route_to_superdirt(s: "superpiano", gain_scale: 0.5)
# Add effects
Input.route_to_superdirt(s: "piano", room: 0.5, size: 0.8)
# Stop routing
Input.stop_superdirt_routing()Channel filtering:
# Only receive events from channel 1
Input.set_channel_filter(1)
# Receive from all channels (default)
Input.set_channel_filter(nil)All event types:
# Handle all MIDI events (for debugging or custom routing)
Input.on_all(fn event ->
case event.type do
:note_on -> "Note #{event.note} on"
:note_off -> "Note #{event.note} off"
:cc -> "CC #{event.cc} = #{event.value}"
:program_change -> "Program #{event.program}"
:pitch_bend -> "Pitch bend #{event.value}"
:aftertouch -> "Aftertouch on note #{event.note}"
_ -> "Unknown"
end
end)
# Clear specific handlers
Input.clear_handlers(:note)
Input.clear_handlers(:cc)
# Clear all handlers
Input.clear_handlers()Velocity curves:
Convert gain values (0.0-1.0) to MIDI velocity using different curves:
# config/config.exs
config :waveform,
midi_velocity_curve: :linear # Default: 0.5 gain → ~64 velocity
# midi_velocity_curve: :exponential # Quieter: 0.5 gain → ~32 velocity
# midi_velocity_curve: :logarithmic # Louder: 0.5 gain → ~90 velocityVirtual MIDI ports:
Create virtual MIDI outputs that appear as MIDI devices to other applications (macOS/Linux only):
# Create a virtual output named "Waveform"
{:ok, conn} = MIDI.Port.create_virtual_output("Waveform")
# Other apps (DAWs, etc.) can now connect to "Waveform" as a MIDI inputSynchronize with external MIDI devices using MIDI clock. Waveform can act as a clock master (sending clock) or slave (receiving clock).
Master mode - send clock to external gear:
alias Waveform.MIDI.Clock
# Start sending clock at 120 BPM
Clock.start_master(120)
# Change tempo on the fly
Clock.set_bpm(140)
# Transport controls
Clock.send_start() # Tell receivers to start playback
Clock.send_stop() # Tell receivers to stop
Clock.send_continue() # Resume from current position
# Stop sending clock
Clock.stop_master()Slave mode - sync to external clock:
# Start listening for clock from a MIDI device
Clock.start_slave("USB MIDI Keyboard")
# PatternScheduler tempo automatically syncs to incoming clock
# When external device sends Stop, patterns will hush
# Check if clock is running (received Start without Stop)
Clock.running?()
# Get calculated BPM from incoming clock
Clock.get_bpm()
# Stop listening
Clock.stop_slave()Configuration:
# config/config.exs
config :waveform,
midi_clock_port: "IAC Driver Bus 1", # Default clock output port
midi_clock_smoothing: 8 # Ticks to average for BPM calculationLoad custom samples into SuperCollider for use with your own synth definitions. This is separate from SuperDirt's sample loading.
alias Waveform.Buffer
# Load a sample file
{:ok, buf_num} = Buffer.read("/path/to/kick.wav")
# Load portion of a file
{:ok, buf_num} = Buffer.read("/path/to/long_sample.wav",
start_frame: 44100, # Start 1 second in (at 44.1kHz)
num_frames: 88200 # Load 2 seconds
)
# Allocate empty buffer (for recording)
{:ok, buf_num} = Buffer.allocate(88200, 2) # 2 seconds stereo
# List all managed buffers
Buffer.list()
# Free a buffer
Buffer.free(buf_num)
# Free all buffers
Buffer.free_all()Playing buffers with synths:
First, define a synth in SuperCollider:
SynthDef(\playbuf, { |out=0, bufnum, rate=1, amp=0.5|
var sig = PlayBuf.ar(2, bufnum, BufRateScale.kr(bufnum) * rate, doneAction: 2);
Out.ar(out, sig * amp);
}).add;Then play it from Elixir:
{:ok, buf} = Buffer.read("/path/to/sample.wav")
Waveform.Synth.new("playbuf", bufnum: buf, rate: 1.0, amp: 0.8)# Clone the repository
git clone https://github.com/rpmessner/waveform.git
cd waveform
# Install dependencies
mix deps.get
# Compile
mix compile
# Run tests
mix test
# Check code coverage
MIX_ENV=test mix coveralls
# Generate documentation
mix docsDevelopment sessions are documented in docs/sessions/ to maintain context across sessions and help contributors understand recent changes:
- Session history: See docs/sessions/README.md
- Latest session: Check the most recent file in
docs/sessions/ - Project changelog: See CHANGELOG.md
When working on Waveform (especially with AI assistants), consult the session documentation for context on recent architectural decisions and ongoing work.
- SuperDirt integration (✅ Complete - v0.3.0)
- Pattern scheduling utilities (✅ Complete - v0.3.0)
- MIDI output support (✅ Complete - v0.4.0)
- MIDI input support (✅ Complete - v0.4.0)
- MIDI clock sync (✅ Complete - v0.4.0)
- Buffer management for custom samples (✅ Complete - v0.4.0)
- More examples and guides
Cause: SuperCollider's buffer limit is too low for Dirt-Samples (1817 audio files).
Solution: Waveform is pre-configured with numBuffers = 4096 to support all samples.
If you're still experiencing issues:
-
Verify Dirt-Samples is installed:
mix waveform.install_samples
-
Restart your application completely (not just reload):
# In IEx :init.restart()
-
Check the installation:
ls ~/Library/Application\ Support/SuperCollider/downloaded-quarks/Dirt-Samples/ # Should show 217+ directories (bd, sn, hh, cp, etc.)
Cause: The buffer limit has been exceeded.
Solution: This is handled automatically by Waveform's server configuration. If you see this error:
- Make sure you're using the latest version of Waveform
- Restart your application
- If the issue persists, you may have custom server options overriding Waveform's settings
Cause: Sample path not configured correctly.
Solution: Waveform automatically detects the correct sample path for your OS. If samples still don't load:
-
Verify the path exists:
- macOS:
~/Library/Application Support/SuperCollider/downloaded-quarks/Dirt-Samples - Linux:
~/.local/share/SuperCollider/downloaded-quarks/Dirt-Samples - Windows:
~/AppData/Local/SuperCollider/downloaded-quarks/Dirt-Samples
- macOS:
-
Check the sample count:
find ~/Library/Application\ Support/SuperCollider/downloaded-quarks/Dirt-Samples/ -name "*.wav" | wc -l # Should show ~1800 files
-
If files are missing, reinstall:
mix waveform.install_samples
Run mix waveform.doctor to diagnose installation issues.
If SuperCollider is installed in a custom location:
export SCLANG_PATH=/path/to/sclang- Check if another SuperCollider instance is running
- Verify audio device permissions (macOS/Linux)
- Check SuperCollider logs for errors
- Try running SuperCollider IDE directly to diagnose
- Run diagnostics:
mix waveform.doctor - Test SuperDirt:
mix waveform.check - Report issues: https://github.com/rpmessner/waveform/issues
Waveform is the audio layer of the Elixir music ecosystem:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ UzuParser │──▶│ UzuPattern │ │ harmony │
│ (parsing) │ │ (transforms) │ │ (theory) │
│ │ │ │ │ │
│ • parse/1 │ │ • fast/slow │ │ • chords │
│ • mini- │ │ • rev/early │ │ • scales │
│ notation │ │ • stack/cat │ │ • voicings │
│ • [%Event{}] │ │ • every/when │ │ • intervals │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────────┐
│ waveform │ ◀── YOU ARE HERE
│ (audio) │
│ │
│ • OSC │
│ • SuperDirt │
│ • MIDI │
│ • scheduling │
└──────────────────┘
Waveform handles:
- OSC communication with SuperCollider
- SuperDirt sample playback and effects
- Pattern scheduling with cycle-based timing
- MIDI output to hardware synths, DAWs, and virtual instruments
Waveform does NOT handle:
- Pattern parsing (→ UzuParser)
- Pattern transformations (→ UzuPattern)
- Music theory (→ harmony)
- UzuParser - Pattern parsing (mini-notation to events)
- UzuPattern - Pattern transformations (fast, slow, rev, stack, cat, every, jux)
- Harmony - Music theory library for Elixir
- SuperCollider - The audio synthesis platform
Contributions are welcome! Please feel free to submit issues and pull requests.
When contributing significant changes:
- Run
mix testto ensure all tests pass - Check
MIX_ENV=test mix coverallsto verify coverage - Update or create session documentation in
docs/sessions/for major features - Update CHANGELOG.md with your changes
See docs/sessions/README.md for context on recent development work.
MIT License - see LICENSE for details.
Built for live coding music in Elixir and Livebook. Inspired by TidalCycles and the SuperCollider community.