In one breath: give it audio you already have — a song, a field recording, a voice memo — and it rebuilds it into new audio. It slices the sound into tiny grains lined up to the beat, then stitches those grains back together in a shuffled, re-filtered order. The result keeps the original's pulse and texture but becomes something new — hypnotic, glitchy, remixed. That's granular resynthesis, driven by the source's own rhythm.
Two runs with the same settings give two different tracks (grain picks are random) — but both stay locked to the source's groove.
Beat detection here never asks whether the audio is rhythmic — it fits a pulse to whatever it can find, and it always has a default tempo in mind (~120 BPM). Feed it literal white noise and it will confidently hear 23 beats at 112 BPM and grind to it. Feed it a real 400 ms click track and it nails 400.1 ms. It runs in two regimes — locking to a real pulse, or hallucinating one where there is none — and it never tells you which. That's not a bug; it's the instrument. The imagined grid is what lets you grind a rainstorm or a room's silence into something that grooves.
The one thing to remember: beatless input is a use case, not a failure — but a source with 0 detected beats gives the grinder nothing and yields a dead mix. A few beats (real or hallucinated) is all it needs.
→ The full story, the measured proof, and the step-by-step pipeline: docs/HOW_IT_WORKS.md.
uv venv .venv && . .venv/bin/activate # or: python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt # TUI/GUI extras are optional
# system prerequisite: ffmpeg (pydub uses it for mp3/m4a/webm)Beat detection and time-stretch use librosa — no madmom/rubberband build needed. The Conda
flow still works but isn't required.
python main.py <audio file or YouTube URL> <output dir> [amc params]# default automix — fast path, raw grains (no band-pass filtering)
python main.py song.mp3 output/
# half-length grains, each grain a touch faster, whole mix a touch slower
python main.py song.mp3 output/ amc l /2 ss 1.2 s 0.9
# two frequency bands (bass + air), tighter windows — opts into band-pass filtering
python main.py song.mp3 output/ amc c 1,250;10000,15000 w 6
# reproducible render — same seed + same params = byte-identical output
python main.py song.mp3 output/ amc seed 42 l /2 ss 1.2
# a whole sweep at once — one file per grain length
python main.py song.mp3 output/ amc l [/2,/3,/4]You can also pass a YouTube URL or an "artist - track" search string in place of a file, and it
will fetch the audio first.
The fourth source, a peer of file / URL / search: the room you are sitting in.
python main.py --list-inputs # what this node can actually record from
python main.py --record 15 "" output/ amc l /2 # 15s off the mic, then grind it
python main.py --record 15 --record-device alsa_input.pci-0000_2d.analog-stereo "" output/In the TUI it is the ● REC button in the source panel, or ctrl+g from anywhere. Press to
start, press again to stop; the take lands in <output>/recordings/ and is loaded as the source
through the same pipeline a file takes.
Every take is measured before it is used. A muted, unplugged, or already-held mic produces a perfectly well-formed wav full of zeros — indistinguishable from a good recording by every check except the one that looks at the samples. So a silent or too-short take is reported and refused rather than handed to the grinder, and kept on disk so you can load it by hand if you meant it:
SILENT capture — 2.0s · 44100Hz · 1ch · rms 1 · peak 4 (below rms 8: nothing was on the input)
· pcmC0D0c is held by pid 1127493: arecord -D plughw:CARD=Camera,DEV=0
That second line is the difference between "the mic is broken" and "something else already has
it". Capture backends are tried best-first — pw-record, parecord, ffmpeg, then arecord
last because raw ALSA is exclusive and locks the card away from every other client on the
machine. Pin one with --record-backend if you want that.
A .monitor source records what this node is playing rather than the room — a legitimate
source (grind your own speakers), which is why the picker labels it instead of hiding it.
python main.py --pult output/ # prints a URL with a token; open it on the phone
python main.py --pult 9000 output/ # a different port
python main.py --pult --pult-bind 127.0.0.1 # this host onlygrainneukeln pult
output /home/you/grainneukeln/output
open http://192.168.8.224:8731/?t=Ur3k9Xq2p
A single mobile page with the same three verbs the TUI has — record · load · grind — plus a
player for what came out. Params go in as an amc line (l /2 ss 1.2 s 0.9), the same grammar
the TUI's command bar and the CLI take.
It is not a remote control for a running TUI: there is no IPC into a Textual app, and one would make the phone's buttons work only while a particular tmux pane happened to be alive. Instead it runs its own session and shares the TUI's checkpoint file, so params set on the phone are there when the TUI next starts, and the other way round.
Two things to know before you leave it running:
- The token is not optional. Binding to
0.0.0.0is the whole point — a phone has to reach it — and a LAN is not a trusted room. Every API route needs the token; it arrives once in the URL and then lives in the browser's local storage. Anyone who can reach the port and has the token can record and grind on this machine. Pin your own with$GRAINNEUKELN_PULT_TOKEN. - LAN only. Do not port-forward it. There is no TLS and no rate limiting; it is a pult for a room, not a service.
APK later — the page is a plain URL in the meantime, and it installs to a phone home screen as a web app.
python main.py --tui
# or: ./run_tui.sh
python main.py song.mp3 out/ --tui # preload a source, render into out/
python main.py "Aphex Twin - Rhubarb" out/ --tui --seed 42A keyboard-driven terminal UI you can run over SSH inside tmux — no display server needed. It reaches
everything the command line reaches: the amc grammar is a first-class surface here (via the
command bar, Ctrl+E), not a subset re-expressed as widgets, and the line above it is the live
recipe — exactly what Run will render, ready to copy out or paste in.
- Source (
Ctrl+1): file · YouTube URL ·artist - tracksearch · optional Source B. - Params / Mixer / Bands (
Ctrl+2/3/4): everyamcknob, live. Bands are raw or filtered — a raw band skips the band-pass filter and grinds ~27× faster; each row says which it is, so a slow grind is never a mystery. - Run (
Ctrl+5): WAV / verbose / self-feed / low-mem toggles, the series sweep, progress + log. - Uxn ROM control (
Ctrl+6): drive the params from a portable Uxn sequencer ROM, with a dry-run Preview that shows the whole plan without spending a render. - Outputs (
Ctrl+O): renders newest-first with size + age; non-blocking play/pause/seek.
Crash-tolerant: the session is checkpointed before every grind, so a crash (even an OOM) loses only
the render; Ctrl+T shows the recipe that bombed. F1 / ? opens the full keymap + amc grammar
on one screen:
→ Full walkthrough: docs/TUI_GUIDE.md. TUI extra: textual.
python main.py with no arguments launches the PySide6 desktop GUI: load from file or YouTube,
detect beats, configure and run the automixer, play and save. (GUI extras: PySide6, pyqtgraph.)
Needs a display server — on a headless box, use the TUI above.
The amc block is a list of key value pairs. The handful you'll reach for most:
| param | meaning | example | in short |
|---|---|---|---|
l |
grain length | l /2, l *3, l 250 |
/N·*N scale the beat-derived default by ratio (stays on the grid); a bare number is absolute ms. Shorter = finer, more fragmented. |
s |
whole-mix speed | s 0.8 |
tempo of the final track (pitch preserved). <1 slower, >1 faster. |
ss |
per-grain speed | ss 1.2 |
tempo of each grain (pitch preserved) — warps the micro-texture. |
c |
channels / bands | c 0,250;250,15000 |
Opt-in band-pass filtering. Each low,high band pulls its own grain and they're layered. Omit it for the fast raw-grain default (4–5× faster). |
w |
window divider | w 4 |
windows = total_beats / w. Bigger w → tighter windows → more local grain picks. |
m |
mode | m rw, m q, m poly, m lib |
grain-selection algorithm (see below). rw is the default. |
seed |
RNG seed | seed 42 |
reproducibility — same params + seed = byte-identical output. Also --seed 42. |
There are more — per-grain envelope taper (env) and reverse (rv), a second source (src2),
snap-to-beat (snap), swing (sw), euclidean patterns (ek/en), cyclic patterns
(pat/cyc/rot/acc — see below), poly streams (pr), library clustering (lib/lk), and
series sweeps ([...]).
E(k,n) only knows "k hits spread as evenly as the integers allow", over exactly one beat. pat
replaces it with an explicit cycle: a named timeline from the library, an x..x..x. string, or
an additive meter +2,2,2,3 whose gaps are deliberately uneven. cyc says how many beats the cycle
spans (a 16-pulse clave wants four — crammed into one beat it is a blur), rot rotates it, and
acc is a per-slot accent map in dB. That last one is what makes an all-hit cycle like a teental
theka or a gnawa qraqeb stream a groove instead of a wash: those traditions carry the rhythm in
which strokes are accented, not in which slots are struck.
python main.py djembe.wav out/ amc m q pat bembe snap # the 12/8 standard bell
python main.py tabla.wav out/ amc m q pat teental snap # 16 matras, khali at 9
python main.py doira.wav out/ amc m q pat aksak9 snap # 9/8 limping meter
python main.py song.mp3 out/ amc m q pat clave32 rot 4 acc 0,-9
python main.py song.mp3 out/ amc m q pat list # the library, with traditions./ethnic_grind.sh is a worked batch: thirteen Asian and African percussion recordings, each ground
on a timeline from its own tradition, plus cross-continental src2 grafts — every render checked
against its source before it counts.
→ Every parameter, the cross-mode matrix, and recipes: docs/PARAMETERS.md.
| mode | what it does | try |
|---|---|---|
rw |
random window (default) — picks a random beat per grain; the groove is whatever the source had. | amc m rw |
q |
quantized euclidean — fires grains only on the slots a pattern E(k,n) marks: designed grooves (tresillo, cinquillo…) over the source's onsets. |
amc m q ek 3 en 8 |
poly |
phasing polyrhythm — N parallel streams at different subdivisions, overlaid so they phase (Reich-style). | amc m poly pr 4;3 |
lib |
feature library — measures every grain on 4 axes, clusters them, and sequences by similarity (sim, hypnotic) or contrast (con, glitchy). |
amc m lib con lk 8 |
→ The code-level algorithm for each: docs/ALGORITHMS.md.
Wrap any sweepable param's value in square brackets and amc renders the cartesian product —
one file per combination:
python main.py song.mp3 output/ amc l [/2,/3,/4] # 3 renders, grain length varies
python main.py song.mp3 output/ amc l [100:300:50] # numeric range → 100,150,200,250,300
python main.py song.mp3 output/ amc s [0.8,1.0,1.2] ss [1.0,1.5] # 6 renders, speed × sample-speed
python main.py song.mp3 output/ amc seed [1,2,3,4,5] # 5 renders, same recipe, different grain picks
python main.py djembe.wav out/ amc m q pat [bembe,clave32,teental,aksak9] # the timeline sweepThe timeline sweep is how you find out which metric universe a recording belongs to: the same
source ground on a 12/8 bell, a 16-pulse clave, a 16-matra theka and a 9/8 aksak, one command. (An
additive pattern sweeps written with semicolons — pat [+2;2;3,bembe] — since the series body
splits on commas. acc cannot be swept for the same reason, and says so instead of quietly
rendering once.)
[a,b,c] is an explicit list; [start:stop:step] is an inclusive numeric range. Quote the brackets
in the shell to defeat globbing. Each render's filename encodes its own params so you can tell
combinations apart. The TUI's Run panel and the interactive shell (am N runs combination #N) expose
the same grammar. Full grammar and the sweepable-param list are in
docs/PARAMETERS.md.
Run without any amc params to drop into an interactive cutter
(python main.py song.mp3 output/):
| command | does |
|---|---|
p |
play the current selection |
b <ms> / l <ms> |
set selection start / length |
s <ms> |
set the step size for f/r |
f / r |
step forward / rewind (repeat the letter to go further: fff) |
cut / cut -a |
export the current selection (-a snaps to a nearby amplitude peak) |
autocut [n] |
export many cuts automatically |
am / am N |
automix the whole track (am N renders combination #N after a series amc) |
amc … / amc info |
set automix params / show the current config |
load <file> |
load a different track |
plot / info |
view amplitude / current settings |
set_wav_enabled / set_wav_disabled |
also export WAV alongside MP3 |
help / q |
help / quit |
| doc | what's in it |
|---|---|
| docs/HOW_IT_WORKS.md | the rhythm-hallucinator character (with measured proof), the pipeline step by step, and performance benchmarks |
| docs/PARAMETERS.md | every amc parameter, the cross-mode matrix, series-run grammar, and recipes |
| docs/ALGORITHMS.md | the code-level machinery of each mixer — euclidean generator, polyrhythm math, lib clustering + Markov sequencing, loudness, determinism |
| docs/TUI_GUIDE.md | the full TUI walkthrough — every panel, keybinding, and the Uxn ROM control layer |
| uxn_ctrl/README.md | driving a whole render sequence from a portable Uxn ROM (--uxn-ctrl, --uxn-feedback) |
./run_granular_sampler.shBuilds the image and runs the container with settings for your OS. Windows users may need an X server (e.g. VcXsrv) for the GUI.
Contributions welcome — please open a Pull Request.

