| 1 |
# audiofiles-granular |
| 2 |
|
| 3 |
A programmable granular sampler CLAP plugin built on the AudioFiles architecture. Designed for experimental sound designers and musicians who find conventional granular instruments too limiting — where the synthesis behavior itself needs to be as composable and scriptable as the music being made. |
| 4 |
|
| 5 |
--- |
| 6 |
|
| 7 |
## The Problem With Existing Granular Tools |
| 8 |
|
| 9 |
Most granular instruments give you a handful of knobs — position, size, density, scatter — and expect you to twist them manually. That's fine for ambient washes. It's not fine when you want a grain cloud whose density follows the amplitude envelope of a different sample, or position that steps through a Euclidean rhythm, or pitch scatter that narrows as MIDI velocity increases. The parameter space exists; the programmability doesn't. |
| 10 |
|
| 11 |
audiofiles-granular treats the grain scheduler itself as a scripting target. Every grain spawn is a hook. You write the behavior. |
| 12 |
|
| 13 |
--- |
| 14 |
|
| 15 |
## Architecture |
| 16 |
|
| 17 |
**New crate: `crates/audiofiles-granular/`** |
| 18 |
|
| 19 |
Sits alongside `audiofiles-plugin`, depends on `audiofiles-core` and `audiofiles-rhai`. Shares the same SQLite database and flat content-addressed store — no asset duplication, no separate library management. The same samples you've organized in AudioFiles are immediately available as granular source material. |
| 20 |
|
| 21 |
``` |
| 22 |
audiofiles-granular/ |
| 23 |
src/ |
| 24 |
lib.rs # nih-plug plugin entry, params |
| 25 |
engine.rs # grain scheduler, voice pool |
| 26 |
grain.rs # single grain: position, envelope, pitch, pan |
| 27 |
sampler.rs # sample buffer cache (Arc<Vec<f32>>, keyed by hash) |
| 28 |
modulation.rs # LFO, envelope follower, MIDI CC routing |
| 29 |
editor.rs # egui UI: VFS browser, waveform, script editor |
| 30 |
state.rs # SharedState bridge (audio/GUI thread sync) |
| 31 |
script.rs # Rhai integration for per-grain hooks |
| 32 |
``` |
| 33 |
|
| 34 |
No async runtime. No tokio. The audio thread is entirely synchronous — grain scheduling, buffer reads, output mixing all happen in `process()`. Sample decoding runs on a background `std::thread` with a `crossbeam_channel` handoff, same pattern as the preview engine in `audiofiles-plugin`. |
| 35 |
|
| 36 |
--- |
| 37 |
|
| 38 |
## Grain Engine |
| 39 |
|
| 40 |
Each grain is a fixed-size struct, allocated upfront in a pool — no heap allocation inside the audio callback: |
| 41 |
|
| 42 |
```rust |
| 43 |
struct Grain { |
| 44 |
sample_hash: [u8; 32], // references content store directly by hash |
| 45 |
playhead: f32, // current read position (fractional frames) |
| 46 |
duration_frames: usize, |
| 47 |
pitch_ratio: f32, // resampling ratio (1.0 = original pitch) |
| 48 |
pan: f32, |
| 49 |
amplitude: f32, |
| 50 |
envelope_phase: f32, // 0.0..1.0, shape set per-grain at spawn |
| 51 |
reverse: bool, |
| 52 |
active: bool, |
| 53 |
} |
| 54 |
``` |
| 55 |
|
| 56 |
Voice pool is fixed at 64 grains (configurable at compile time). The scheduler fires new grains at a rate determined by density, jittered by scatter. Multiple MIDI notes run independent grain clouds with their own position anchors — the instrument is fully polyphonic. |
| 57 |
|
| 58 |
Sample buffers live in a `HashMap<[u8; 32], Arc<Vec<f32>>>` keyed by content hash. Two VFS nodes pointing to the same underlying sample share one decoded buffer automatically, a free consequence of the content-addressed store. Buffer loads happen off the audio thread; the audio thread receives an `Arc` clone when decoding is done. |
| 59 |
|
| 60 |
--- |
| 61 |
|
| 62 |
## Parameters |
| 63 |
|
| 64 |
Standard granular parameters exposed as nih-plug `FloatParam`s and automatable from the DAW: |
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
| Position | 0.0–1.0 | Read position in source sample | |
| 69 |
| Position Scatter | 0.0–1.0 | Random offset per grain | |
| 70 |
| Grain Size | 1–500ms | Duration of each grain | |
| 71 |
| Size Scatter | 0.0–1.0 | Random size variation | |
| 72 |
| Density | 1–200 grains/sec | Spawn rate | |
| 73 |
| Pitch | ±24 semitones | Global transposition | |
| 74 |
| Pitch Scatter | 0.0–24 semitones | Random pitch spread per grain | |
| 75 |
| Pan Scatter | 0.0–1.0 | Stereo spread | |
| 76 |
| Envelope Shape | Sine / Trapezoid / Tukey | Grain amplitude window | |
| 77 |
| Reverse Probability | 0.0–1.0 | Per-grain direction flip | |
| 78 |
|
| 79 |
MIDI pitch maps to playback pitch ratio. MIDI velocity maps to amplitude. All parameters are automatable and exposed to Rhai scripts. |
| 80 |
|
| 81 |
--- |
| 82 |
|
| 83 |
## VFS Integration |
| 84 |
|
| 85 |
The editor embeds the same `BrowserState` from `audiofiles-plugin`. Users navigate the VFS tree they've already built and select either a single sample or an entire folder. |
| 86 |
|
| 87 |
**Single sample mode** — granularize one file. Standard use. |
| 88 |
|
| 89 |
**Folder mode** — each MIDI note randomly or sequentially selects from a VFS folder. This is where the AudioFiles architecture becomes genuinely useful for experimental work: you can treat a folder of field recordings, a folder of drum one-shots, or a folder of vocal fragments as a unified granular source. The VFS abstraction means the instrument doesn't care about filesystem layout — it navigates the virtual tree, tag-filtered and organized however you've set it up. |
| 90 |
|
| 91 |
Combined with Phase 5's smart folders (saved filter queries), you can point the granular engine at a dynamically defined set of samples: "all samples tagged `texture` with BPM between 60–80" becomes a live instrument. |
| 92 |
|
| 93 |
--- |
| 94 |
|
| 95 |
## Rhai Scripting |
| 96 |
|
| 97 |
This is the core of what makes the instrument useful for complex pattern-making. |
| 98 |
|
| 99 |
The `audiofiles-rhai` sandboxed engine (already built for export plugins) is reused here. A script can implement hooks called at grain spawn time, before the grain is committed to the scheduler: |
| 100 |
|
| 101 |
```rhai |
| 102 |
// Grains step through position in a Euclidean rhythm |
| 103 |
// 5 onsets over 8 subdivisions, synced to host BPM |
| 104 |
|
| 105 |
fn on_grain_spawn(grain, ctx) { |
| 106 |
let steps = 8; |
| 107 |
let onsets = 5; |
| 108 |
let step = (ctx.grain_index % steps); |
| 109 |
|
| 110 |
// Bjorklund/Euclidean check |
| 111 |
if (step * onsets % steps) < onsets { |
| 112 |
grain.amplitude = ctx.velocity; |
| 113 |
grain.position = step / steps; |
| 114 |
} else { |
| 115 |
grain.amplitude = 0.0; // silent grain — maintains rhythm without sound |
| 116 |
} |
| 117 |
|
| 118 |
grain |
| 119 |
} |
| 120 |
``` |
| 121 |
|
| 122 |
```rhai |
| 123 |
// Pitch scatter narrows as velocity increases (harder hits = more focused pitch) |
| 124 |
// Position drifts slowly, creating evolving texture from a static sample |
| 125 |
|
| 126 |
fn on_grain_spawn(grain, ctx) { |
| 127 |
let focus = ctx.velocity; // 0.0..1.0 |
| 128 |
grain.pitch_scatter = grain.pitch_scatter * (1.0 - focus); |
| 129 |
|
| 130 |
// Slow position drift using host time |
| 131 |
let drift_rate = 0.03; |
| 132 |
grain.position = (ctx.time_secs * drift_rate) % 1.0; |
| 133 |
|
| 134 |
grain |
| 135 |
} |
| 136 |
``` |
| 137 |
|
| 138 |
```rhai |
| 139 |
// Density and size respond to a sidechain envelope follower |
| 140 |
// (ctx.sidechain_rms is exposed when a sidechain input is active) |
| 141 |
|
| 142 |
fn on_grain_spawn(grain, ctx) { |
| 143 |
let energy = ctx.sidechain_rms; |
| 144 |
grain.amplitude = energy * 2.0; |
| 145 |
grain.duration_frames = (grain.duration_frames * (1.0 - energy * 0.8)).max(64.0); |
| 146 |
grain |
| 147 |
} |
| 148 |
``` |
| 149 |
|
| 150 |
### Context Object |
| 151 |
|
| 152 |
The `ctx` passed to every hook exposes: |
| 153 |
|
| 154 |
``` |
| 155 |
ctx.time_secs // wall clock time since playback start |
| 156 |
ctx.bpm // host BPM (0.0 if not provided) |
| 157 |
ctx.beat_position // position in current bar (0.0..1.0) |
| 158 |
ctx.note // MIDI note number (0–127) |
| 159 |
ctx.velocity // MIDI velocity (0.0–1.0) |
| 160 |
ctx.grain_index // count of grains spawned since note-on |
| 161 |
ctx.voice_index // which polyphonic voice (for multi-note behavior) |
| 162 |
ctx.position // current position param value (before script override) |
| 163 |
ctx.density // current density param value |
| 164 |
ctx.sidechain_rms // RMS of sidechain input (0.0 if no sidechain) |
| 165 |
ctx.sample_hash_hex // hex string of current sample's content hash |
| 166 |
ctx.sample_duration // source sample duration in seconds |
| 167 |
``` |
| 168 |
|
| 169 |
Scripts are hot-reloaded — the engine polls file mtime and recompiles on change. Edit a script in your text editor while the DAW is running; the next grain spawn picks up the new behavior. No preset save/load cycle, no plugin restart. |
| 170 |
|
| 171 |
The sandbox enforces the same limits as the export plugin runtime: 100K ops, 32-level call stack, no `eval`, no filesystem access beyond what the host API exposes. |
| 172 |
|
| 173 |
--- |
| 174 |
|
| 175 |
## Modulation Sources |
| 176 |
|
| 177 |
Beyond scripting, hardwired modulation sources are available for fast patch-building without writing code: |
| 178 |
|
| 179 |
- **4 LFOs**: rate (Hz or tempo-synced), shape (sine/tri/square/random), per-voice or shared |
| 180 |
- **Envelope follower**: tracks grain output amplitude or sidechain input |
| 181 |
- **MIDI CC**: any CC to any parameter |
| 182 |
- **Note expression**: per-note pitch bend, pressure, timbre (if host supports MPE) |
| 183 |
|
| 184 |
Modulation targets are the same parameter set as the knobs, plus `grain.position`, `grain.size`, and `grain.amplitude` as direct per-grain targets. |
| 185 |
|
| 186 |
--- |
| 187 |
|
| 188 |
## Editor UI |
| 189 |
|
| 190 |
Three panels: |
| 191 |
|
| 192 |
**Left — VFS Browser** |
| 193 |
Reuses the breadcrumb/file-list pattern from `audiofiles-plugin/src/editor.rs`. Navigate to a sample or folder, hit Enter to load. Tag filters exposed as a collapsible sidebar — filter by any tag dimension to narrow the source pool in real time. |
| 194 |
|
| 195 |
**Center — Waveform + Grain Visualization** |
| 196 |
Waveform display using Phase 4's downsampled peak data. Active grains render as short animated lines or dots scrubbing across the waveform — position on the x-axis, amplitude on the y-axis, color-coded by voice. At high densities this becomes a particle system. At low densities you can watch individual grains move. |
| 197 |
|
| 198 |
The visualization isn't decorative. When you're writing a position script and the grains aren't landing where you expect, watching them move tells you immediately what the math is doing. |
| 199 |
|
| 200 |
**Right — Script Editor + Parameters** |
| 201 |
Monospace code editor using egui's `TextEdit` multiline, with line numbers and inline Rhai error display. The compiler runs on a debounced background thread — errors appear at the offending line within a few hundred milliseconds of a change, without ever blocking the audio thread. |
| 202 |
|
| 203 |
Below the editor: the standard parameter knobs, laid out as a grid. Knobs with active script overrides are visually distinguished (dimmed ring or indicator dot) so it's clear which parameters the script is controlling versus which are running from the UI value. |
| 204 |
|
| 205 |
--- |
| 206 |
|
| 207 |
## What This Enables |
| 208 |
|
| 209 |
The combination of per-grain scripting, VFS-integrated multi-sample selection, and host tempo/position context makes patterns possible that simply aren't achievable with conventional granular instruments: |
| 210 |
|
| 211 |
- **Rhythmic grain clouds** that lock to host tempo without quantizing to a fixed grid — Euclidean patterns, polyrhythmic layering, probabilistic pulse |
| 212 |
- **Spectral morphing** across a folder of related samples, where position and sample selection both evolve over time according to the same slow function |
| 213 |
- **Velocity-sensitive texture** where playing harder focuses pitch and shortens grains, playing lighter spreads and stretches them |
| 214 |
- **Sidechain-reactive granulation** where a kick drum's transients trigger density spikes in a pad texture |
| 215 |
- **Tag-driven live composition** where smart folders make the available source material conditional on whatever tags you've applied — changing the "instrument" by re-filtering the library rather than loading a different preset |
| 216 |
|
| 217 |
--- |
| 218 |
|
| 219 |
## Integration With AudioFiles Roadmap |
| 220 |
|
| 221 |
|
| 222 |
|
| 223 |
| Phase 4 (Analysis) | BPM-synced density defaults, key detection informs pitch scatter range, waveform data drives center panel | |
| 224 |
| Phase 5 (Search) | Smart folders as live instrument definitions, similarity search to find source material related to what's already loaded | |
| 225 |
| Phase 6 (Bulk Ops) | Batch-tag a folder of recordings, immediately available as a filtered source pool | |
| 226 |
| Phase 9 (Cloud Sync) | Sample buffers cached by hash — cloud samples load on demand without changing any engine code | |
| 227 |
|
| 228 |
--- |
| 229 |
|
| 230 |
## New Work Required |
| 231 |
|
| 232 |
The grain scheduler, buffer cache, and modulation system are net-new. The LFO and envelope follower are straightforward DSP, a few hundred lines each. The grain scheduler is the interesting part — fixed-size pool, lock-free reads from audio thread, background decode handoff. |
| 233 |
|
| 234 |
The script editor UI is new egui work, but bounded: it's a styled `TextEdit` with a line number overlay and an error label. No syntax highlighting library needed — Rhai errors include line numbers, which is sufficient for the feedback loop to work. |
| 235 |
|
| 236 |
Everything else — VFS browser, content-addressed buffer lookup, Rhai sandbox, sample decode via symphonia — is already built. |
| 237 |
|