A stochastic multi-speaker texture sequencer.
Drift Wood plays a library of long, pre-rendered sounds across a ring of speakers with the audience in the middle. A composed arc decides how full the room is and how fast it changes. Chance decides everything else — which texture, on which speaker, for how long, and when it moves.
Sounds move between speakers rather than crossfading in place. One speaker fades a texture out while a different one brings another in, so the piece travels around the room instead of sitting in front of you. Silence is a legitimate state for any speaker, which lets activity concentrate in one part of the ring and then migrate somewhere else.
It runs unattended for as long as you ask it to, and it never plays the same session twice unless you tell it to.
Five states, walked once, each given a share of the session in proportion to its weight:
sparse -> growing -> dense -> fragmenting -> collapse
Each state carries a weighted draw pool of sound families, a density range (how
many speakers are sounding), and a dwell range (how long before a speaker
reconsiders what it is doing). That table, at the top of driftwood/arc.py, is
the composition. Everything else in this repository exists to play it.
Inside a state, nothing is fixed. Which family gets drawn, which file from it, which speaker changes next and how long it holds — all sampled. Two constraints hold throughout:
- No two speakers play the same texture at once. That reads as one doubled source rather than as two places, which is the opposite of the point.
- No two textures enter within three seconds of each other. Entrances are spaced on the moment the sound actually starts, not the moment the decision was made, so an all-at-once onset is structurally impossible.
A session runs against a virtual clock in well under a second:
python3 -m driftwood path/to/sounds --duration 40 --dry-runseed 42 duration 40.0 min dwell x1 6 speakers library 18 files in 6 families
time n event
--------------------------------------------------------------
00:00 0 == SPARSE ==
00:00 1 speaker 1 <- sparse_02.flac
01:40 2 speaker 4 <- sparse_03.flac
01:44 1 speaker 1 fades out
02:11 2 speaker 3 <- sparse_01.flac
03:59 1 speaker 4 fades out
...
--------------------------------------------------------------
40:00 1 == OUTRO ==
105 texture entrances, 18 distinct files used
sparse mean 1.4 speakers sounding
growing mean 2.8 speakers sounding
dense mean 5.2 speakers sounding
fragmenting mean 4.1 speakers sounding
collapse mean 1.6 speakers sounding
A seed reproduces a session exactly, which is the only honest way to compare two libraries — otherwise you are comparing two different compositions.
python3 -m driftwood sounds --seed 42 --dry-run # same session, every time
python3 -m driftwood sounds --dwell-scale 0.5 --dry-run # twice the transitions--dwell-scale is not a speed control. State boundaries stay on wall-clock time
while decisions compress, and all the voices share one random stream, so a scaled
session is a different session with the same shape.
Watch a session replay (21s) — or open ring.html yourself.
ring.html replays a recorded session. Open it — no server, no build step, no
network. It ships with a session embedded, and takes your own:
python3 -m driftwood sounds --duration 12 --dry-run --json my-session.jsonThe rings are the real volume envelope, not an animation. Every value drawn is a value that was sent, so a fade you watch dissipate is the fade that was heard.
Drift Wood has no idea how sound reaches a speaker, and does not want one. It needs exactly four methods:
class MyBackend:
def loop(self, speaker: int, file: str) -> None: ... # start looping, silently
def stop(self, speaker: int) -> None: ...
def volume(self, speaker: int, vol: int) -> None: ... # 0-100
def close(self) -> None: ...from driftwood import Session, RealClock, scan_library
lib = scan_library("sounds")
sess = Session(lib, duration_s=40 * 60, seed=42,
clock=RealClock(), backend=MyBackend(), speakers=6)
sess.run()Two assumptions it makes about you:
- One stream per speaker —
loop()replaces, it does not layer. This is what forces transitions to happen across speakers instead of within one. loop()starts silent — the session sends volume 0, then ramps up. A texture should arrive, not appear.
volume() is called a couple of thousand times in a long session and is
fire-and-forget: a lost step is inaudible and the next one corrects it. Don't
make it block.
| Backend | Use |
|---|---|
null |
--dry-run. The trace is the output. |
stdout |
One line per cue. Pipe it into anything. |
osc |
OSC over UDP — Max/MSP, Reaper, SuperCollider, Ableton, Pd. |
python3 -m driftwood sounds --backend osc --osc-port 9000/driftwood/loop ,is speaker, filename
/driftwood/stop ,i speaker
/driftwood/volume ,ii speaker, 0-100
The OSC encoder is written out by hand, because this repository has no dependencies at all. Python 3.8+, nothing else.
A flat directory. Files are named <family>_<anything>.<ext>:
sparse_01.flac dense_01.flac sustained_01.flac
transient_01.wav low_02.aiff chaotic_03.flac
Families are sparse, dense, sustained, transient, low, chaotic — the
six the arc is written against. A prefix it does not recognise is a hard error
naming the file, because rendering ten textures, mistyping one, and never
noticing it was absent is a worse outcome than a loud complaint.
You do not have to name anything. If no filename matches a family, the
library collapses to a single pool and the arc still runs: density, pacing and
the order of the states are all preserved. What is lost is timbral selection —
nothing can tell which of your sounds is the "dense" one. A mixed library is
still an error, because one odd name among seventeen correct ones is exactly what
a typo looks like; pass --free if you meant it.
Material wants to be long (a minute or more), loops seamlessly, and is matched in loudness across the library — the same volume is sent whichever file a speaker drew, so a level difference is heard directly on every entrance.
Six by default, because that is what it was composed on. --speakers N works for
any N: the density column is expressed in sixths and scaled. At six the
multiplier is exactly 1.0, so the original composition is untouched — that is
asserted in the test suite, not assumed.
python3 tests/test_driftwood.py # 82 checks, no hardware, no waiting
python3 tools/mutation_test.py # reintroduce 15 bugs; all must be caught
bash tools/scrub_check.sh # nothing here names anyone's hardwareEvery check was confirmed to fail when the bug it guards is reintroduced. Four of them exist because the mutation pass caught guards that proved nothing: the outro paths and the starved-swap case are rare enough that a normal session may never take them, so they are driven directly rather than sampled.
MIT. See LICENSE.
Drift Wood is by Merlin Goldman. The sound library that goes with the original installation is not part of this repository — bring your own.