# Waveform
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.
## Prerequisites
**Requirements:**
- Elixir 1.17 or later
- Erlang/OTP 27 or later
- SuperCollider 3.x
**⚠️ SuperCollider must be installed on your system before using Waveform.**
### Installing SuperCollider
**macOS:**
```bash
brew install supercollider
```
**Linux:**
```bash
# Debian/Ubuntu
sudo apt-get install supercollider
# Arch
sudo pacman -S supercollider
```
**Windows:**
Download from [supercollider.github.io](https://supercollider.github.io/)
### Installing SuperDirt (Optional, for Pattern-Based Live Coding)
If you want to use Waveform's pattern scheduler with SuperDirt (TidalCycles-style live coding):
1. **Install the SuperDirt Quark** (one-time setup):
Open SuperCollider IDE and run:
```supercollider
Quarks.install("SuperDirt");
thisProcess.recompile;
```
2. **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:
```bash
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!
3. **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.
```elixir
alias Waveform.Helpers
Helpers.ensure_superdirt_ready()
```
4. **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:**
```bash
# 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 see `test_superpiano.exs` for an example.
### Custom Installation Path
If SuperCollider is installed in a non-standard location, set the `SCLANG_PATH` environment variable:
```bash
export SCLANG_PATH=/path/to/sclang
```
### Verify Installation
After installing SuperCollider (and optionally SuperDirt), run:
```bash
mix waveform.doctor
```
This will verify that your system is properly configured, including checking for SuperDirt if you plan to use pattern-based features.
## Features
- **OSC Transport**: Send and receive OSC messages to/from SuperCollider
- **Process Management**: Automatically manages the `sclang` process
- **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
## Installation
Add `waveform` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:waveform, "~> 0.2.0"}
]
end
```
Then run:
```bash
mix deps.get
mix waveform.doctor # Verify SuperCollider is installed
```
## Quick Start
```elixir
# 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)
```
## Usage
### Defining Synths
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**
```elixir
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**
```elixir
# 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](#integrating-with-superdirt) section below.
### Triggering Synths
Once you have synths defined, trigger them with:
```elixir
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)
```
### Low-Level OSC API
For more control, use the OSC module directly:
```elixir
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")
```
### Node and Group Management
```elixir
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")
```
### Sending Commands to SuperCollider
You can send arbitrary SuperCollider code to the `sclang` interpreter:
```elixir
alias Waveform.Lang
Lang.send_command("""
SynthDef(\\simple, { |freq=440, amp=0.1|
Out.ar(0, SinOsc.ar(freq, 0, amp))
}).add;
""")
```
## Architecture
Waveform starts a supervision tree with these processes:
- **Waveform.Lang** - Manages the `sclang` process
- **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
## Use Cases
### Live Coding in Livebook
```elixir
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)
```
### Building a Pattern Engine
Waveform is designed to be a foundation for higher-level pattern languages (like TidalCycles or Strudel):
```elixir
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
end
```
### Pattern-Based Live Coding with SuperDirt
Waveform includes built-in SuperDirt integration and a high-precision pattern scheduler that works directly with [UzuPattern](https://github.com/rpmessner/uzu_pattern) for TidalCycles-style live coding.
**Prerequisites:** Make sure SuperDirt is installed and loaded (see [Prerequisites](#prerequisites)).
**Basic SuperDirt playback:**
```elixir
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 reverb
```
**Continuous pattern playback with UzuPattern:**
The PatternScheduler works directly with `%UzuPattern.Pattern{}` structs. Parse mini-notation strings and schedule them for playback:
```elixir
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:
```elixir
# 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:
```elixir
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)
```
### MIDI Output
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:**
```elixir
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:**
```elixir
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:
```elixir
# config/config.exs
config :waveform,
midi_ports: %{
drums: "IAC Driver Bus 1",
synth: "USB MIDI Device",
hardware: "MIDI Out 1"
}
```
```elixir
# Use aliases in patterns
bass = UzuPattern.parse("36 48 36 48")
PatternScheduler.schedule_pattern(:bass, bass,
output: :midi,
midi_port: :synth,
midi_channel: 2
)
```
### MIDI Input
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# Only receive events from channel 1
Input.set_channel_filter(1)
# Receive from all channels (default)
Input.set_channel_filter(nil)
```
**All event types:**
```elixir
# 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:
```elixir
# 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 velocity
```
**Virtual MIDI ports:**
Create virtual MIDI outputs that appear as MIDI devices to other applications (macOS/Linux only):
```elixir
# 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 input
```
### MIDI Clock Sync
Synchronize 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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 calculation
```
### Buffer Management
Load custom samples into SuperCollider for use with your own synth definitions. This is separate from SuperDirt's sample loading.
```elixir
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:
```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:
```elixir
{:ok, buf} = Buffer.read("/path/to/sample.wav")
Waveform.Synth.new("playbuf", bufnum: buf, rate: 1.0, amp: 0.8)
```
## Development
```bash
# 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 docs
```
### Development Session Documentation
Development sessions are documented in `docs/sessions/` to maintain context across sessions and help contributors understand recent changes:
- **Session history**: See [docs/sessions/README.md](docs/sessions/README.md)
- **Latest session**: Check the most recent file in `docs/sessions/`
- **Project changelog**: See [CHANGELOG.md](CHANGELOG.md)
When working on Waveform (especially with AI assistants), consult the session documentation for context on recent architectural decisions and ongoing work.
## Roadmap
- [x] SuperDirt integration (✅ Complete - v0.3.0)
- [x] Pattern scheduling utilities (✅ Complete - v0.3.0)
- [x] MIDI output support (✅ Complete - v0.4.0)
- [x] MIDI input support (✅ Complete - v0.4.0)
- [x] MIDI clock sync (✅ Complete - v0.4.0)
- [x] Buffer management for custom samples (✅ Complete - v0.4.0)
- [ ] More examples and guides
## Troubleshooting
### SuperDirt / Dirt-Samples Issues
#### Only hearing kick drum / Some samples don't play
**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:
1. Verify Dirt-Samples is installed:
```bash
mix waveform.install_samples
```
2. Restart your application completely (not just reload):
```elixir
# In IEx
:init.restart()
```
3. Check the installation:
```bash
ls ~/Library/Application\ Support/SuperCollider/downloaded-quarks/Dirt-Samples/
# Should show 217+ directories (bd, sn, hh, cp, etc.)
```
#### ERROR: No more buffer numbers
**Cause:** The buffer limit has been exceeded.
**Solution:** This is handled automatically by Waveform's server configuration. If you see this error:
1. Make sure you're using the latest version of Waveform
2. Restart your application
3. If the issue persists, you may have custom server options overriding Waveform's settings
#### Samples installed but not loading
**Cause:** Sample path not configured correctly.
**Solution:** Waveform automatically detects the correct sample path for your OS. If samples
still don't load:
1. 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`
2. Check the sample count:
```bash
find ~/Library/Application\ Support/SuperCollider/downloaded-quarks/Dirt-Samples/ -name "*.wav" | wc -l
# Should show ~1800 files
```
3. If files are missing, reinstall:
```bash
mix waveform.install_samples
```
### General SuperCollider Issues
#### SuperCollider not found
Run `mix waveform.doctor` to diagnose installation issues.
If SuperCollider is installed in a custom location:
```bash
export SCLANG_PATH=/path/to/sclang
```
#### Server won't start
1. Check if another SuperCollider instance is running
2. Verify audio device permissions (macOS/Linux)
3. Check SuperCollider logs for errors
4. Try running SuperCollider IDE directly to diagnose
### Getting Help
1. Run diagnostics: `mix waveform.doctor`
2. Test SuperDirt: `mix waveform.check`
3. Report issues: https://github.com/rpmessner/waveform/issues
## Ecosystem Role
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)
## Related Projects
- [UzuParser](https://github.com/rpmessner/uzu_parser) - Pattern parsing (mini-notation to events)
- [UzuPattern](https://github.com/rpmessner/uzu_pattern) - Pattern transformations (fast, slow, rev, stack, cat, every, jux)
- [Harmony](https://github.com/rpmessner/harmony) - Music theory library for Elixir
- [SuperCollider](https://supercollider.github.io/) - The audio synthesis platform
## Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
When contributing significant changes:
1. Run `mix test` to ensure all tests pass
2. Check `MIX_ENV=test mix coveralls` to verify coverage
3. Update or create session documentation in `docs/sessions/` for major features
4. Update [CHANGELOG.md](CHANGELOG.md) with your changes
See [docs/sessions/README.md](docs/sessions/README.md) for context on recent development work.
## License
MIT License - see [LICENSE](LICENSE) for details.
## Acknowledgments
Built for live coding music in Elixir and Livebook. Inspired by TidalCycles and the SuperCollider community.