Een procedureel muzieksysteem dat elke start ergens anders begint, nooit terugkeert naar dezelfde staat, en volledig bedienbaar is met vier vlakken op een iPhone-scherm. Elke generatie zet een laag af; de laag is leesbaar, reproduceerbaar en herspeelbaar.
Een seed van 32 bits bepaalt een genoom: toonsoort, modus, tempo, luslengtes, ritmecel, CA-regel, FM-timbres, en welke parameters op welk vlak liggen. Vanuit dat genoom draait een klok die generatoren aanstuurt; elke acht maten muteert precies één gen. De gebruiker heeft vier vlakken die per generatie een andere parameter kunnen sturen — behalve de vlakken die hij heeft vastgezet. Alles is deterministisch: seed plus actielog reconstrueert de sessie noot voor noot.
Een varve is een jaarlaagje sediment: één paar lagen per jaar, afleesbaar als archief. Strata is het meervoud van de lagen die het systeem afzet. Het project heet zoals het werkt.
Het AV-synth-traject liep parallel met hetzelfde doel. Besluit sept 2026: één systeem. Strata levert de motor en de modulestructuur, de AV-synth levert de beeldlagen, de koppelingsmatrix en de Bank. Beeld wordt laag L8.
Determinisme is geen luxe maar de architectuurbeslissing waar alles aan hangt: archief, herspeelbaarheid, deelbaarheid en debugbaarheid komen er gratis uit.
Van bedrock naar oppervlak. Elke laag kent alleen de laag eronder. Geen laag mag omhoog reiken — dat is de regel die het systeem testbaar houdt.
Dit is de kern van het ontwerp en het punt waarop Strata verschilt van elke andere generatieve app: het vlak is het instrument, niet de parameter. Er zijn altijd vier slots. Een slot houdt een handle vast. Bij elke generatie mag het systeem een slot herbinden aan een andere handle uit de pool. Wat de gebruiker vasthoudt, blijft.
Handle = {
id, label, sub, // 'dichtheid', 'noten / stap'
get(g), set(g, v), // leest/schrijft genoom, altijd 0..1
curve: 'lin'|'exp'|'step', steps?: n,
affinity: ['continu','bipolair'], // welke gebaren passen
weight: 1.0 // fitness, groeit door vasthouden
}
Slot = { index: 0..3, handle, value, latched, range, touchedAt }
Intent = { slot, kind, amount?, axis? }
kind ∈ tap | doubletap | hold | drag | flick | pinch | tilt | shake
| gebaar | intentie | effect op slot | effect op systeem |
|---|---|---|---|
| tik | tap | +1 stap (curve-afhankelijk) | — |
| dubbeltik | doubletap | terug naar systeemwaarde | — |
| lang drukken 500 ms | hold | latch aan/uit | fitness +1 op deze handle; slot wordt niet herbonden |
| sleep ↕ | drag y | absolute waarde, 1 vlakhoogte = volle range × bereik | — |
| sleep ↔ | drag x | secundaire as van de handle | — |
| flick > 1,2 px/ms | flick | sprong naar willekeurige waarde | fitness −1 op deze handle |
| knijpen | pinch | bereik ×0,25 – ×2,0 (fijnregeling) | — |
| kantelen | tilt | additieve modulatie, niet-destructief | alleen op niet-gelatchte slots |
| schudden | shake | — | forceer generatie nu |
| twee vingers houden + sleep | drag fijn | ×0,1 gevoeligheid | — |
rebind(slots, pool, rng):
kandidaten = slots.filter(s => !s.latched
&& now - s.touchedAt > 45_000) // niet net aangeraakt
if kandidaten.length === 0 or rng() > 0.35: return // 35% kans per generatie
slot = pick(kandidaten)
vrij = pool.filter(h => !slots.some(s => s.handle.id === h.id))
nieuw = weightedPick(vrij, h => h.weight) // fitness stuurt de keuze
// crossfade: oude handle houdt 2 maten zijn waarde vast,
// nieuwe handle start op zijn huidige genoomwaarde => geen sprong
slot.handle = nieuw; slot.value = nieuw.get(genome)
log('rebind', slot.index, oud.id, nieuw.id)
| handle | schrijft naar | curve | secundaire as ↔ |
|---|---|---|---|
| dichtheid | kans per stap in markov + CA-drempel | lin | euclid-rotatie |
| helderheid | filter-cutoff + FM-index | exp | reverb-send |
| spanning | Krumhansl-gewicht (chromatiek) | lin | Tonnetz-afstand tot huis |
| tijd | tempo ×0,75–1,25 | lin | euclid n ∈ {8,12,16} |
| puls | euclid k | step 1..n−1 | swing 50–66% |
| textuur | CA-regel-index | step 6 | hi-hat toonhoogte |
| diepte | reverb-tijd + delay-feedback | exp | stereo-breedte |
| drift | Perlin-stapgrootte (hoe wild het systeem zelf beweegt) | exp | generatie-interval 4/8/16 maten |
| register | octaafspreiding melodie | step 4 | bas aan/uit |
| korrel F4 | granulaire positie in archiefsample | lin | korrelgrootte |
Tien handles, vier slots: op elk moment is 60% van het systeem autonoom. Dat is het punt — je stuurt bij, je bestuurt niet.
Twee snelheden. De trage snelheid is hoorbaar als beweging, de snelle als leven.
Perlin-ruis (1D, 3 octaven) op elke niet-gelatchte parameter. Periode 40–90 s per parameter, uit de seed. Amplitude = de drift-handle. Gecorreleerd, dus het ademt in plaats van te trillen.
Elke 8 maten precies één gen. Gewogen keuze; de gewichten verschuiven met fitness. Elke mutatie is een record in het strata-log.
| mutatie | kans | hoorbaarheid | beperking |
|---|---|---|---|
| euclid-rotatie ±1 | 19% | laag — het ritme verschuift | — |
| euclid k ±1 | 14% | midden | 1 ≤ k ≤ n−1 |
| markov-rij hertrekken | 14% | laag — melodie krijgt andere neiging | 1 rij per keer |
| grondtoon over de kwintencirkel F3 → Tonnetz | 14% | hoog — de muziek wendt | ver van huis alleen nog kwintstappen, geen tertssprong |
| CA-regel wisselen | 9% | midden — textuur verandert | uit {30,90,110,150,182,54} |
| fase-lus herzaaien | 9% | laag | lengtes blijven copriem |
| modus wisselen | 7% | hoog | alleen op maat 8 van een 32-maats blok |
| FM-ratio herzaaien | 6% | hoog — timbre verandert | uit harmonische set, nooit willekeurig |
| tempo ±4,5% | 7% | laag — het zakt of trekt aan | 40–120 bpm; het anker trekt terug |
hold op slot s → s.handle.weight *= 1.35 // dit wil ik houden
flick op slot s → s.handle.weight *= 0.75 // weg ermee
elke generatie → weight = 1 + (weight-1)*0.98 // vergeet langzaam
weight bepaalt: (a) kans dat de handle wordt gekozen bij rebind,
(b) kans dat het gen eronder muteert (hoog gewicht = met rust laten)
Geen expliciete "vind je dit mooi"-knop. De bediening zelf ís het fitnesssignaal — vasthouden is goedkeuren, weggooien is afkeuren.
Elke generatie schrijft één regel. Dat log is tegelijk archief, debugger en reproductie-instructie. Bij 74 bpm duurt een generatie 26 seconden, dus een uur levert ongeveer 140 regels op: circa 4 kB, plus de gebruikersacties.
{ s: "e5212af7", // seed, hex
t0: 1757462400, // starttijd unix
g: [ // per generatie: [maat, mutatiecode, waarde]
[8, 'er', +1], [16, 'mk', 3], [24, 'tn', 'L'], [32, 'ek', -1], ...
],
u: [ // gebruikersacties: [maat.tick, slot, intent, waarde]
[12.3, 0, 'd', 0.62], [19.0, 2, 'h', 1], [27.5, 1, 'f', 0.11], ...
] }
Zelfde seed + zelfde log = identieke audio, sample voor sample. Voorwaarde: elke Math.random() is verboden in de hele codebase. Eén ESLint-regel bewaakt dat.
OfflineAudioContext speelt het log op 40× snelheid af en levert een WAV. Een uur ambient rendert in ~90 s op de iPhone zelf — geen server nodig.
Logs gaan naar de artifact-database. Een sessie die goed was, is een permalink: ?s=e5212af7&l=…. Dat is de brug naar de albumradio en de lange mixen.
┌─ reverb (procedurele IR, 2.2 s) ─┐
voice ──▶ filter ──▶ gain ──▶ pan ──┼─ delay (3/16, fb 0.35) ─────────┼──▶ bus ──▶ comp ──▶ out
(wegwerp, max 24) └─ dry ───────────────────────────┘ │
analyser ──▶ viz
| stem | bouw | rol | budget |
|---|---|---|---|
| bas | triangle + lowpass, 1 osc | euclid-puls, fundament | 1 tegelijk |
| lead | FM 2-operator, ratio uit genoom | markov-melodie | 4 tegelijk |
| pad | 3× saw, licht ontstemd, traag env | fase-lussen (7/11/13) | 3 tegelijk |
| textuur | ruisburst + highpass | CA-accenten | 8 tegelijk |
| snaar F4 | Karplus-Strong via delay+lowpass-fb | gepluk, tegenmelodie | 4 tegelijk |
| korrel F4 | AudioWorklet, 40 korrels max | archieftextuur | 1 worklet |
OscillatorNode per noot) is correct Web Audio maar de GC-druk loopt op bij hoge dichtheid. Mitigatie: harde cap op 24 gelijktijdige stemmen met steal-by-oldest, en een stemtelling in de viz zodat het meetbaar is voordat het hoorbaar wordt.strata/
├─ index.html // shell, manifest-link, één script-entry
├─ sw.js // service worker: offline cache
├─ manifest.webmanifest
├─ core/
│ ├─ rng.js mulberry32, sub(seed,salt), fnv1a
│ ├─ genome.js fromSeed(s), mutate(g,rng), serialize/parse
│ ├─ clock.js Worker-tik, lookahead, onTick(from,to)
│ └─ worker-clock.js
├─ gen/
│ ├─ euclid.js bjorklund(k,n), rotate
│ ├─ markov.js matrix(n,rng), next(m,i,rng), reroll(m,i)
│ ├─ ca.js step(cells,rule)
│ ├─ phase.js coprime loops, advance
│ └─ walk.js perlin1d(seed), octaves
├─ harmony/
│ ├─ scale.js MODES, degToMidi(g,deg,oct)
│ ├─ tonnetz.js P, L, R, distanceFromHome
│ └─ krumhansl.js PROFILES, weight(pc,key,tension)
├─ synth/
│ ├─ graph.js buildGraph(ac) → {bus,verb,delay,analyser}
│ ├─ voices.js pool, alloc, steal
│ ├─ fm.js ks.js noise.js grain.js
│ └─ space.js procedurele IR
├─ mod/
│ ├─ drift.js apply(genome, t)
│ └─ evolve.js tick(genome, rng, fitness) → mutation
├─ ctl/
│ ├─ gestures.js pointer → Intent (geen kennis van muziek)
│ ├─ handles.js POOL: Handle[]
│ ├─ slots.js Slot[4], apply(intent), rebind()
│ └─ fitness.js
├─ io/
│ ├─ persist.js localStorage: seed, gen, slots, log
│ ├─ strata.js log schrijven/lezen, permalink
│ ├─ render.js OfflineAudioContext → WAV
│ └─ bridge.js WebSocket → NEXUS (OSC-vorm)
└─ ui/
├─ app.js pads.js viz.js hud.js
Geen framework, geen bundler in F1–F3. ES-modules native; Safari doet dat prima. Een bundler komt pas als het worklet-bestand erbij komt (F4).
| onderwerp | aanpak | fase |
|---|---|---|
| Audio ontgrendelen | Startscherm met één knop: resume() + stille buffer. Geen automatische start, ooit. | F1 |
| Belschakelaar dempt | Stille loopende <audio> bij unlock — dwingt de "media"-audiosessie af in plaats van "ambient". | F6 |
| Achtergrond doorspelen | Datzelfde element + MediaSession-metadata. Vergrendeld scherm toont titel = huidige toonsoort en generatie. | F6 |
| Timer-throttling | Worker-klok (F1 al). Bij visibilitychange → resume() en klok herijken. | F1 |
| Scherm aan | Wake Lock; opnieuw aanvragen na visibilitychange (iOS geeft hem vrij). | F6 |
| Zoom / scroll / rand-swipe | touch-action:none op vlakken, overscroll-behavior:none, vlakken ≥ 20 px van de schermrand, in standalone helemaal geen randgebaar. | F1 |
| Sensoren | DeviceMotionEvent.requestPermission() in de tik van de startknop meenemen — één toestemmingsmoment, niet twee. | F6 |
| Geen haptiek, geen MIDI | Accepteren. Feedback visueel + auditief. Externe hardware loopt via de WebSocket-brug. | — |
| Geheugen | IR procedureel genereren, archiefsamples ≤ 10 s mono 32 kHz, lazy laden. | F4 |
Perpetuum Harnas: device-probe, kennisbank, gebarenlog, werkend proefsysteem. Blijft staan als meetinstrument.
rng · genome · clock · euclid · markov · scale · graph · fm · noise · gestures · slots · pads · viz.
Klaar als: app start op de iPhone, speelt oneindig door, vier vlakken sturen vier vaste parameters, dezelfde seed geeft twee keer exact dezelfde eerste 60 seconden.
evolve · drift · fitness · rebind · strata-log · thuis-anker.
Gemeten over 200 generaties (≈ 85 minuten muziek):
| meting | uitkomst | oordeel |
|---|---|---|
| Zelfde seed, zelfde mutatiereeks | identiek | goed |
| Gemiddelde afstand tot de thuistoonsoort | 1,24 kwint | goed |
| Tijd ver van huis (> 3 kwinten) | 1,5% · max 2 generaties | uitstapje, geen wegdwalen |
| Tempo-uitslag rond de seedwaarde | 9,4 bpm | leeft en keert terug |
| Herbindingen | 37% van de generaties | op het ontwerp |
| Vastgezet vlak ooit herbonden | nooit | goed |
Negen lagen — nasleep, achtergrond, strata, contour, deeltjesveld, spectrum, golfvorm, gebeurtenissen, patch-graaf — met 21 bronnen en 8 krommen. Elke laageigenschap hangt via bron → kromme → sterkte aan wat er ook maar meet.
Beeld praat terug naar geluid. Twee wegen: de spreiding van het deeltjesveld stuurt de reverb-send, de rimpelenergie stuurt de delay-terugkoppeling en de demping. Het veld draait daarom op de audioklok met een vaste stap van 1/60 s en niet op requestAnimationFrame — anders zou beeldsnelheid de muziek veranderen en was de sessie niet meer reproduceerbaar.
| meting | uitkomst | oordeel |
|---|---|---|
| Deeltjesveld reproduceerbaar bij zelfde seed | identiek | determinisme blijft heel |
| Beeldsnelheid met 7 lagen aan | 60 fps | goed |
| Reverb-send door het veld, over 15 minuten | 0,31 – 0,41 | ademt, schokt niet |
| Sediment zet een band af per generatie | bevestigd | goed |
Bank gemigreerd: 75 entries in 8 hoofdstukken (één bron, krommen, bestemmingen, beeldlagen, gebaren, combinaties, lussen, stukken), elk met hint, "probeer", instellen, beoordeling en notitie. Beoordelingen gaan naar de database.
tonnetz (P/L/R, BFS-afstand, stap begrensd op 3 van huis) · krumhansl (K-K-profielen; spanning keert de weging om) · stemvoering: geen twee kwinten achter elkaar tussen bas en pad.
Gemeten: 300 generaties → 30 akkoordstappen over 12 verschillende drieklanken, nooit verder dan 3 stappen van huis. Elke fase-lus draagt nu één akkoordtoon; bij spanning > 0,6 wordt de terts een septiem.
ks (Karplus-Strong met native delay-feedback, seed per aanslag) · grain (AudioWorklet als Blob in de bundel, 40 korrels, eigen LCG) · sample-loader (mono, ≤ 10 s) · korrel-handle (↕ mix, ↔ positie) · ingebouwd testsample zodat de laag zonder archief al klinkt.
Gemeten: worklet laadt over http(s); onder file:// niet — op de iPhone dus wél. Render naar wav slaat de korrellaag over.
manifest + service worker + icoon (alleen in de echte deployment; de artifact is één bestand) · wake lock, opnieuw na visibilitychange · MediaSession met toonsoort en generatie op het vergrendelscherm · stille <audio>-loop bij ontgrendelen zodat de belschakelaar en de achtergrond geen audio meer stoppen · sensoren in dezelfde tik als de audio.
replay (acties op stapindex, niet op wandkloktijd — daarvoor zijn de afkoeltijden van slots en drift omgezet naar stappen) · permalink ?s=seed&l=log · OfflineAudioContext → wav via downloads-capability of anchor · sessies en Bank-beoordelingen naar de artifact-database · galerij van de laatste 12.
Gemeten: replay van 5 acties over 40 maten → byte-identieke motorstaat; 6 s render in 0,56 s (≈ 11× realtime headless), bestandsgrootte exact zoals berekend.
WebSocket-client met OSC-vormige JSON: per maat één bundel (/strata/bar, /gen, /key, /chord, /bpm, /p, /voices, /field, /euclid) en per gebaar /strata/action. Herverbinden met backoff. Adres in de app instelbaar.
Niet getest tegen NEXUS zelf — alleen het berichtformaat. Dat is de enige stap in deze reeks die op jouw laptop moet.
Gewichten overleven de sessie (localStorage) en kleuren elk nieuw genoom: een handle die je structureel vasthield start tot +0,3 hoger, weggeflickt tot −0,3 lager. Nooit een noot opgeslagen.
Gemeten: gewicht 1,6 op spanning → start 0,35 → 0,65; gewicht 0,6 op helderheid → 0,50 → 0,26.
Math.random() of één floating-point-verschil tussen toestellen breekt reproductie. De ESLint-regel dekt het eerste; het tweede is in de praktijk geen probleem zolang alle DSP-parameters via de graaf lopen en niet via handmatige sample-berekening.Alles wat mechaniek is, is gebouwd en gemeten. Wat overblijft, vraagt oren, een toestel of een laptop.
Drie artifacts en een repo zijn geen onderdeel; een contract wel. Dit is wat Strata naar buiten toe is, zodat het als één blok in de rest van het systeem past.
| contract | inhoud |
|---|---|
| identiteit | strata · seed van 32 bits · elke sessie = seed + log |
| ingang | seed (of niets), gebaren, eigen samples, gewichten uit vorige sessies, dagdatum (radio) |
| uitgang | audio (live / wav), beeld (canvas), OSC-bundel per maat, strata-log (~4 kB/uur), sedimentbeeld |
| geheugen | artifact-database: sessies, Bank-beoordelingen, fitness |
| documentatie | Harnas (waarom) · Blueprint (hoe) · README (draaien) — één navigatie, één huisstijl |
| bron | strata/: src, docs (PWA), test |
| module | koppeling | status |
|---|---|---|
| NEXUS / TouchDesigner | /strata/* OSC-bundel over WebSocket :8080 | gebouwd wacht op NEXUS |
| Varve Radio | dagselectie: seed = hash(datum), 60 min wav — deterministisch per dag, zoals de radio zelf | gebouwd |
| Oude Data Archief | "Sample laden" in de korrellaag (Heritage Rule) | gebouwd |
| Logic Pro | MIDI-clock via de laptopkant van de brug | niet gebouwd |
| varve.nl | projectkaart met seed-permalink | jij |
| Second brain / masterdocument | strata-logs zijn databaserecords | leesbaar |
| Werkwereld | Strata als instrument in de 3D-werkplaats | idee |
Wat het nu nog niet één onderdeel maakt: drie losse artifact-adressen en een PWA die nergens gehost is. De repo lost dat op.