Turn a recording into overlapping grains, with independent control over their position, duration, pitch and timing. The same Rust scheduler and readers run on desktop and in the browser.
Hear it and change it#
Granular synthesis layers short windows of a recording. Grain size sets how much each little window contains; grain rate sets how often a new one begins. Together they move from scattered particles toward a sustained texture.
Listen for: a piano recording becoming a shimmering, overlapping texture.
tempo = 120bpm
timingVersion = 2
cloud = patch {
source = sample "piano" |> granular
|> grainSize 100ms |> grainRate 24Hz
|> grainPos 0.12 |> grainSpray 30ms |> grainPan 0.6,
ampEnv = adsr 200ms 0ms 1 400ms,
gain = 0.25,
voices = 1
}
play cloud ("c4" |> slow 4)
Make it yours: change 100ms to 35ms for finer grains, then try a higher grain rate to reconnect the texture. This example reads a fixed region of the sample; its note does not set sample pitch. See Granular synthesis for pitch and position controls.
Sources and selected regions#
sample "name" |> granular makes a cloud that reads at the buffer's native pitch. grains "name" ~C3 follows played notes relative to the stated root. sampleAt selects its buffer at note onset. A keymapped sample selects its zone like a plain read: a legacy table its nearest root, a table with manifest fields the note's latched pick — velocity layer, round-robin slot, and in a key/velocity crossfade BOTH zones, each grain reading them at the same position fraction with their own root, rate, tune, offset/end window and equal-power gain (Multisampled instruments § Crossfades). A note-pitched cloud in a glide patch latches the GLIDED pitch at each grain's spawn, so a slide moves it grain by grain (The sampler § Glide and legato); the per-event begin/end lanes narrow the selected region. Every granular source in a patch has its own 64-grain pool, clock and seeded random sequence, even when several sources feed the same output.
trim a b confines all reads, including resampling taps, to that region of the original buffer. Grain position is then relative to the selected region. reverse, speed, event speed, pitched and repitching warp compose with the reader. Stereo buffers retain both channels, using synchronized grain readers. Chop/slice modes (chop, chopAt, slice, sliceAt, snap, marks) cannot share a sample node with granular playback; the compiler diagnoses either order.
Play a complete instrument#
Open Grain Garden from the song chooser. Its source recording is synthesized at load time, so it needs no downloaded samples. Hear the seed first, then its chord cloud, reverse particles and clocked pulse. Four live macros shape the cloud: scan moves through the recording, texture changes grain duration, motion opens spray/stereo spread/jitter, and grainRhythm changes free density. The arranged sections also demonstrate those gestures. Source: examples/songs/grain-garden.volta.
Grain controls#
| Source modifier | Meaning | Default |
|---|---|---|
grainSize | Grain duration in seconds; duration units such as 80ms work | 50 ms |
grainRate | Mean onsets per second | 20 Hz |
grainPos | Position inside the selected source, 0–1 | 0 |
grainPitch | Signed playback ratio; negative reads backward | 1 |
grainSpray | Random position radius in seconds | 0 |
grainPan | Random pan spread, 0–1 | 0 |
grainJitter | Random interval variation, 0–1 | 0.25 |
grainWindow | "hann", "tukey" or "gauss" | "hann" |
grainPitchQuant | Allowed semitone pitch classes | unrestricted |
Size, rate, position, pitch, spray, pan and jitter accept constants or live controls, including macros and modulation expressions. Each grain samples its reader parameters at its own onset; duration, read rate, pan and window stay fixed for that grain unless a trajectory below was authored. Changing size and rate also changes the cloud's overlap normalization. A rate edit selects free-running timing, replacing any earlier grainEvery or grainTrigger on the same source.
A grain lasts round(size × sampleRate) output samples, floored at the larger of six samples and 125 microseconds. Literal and signal-valued sizes use the same floor: grainSize 0.2ms and grainSize (const 0.2ms) agree. The engine shortens a live capture grain if its reader would overtake or be lapped by the write head; the inspector shows the effective duration.
Position, pan and jitter clamp to 0–1. Rate schedules at most one grain per output sample, with a minimum free rate of 0.01 Hz. Source ratios may exceed the advisory −4…4 band; the resampler's fixed kernel limits rejection at extreme transpositions. Track controls additionally bound size/spray to 0–30 seconds, pitch to ±1,000,000 and rate to the device sample rate. A non-finite track-control value holds its last finite value; source controls fall back to their defaults.
A source grainPitch is unbounded in magnitude and in sign, and the read is in range for any of it. Every grain read wraps the selected reader (granular_sample_at, looped), with each of the up-to-42 sinc taps re-wrapped by rem_euclid, so a negative or out-of-range position is folded, never clamped and never indexed raw. The scale-lock reads the ratio's MAGNITUDE and re-applies its sign, so a negative ratio quantizes like its positive twin. Measured at 48 kHz, 36 combinations of ratio × read geometry (−0.0001 to −10⁹, crossed with the scale lock, multi-second and sub-millisecond grains, grainPos 1 + 5 s spray, and a source shorter than one sinc stencil): every one finite and under 0.06 peak at gain 0.2 (engine/tests/grain_pitch_extremes.rs). The read POSITION is separately re-wrapped once it passes 2⁴⁰ samples (≈ 254 days at 48 kHz), where an f64 would stop resolving single samples — unreachable for any authored render, so every existing render is bit-identical.
grainPitchQuant [0, 3, 7, 12] reduces offsets modulo 12, sorts them, and snaps the magnitude of each ratio to the nearest allowed pitch class. Octaves and reverse direction survive; zero remains zero. Note transposition is applied on top. The track counterpart is grainPitchQuantT.
The three windows have power corrections relative to Hann. Pan uses the engine's constant-power law with unity center gain; opening pan spread moves energy between channels without paying an extra center attenuation. Existing single-cloud settings retain their seeded timing and gain conventions.
Travel between recordings#
grainMorph [b, c, d] amount adds one to three recordings to the primary sample: zero selects A, one selects the last recording, and intermediate amounts blend the adjacent pair inside each grain, under one window and clock. Names and sampleOf/bounceOf handles work, including a named list. The amount accepts a live control and latches at each grain's onset. Moving it changes new grains; existing grains finish their own blend.
bright = sampleOf 1s (\t -> 0.3 * sin (t * 2764.601535))
hollow = sampleOf 2s (\t -> 0.25 * sin (t * 2764.601535) + 0.08 * sin (t * 8293.804605))
material = macro 0.5
voice = patch {
source = grains bright ~A4 |> grainMorph [hollow] material
|> grainSize 160ms |> grainRate 24Hz |> grainPos 0.2
|> grainGlide 7 |> grainOrbit 1,
ampEnv = adsr 10ms 0ms 1 180ms, gain = vel * 0.4
}
Listen for: one note's texture changing from a pure tone toward a hollow recording, with each little grain rising through a fifth and moving right. Set Glide and Orbit to zero to isolate the source blend. Tidal Glass and Firefly Switchyard are complete portable arrangements in the song chooser.
The blend is linear and coherent: identical recordings with the same reader geometry stay identical, without a midpoint boost. Uncorrelated recordings can dip in energy at the midpoint, and opposite polarities can cancel. This does not align phases, identify pitches or interpolate spectra. Choose recordings whose pitch and content make a useful pair; pMorph provides spectral partial morphing when that is the intended transformation.
Each source selects its own nearest multisample zone and root, source sample rate and BPM warp. Position and trim are normalized to each recording's own length; spray retains seconds in each recording. Stereo channels remain paired. A missing selected source contributes silence; an unselected missing source cannot mute or leak into the selected endpoint.
Motion inside a grain#
| Source modifier | Travel over one grain | Default / bounds |
|---|---|---|
grainGlide | Logarithmic pitch travel in signed semitones; 12 rises an octave | 0 / −48…48 |
grainDrift | Extra signed source seconds, added to the reader's ordinary travel | 0 / −30…30 s |
grainOrbit | Signed pan displacement, reflecting at the left/right edges | 0 / −4…4 |
All three accept live controls sampled at onset. A zero setting preserves the ordinary grain. Glide preserves the initial forward/reverse direction; drift can slow, stop or reverse that read because it adds signed speed. Drift changes pitch: it is not independent time stretching. For example, grainDrift 40ms on an 80ms grain adds approximately half-speed to the recording's normal read. Pitch quantization applies to the starting ratio; a glide then travels freely.
Orbit uses the existing constant-power stereo law. grainOrbit 1 moves a centered grain to the right edge; -1 moves left. Random grainPan still chooses the starting placement. A grain that reaches an edge reflects without jumping across the stereo field. Stereo source content remains stereo underneath this placement; Orbit does not first collapse it to mono.
Resampling uses the largest absolute speed of the trajectory, including drift, source-rate conversion, note pitch and reverse. Its low-pass cutoff stays fixed for that grain, so fast glides can soften the start as well as the end. Extreme rates retain the fixed-kernel rejection limit described above. At most two stereo readers sound per grain, regardless of how many sources are listed; all four new controls use no extra random draws.
Replacing a recording, changing its selected root/BPM metadata or changing the source region retires motion readers whose geometry changed, with a 128-sample settling fade. Later onsets bind the new source. Grain clocks and random state continue; ordinary knob moves do not retire readers. Decoded recording identities prevent allocator-address reuse from hiding a replacement. These four modifiers belong to sample-source instruments; capture inserts retain their existing controls.
A motion cloud's onset clock runs even while none of its sources has resolved yet — a recording still arriving, a sample not yet loaded. It ages grains over a static silent geometry without allocating a buffer, so the cloud keeps its timing and random sequence and does not restart when the recording appears (engine/src/voice/eval_sampler.rs). Every source the cloud can travel to is a dependency, not only the primary sample: bounce ordering and recipe keys walk the whole motion source list, including sources reachable only through morph. In grain telemetry a missing selected reader is source index 255 (engine/src/grain_viz.rs), never a fifth lane.
Exact timing#
Free timing uses grainRate with interval variation up to ±grainJitter of the nominal interval. grainJitter 0 gives a regular train; the default remains 0.25. Random draws are deterministic, including when the pool is full.
Two opt-in clocks bypass interval jitter:
regular = sample "padlong" |> granular |> grainEvery (1c/16)
patterned = sample "padlong" |> granular |> grainTrigger !"1 0 1 1"
grainEvery 20ms uses an absolute seconds grid. Beat/cycle expressions such as grainEvery (1c/16) follow the transport's tempo map, including ramps. These are shared clock grids; starting a voice between grid points waits for the next onset. Parenthesize compound durations in pipe arguments.
grainTrigger takes a repeating, statically resolved pattern. Non-rest, nonzero numeric/boolean events trigger grains; pitched/sample events also trigger. Zero and rest events do not. An all-rest pattern produces no grains. The pattern's rate determines its repeating period. Coincident onsets coalesce; there is at most one onset per output sample and at most 4,096 onset positions in a clock. grainEveryT and grainTriggerT provide the same timing on inserts. The last rate/every/trigger modifier selects the clock.
Track clouds, capture and input#
track |> granularize "name" reads a named stereo sample continuously while that track's effect chain is running. It can sound across note rests. Bare track |> granularize records the track's dry signal into a stereo history ring and grains that history. track |> granularize "input" records the host's actual microphone/line input. On desktop input is opened for an input-using song; in the browser, choose Connect input in the inspector and grant audio access. The inspector distinguishes disconnected input, empty history, capture and freeze.
The track controls grainSizeT, grainRateT, grainPosT, grainPitchT, grainSprayT, grainPanT, and grainJitterT accept live controls, with the same per-onset semantics. grainWindowT and grainPitchQuantT are compile-time choices. Numeric constants keep their numeric IR representation; macros keep their live bindings instead of baking the initial knob position.
| Capture/mix control | Meaning | Default |
|---|---|---|
grainWetT | Grain output gain, 0–1 | 1 |
grainDryT | Dry signal gain, 0–1 | 1 |
grainHistoryT | History capacity, 0.05–30 seconds; compile-time | 4 s |
grainFreezeT | Stop recording when the control reaches 0.5 | 0 |
The default effect remains additive. Set dry to zero for a fully wet cloud; changing wet/dry never changes what is recorded. Live history needs up to 50 ms of priming. Its position is a delay behind the write head, bounded by each grain's length and read rate. Spray reflects within the safe delay interval.
Freeze stops writing while grains continue to read the valid captured region. A frozen position of zero addresses the newest captured sample. Freezing an empty input does not invent audio. Changing between live and frozen geometry retires existing readers with a short settling fade. Compatible edits preserve the buffer and pending keep; changing the capture source or capacity replaces it. Stopping ends generation and retains the capture. Track stop or scene exit lets active grains finish; Stop All retires active readers with a short fade. Sample and input generators follow track volume, mute, pan and width. Bypassing the insert clears its grains and capture history.
Keep as sample copies the valid frozen history in chronological order, retaining both channels. The audio callback copies at most 4,096 stereo frames per insert per callback into preallocated storage. The host installs the result in the sample bank, reports its name, and saves it: a WAV beside native song assets or origin-local IndexedDB in the browser. Browser captures reopen when referenced by their saved names. Storage failure is reported and the sample remains available for the session. Browser captures are local to that browser profile; source text alone does not transfer the audio to another computer.
Inspect and edit#
Open the grains viewer on a granular source or granularize insert, the sample waveform dock, or the selected patch's expandable Grains panel in Rack. These views share ui/src/grain_ui.rs; there is no separate browser DSP or synthetic particle scheduler.
The waveform follows the same note-selected sample or keymap zone as the reader. It shows the selected source region, spray band, grain starts/read heads and footprints. Its visual scale is normalized, with the real peak labeled in dBFS. Drag to change position, Shift-drag to change spray, or drag source-region edges to trim. Numeric controls preserve literal units. A gesture makes one undo entry. Expressions remain expressions: choose their arrow to jump to the source instead of replacing a modulation graph.
The Source travel lanes show actual selected source readers, read direction and current stereo placement. They label sources A–D; the waveform above remains the primary source. No partner waveform is invented. Morph amount edits have a separate guarded span from their source list; named-option expressions remain source links. Glide, Drift and Orbit preserve literal units and single-gesture undo.
The output-time strip shows actual sample-timestamped onsets and durations over the last two seconds or eight transport beats, alongside active/64, spawned and dropped counts and the effective duration/rate. Expand pitch and pan distributions to inspect the recorded events. Capture views also show the write head and valid history.
Up to eight interested grain sources publish frames. Each holds the latest 128 successful onsets and up to 64 active readers; the view states how many of the spawned onsets remain retained. Short grains therefore remain visible between screen refreshes. Counters are per inspected pool. A source view explicitly identifies its representative voice, note and buffer selection; it does not claim to sum all polyphony. Last-observed data is labeled idle, and snapshots from a queued previous module are rejected before names can be misjoined.
The compiler warns when bounded size × rate can exceed 64 overlapping grains. The engine drops new onsets at capacity; reducing size or rate can remove those drops. Telemetry uses fixed storage and never consumes the audio random sequence.
Verification and remaining research#
Regression suites: engine/tests/granular_instrument.rs, engine/tests/granular_cloud.rs, engine/tests/grain_motion.rs, engine/tests/grain_bandlimit.rs, engine/tests/gaps_dsp_batch.rs, and ui/tests/grain_inspector.rs. The dated review and its measurement correction remain in Granular Review 2026 09 07. Implementation acceptance, native images and browser persistence measurements: Granular Instrument 2026 09 07.
Grain feedback, distribution families and pitch-synchronous/transient-preserving grains remain research directions; Granular Instrument records the implemented package's scope.
Inspector editing and responsiveness#
Choose Hann, Tukey or Gaussian directly in Window. Authored window expressions remain a link to their source. Likewise, numeric controls preserve existing modulation expressions and literal units. Editing Rate after an exact clock or trigger pattern explicitly switches that source to free timing; the inspector labels the current clock before the edit.
Spray is measured in seconds of the original recording. Trimming the playable region does not rescale those seconds. The spray highlight wraps inside the selected region, matching the reader; Shift-drag uses the same source-time scale. Overload feedback reports dropped onsets and suggests reducing duration or rate. Counts include earlier overloads.
Grain telemetry is in trimmed-reader units, not waveform units. For a static source, GrainFrame.source_frames is the length of the selected reader (engine/src/voice/eval_sampler.rs) and region locates that reader on the full sample, so the view converts spray seconds to a reader fraction and then multiplies by the region span (grain_ui::spray_fraction), inverting the same conversion for a gesture. Live capture instead reports ring capacity, including a partially filled frozen ring, and takes no trim multiplier.
Unchanged source bindings are cached, including incomplete edits; changing the text or selected pipe invalidates the cache. Unchanged telemetry is retained without copying it on each repaint. Static waveform painting aggregates peak extrema to at most one segment per screen column, and clipped waveform/timing plots skip painting. Source gestures remain single undo operations.