How ADNS SPACE works
ADNS SPACE is a one-shot sampler you play on an X-Y pad. You load a short sample, and an arpeggiator plays it in your key and scale while you drag. The sound runs through ten effects you can put in any order and a Saturator at the end. Six LFOs and two envelopes move the knobs, and a 64-step sequencer can lock any knob on any step. Everything you play is recorded as audio and as automation, so a performance can be re-rendered later with a different sample, key or scale.
It runs in the browser and as a Mac app. There is no server and no account. It is built with TypeScript, React, Zustand and the Web Audio API, with AudioWorklets for the effects that need per-sample code, and Electron for the Mac app.
This page explains how the parts fit together:
- Two threads, two clocks
- The lookahead note scheduler
- The audio graph and the effect order
- AudioWorklets
- The store and modulation
- Takes: recording and exact re-render
- The Mac app: Electron, updates and releases
- How it is tested
1. Two threads, two clocks
The app has two threads and two clocks.
MAIN THREAD (JavaScript, can stall) AUDIO THREAD (real time)
────────────────────────────────── ────────────────────────
React UI ──► Zustand store ◄── arp, sequencer Web Audio nodes
│ LFOs, envelopes AudioWorklet processors
│ recorder (grain, shifter, phaser,
▼ auto pan, corpus, recorder)
engine.apply(params) ─────────────────────► AudioParams, port messages
engine.trigger(time) ─────────────────────► note starts at exactly `time`
clock: setInterval / setTimeout clock: AudioContext.currentTime
(jitters by many ms) (sample-accurate)
JavaScript timers are late whenever the main thread is busy drawing, garbage-collecting or handling input. The audio clock never is. So the rule throughout the code is: timers decide when to look, the audio clock decides when things happen. Every note, step and lock carries an AudioContext timestamp. The LFOs, envelopes and recorder all measure time on the audio clock too, which is also what makes a re-render repeatable (section 6).
The engine, arpeggiator, sequencer, LFOs and recorder are plain modules: module state plus one exported object, no classes. The only classes are the AudioWorklet processors, because the Web Audio API requires class … extends AudioWorkletProcessor.
2. The lookahead note scheduler
src/audio/arp.ts and src/audio/seq.ts
Starting each note from a timer callback would make the rhythm wobble with every hiccup on the main thread. Instead both the arpeggiator and the sequencer use the classic Web Audio lookahead pattern:
- a timer runs every 20 ms;
- on each run it schedules every note that falls within the next 100 ms, each at its exact audio time;
- a running
gridTimeholds where the next note belongs on the grid, so timing errors never accumulate.
audio time ──────────────────────────────────────────────────────────────►
now now + 100 ms (horizon)
│◄──────── lookahead ────────►│
grid: · │ ♪ ♪ ♪ │ ♪ ♪
│ └─ scheduled on this tick ─┘ └─ next ticks
timer: ▲ ▲ ▲ ▲ ▲ (every 20 ms, may be late; the notes are not)
As long as a timer is never more than about 80 ms late, nothing is lost. A late timer only means notes are handed to the audio thread a little closer to their time.
Each note reads the settings at the moment it is scheduled, through the same "effective" values the effects hear (section 5). So an LFO on the pad's X axis plays a melody, and an LFO on Gate or Swing changes the next note. Swing delays every second 1/8 or 1/16 note by a fraction of its length. Gate turns into a fade-out time passed with the note. The first note is placed 5 ms after the pad is touched, so it plays straight away instead of waiting for the next grid line.
The sequencer's step locks. A step can hold its own value for any effect knob. The note is scheduled up to 100 ms early, but the knob must change exactly when the step starts, not when it was scheduled. Each scheduled step therefore gets:
- a timer set for the moment the step starts, which hands its locks to the modulation layer;
- a fallback on every 20 ms tick: the latest step whose audio time has passed is applied, in case that timer fired late.
Steps only ever move forward (a counter of the last step applied), so an early timer and a later tick never undo each other.
3. The audio graph and the effect order
src/audio/engine.ts, src/audio/fx/
sample ─► voice ─► voiceBus ─► [ 10 effects, in the order of the strips ] ─► fade
Filter ─► EQ 1 ─► Shifter ─► Phaser-Flanger ─► Grain delay
─► Space ─► Auto pan ─► EQ 2 ─► Corpus ─► Filter delay
(the starting order; drag a strip to change it)
fade ─► Saturator (always last) ─► safety limiter (-0.3 dB) ─┬─► speakers
├─► level meter
└─► recorder ─► silent gain ─► speakers
The voice. Like a classic hardware sampler in mono mode, one note sounds at a time. A note is an AudioBufferSourceNode whose playback rate is 2^((note − root) / 12), so higher notes are shorter and brighter, as on a real sampler. A new note fades the previous one out over 4 ms at its own start time ("choke"), so notes cut each other off without clicks. A gate shorter than the sample schedules an 8 ms fade-out. A second gain node per voice follows an envelope when one is shaping loudness, kept separate from the choke.
Every effect has the same shape. An effect is a factory function that returns input, output and apply(params). Most are built on a shared "slot": a dry path, a wet path and an equal-power Mix between them. Off means dry only, so an effect that is switched off costs nothing in sound. Every parameter change ramps over 15 ms (setTargetAtTime), so knob turns and LFO movement never click.
Reordering. The effects can be dragged into any order. To rewire the graph while sound is playing, the engine fades a gain stage to silence over 12 ms, disconnects every effect output, reconnects them in the new order and fades back in. The order is a list of effect ids. Anything read back from storage or from an old take goes through normalizeOrder(): unknown or repeated ids are dropped and missing ones are put back in their default place. That way an effect added in a later version still appears in an order saved by an older one.
The end of the chain does not move. The Saturator is a WaveShaperNode, 4× oversampled to keep aliasing down, with a curve built in code (soft clipping, a cubic term for third harmonics, ripples for folded tones and a gate near zero). It is only rebuilt when one of the knobs that shape it changes. After it, a DynamicsCompressorNode at -0.3 dB, 20:1, acts as a hidden safety limiter. The recorder hangs off the limiter through a gain set to 0: an AudioWorklet only runs while something pulls on its output.
The reverb is a ConvolverNode with an impulse response generated in code from a fixed seed, so the same settings always give the same reverb. Generating it takes time, so rebuilds wait until Size, Decay or Damp have stopped moving for 120 ms. That is also why those three knobs can't be mapped or locked.
4. AudioWorklets
src/audio/worklets/
Built-in Web Audio nodes cover filters, delays, gains and convolution. Anything that needs its own per-sample code runs in an AudioWorklet processor on the audio thread:
| Processor | What it does |
|---|---|
| Grain delay | A 4-second circular buffer. Hann-windowed grains with 2× overlap read it back delayed, pitched and scattered. The reverb's shimmer reuses it at +12 semitones. |
| Shifter | Pitch mode: a dual-tap delay-line shifter with crossfaded windows. Freq mode: single-sideband frequency shifting through a 90° all-pass network (I/Q). Ring mode: a ring modulator. A feedback delay sends the result back in for spirals. |
| Phaser-Flanger | Up to 84 first-order all-pass stages with coefficients recomputed every 16 samples, or a swept fractional delay for Flanger and Doubler. |
| Auto pan | Left and right gains computed for every sample, so even fast square waves stay exact. |
| Corpus | Modal synthesis: up to 16 band-pass resonators per channel, one per partial of the chosen object (beam, marimba, string, membrane, plate, pipe, tube). Each resonator's Q is set so it rings for the requested decay time. |
| Recorder | Copies the limiter's output into 4096-frame blocks and transfers them to the main thread (no copy). |
How they talk to the app:
- Settings arrive as messages. Each effect turns its knobs into real units (Hz, seconds, cents) and posts them over the worklet's port only when they actually changed, so a 10 ms LFO tick doesn't flood the audio thread.
- Time comes from the audio thread. A processor's LFO phase is computed from the worklet's own
currentTimeminus a start time (epoch/resetAt) the app sends when a take starts. It is never counted from the moment the processor happened to be created. - Randomness is seeded. Grain spray and the random auto-pan shape use a seeded generator or hash, never
Math.random(). - Memory can be wiped. A
clearmessage empties delay lines and filter state, so a re-render starts from silence instead of the last performance's tail.
The last two points are what make re-renders exact (section 6).
Two practical details: the build keeps worklet files as real files instead of inlining them as data: URLs, which addModule() does not reliably accept. And the Mac app has to serve them with a JavaScript content type, or addModule() refuses them.
5. The store and modulation
src/state/store.ts, src/state/params.ts, src/state/history.ts, src/audio/lfo/index.ts, src/audio/env.ts
One flat record of numbers. Every control in the app, from every knob, selector and switch to the sequencer's Play button and each LFO's mapping, is a number in one Record<string, number> in a single Zustand store. Knobs are always 0 to 1. A shared map table turns that into Hz, dB, ms and so on, and the engine and the on-screen labels both use it, so they never disagree. Because everything is a number in one place:
- React components subscribe to the slices they draw and re-render only when those change;
- audio modules subscribe outside React (
useStore.subscribe,getState()); - the recorder records any change by subscribing once (section 6);
- an LFO mapping is just a number (an index into a target list), so it is recorded and replayed like a knob.
The store is updated immutably, so "did anything change?" is a single reference comparison.
Base values and effective values. The store holds what you set: the values the knobs show and the recorder records. What the effects hear is computed from it in layers, every time something changes and every 10 ms while anything moves:
store (your knobs) ──► step knob locks ──► LFOs ──► envelopes ──► effective params
(replace the knob (swing (add on top) │
for that step) around ├─► arp & sequencer (next note)
it) └─► engine.apply() ─► effects
▲
effect on/off locks ── only while that step is the voice
- Step locks replace a knob's value for the step that is playing. An LFO mapped to a locked knob swings around the lock instead of the knob.
- LFOs add
wave × depth + offset, scaled and clamped to the LFO's Min and Max. The 10 ms ticker only runs while at least one LFO or envelope is actually driving something. - Envelopes add
level × amounton top. An envelope that drives no knob shapes the note's loudness instead. - Effect on/off locks are kept out of that mix on purpose. A step can switch an effect on just for itself, but the pad's notes and the unlocked steps keep the switch as you left it. So the engine only adds those switches while a sequencer step is the sound playing, and drops them again when a pad note takes over.
Modulation is a function of time, not of state. An LFO doesn't keep a running phase. Its phase is computed from the time since a reference point: the start of the take (Free), the last pad touch (Touch) or the last note (Note), or the last Reset press if that is later. Its random shapes take their values from a hash of (seed, LFO, cycle). Envelopes are a pure ADSR function of the note's start and end times. Given the same seed and the same timeline, the modulation comes out identical, which section 6 relies on.
Saved takes depend on the order of the target list. Takes store LFO mappings as indices into the target list. New targets are therefore only ever appended, and removed effects keep their slots. A test pins the full list so a reorder can't slip in and silently remap old takes.
Undo and Redo come almost for free. Because every setting is in the store and the store is immutable, the history module is one more subscriber:
- The first change of a gesture keeps a reference to the state just before it. The gesture ends on mouse-up, on the next mouse-down, or 400 ms after the last change when no button is held: keys, the wheel, a pick from a menu, or a click whose change lands after mouse-up. Then the two states are compared, and only the differences are kept: each changed param as
[before, after], and each changed sequencer step as a pair of references, since steps are immutable objects too. So a whole knob drag is one step, and a step costs a few numbers, which is why the history can keep 10,000 of them. - Undo writes the "before" side back in one store update, and Redo the "after" side. They are ordinary changes, so the recorder logs them like any other.
- Playback switches (sequencer Play, arp Hold) and the LFO restart counters are left out, and so is anything a re-render writes while it loads and replays a take. The effect order, takes and the sample sit outside
paramsandsteps, the only two parts the history compares, so they are never undone.
6. Takes: recording and exact re-render
src/audio/recording.ts, src/storage/db.ts
A take is the audio plus everything needed to play the performance again:
| Part | What it is |
|---|---|
| Audio | 24-bit stereo WAV from the recorder worklet. Trailing silence is trimmed, leaving 100 ms. |
| Snapshot | The full settings record at the first touch. |
| Event log | Every settings change {t, key, value} and every pad move {t, x, y, touch}, with t in seconds of audio time since the start. |
| Seed | One random number that every random part of the sound is derived from. |
| Stop time | When Stop was pressed. The tails after it ring out but aren't part of the performance. |
| Pattern and effect order | Copies of the sequencer steps and the effect order at the start. |
Recording. Rec arms the recorder, and the next pad touch (or ▶ on the sequencer) starts the take. Pad gestures from your mouse and from a replay go through the same function, which starts the recorder before the pad is marked as touched, so the first note is always in the take. At the start the recorder:
- picks a seed and notes the audio time
t0; - sends that seed and
t0to the arpeggiator's random pattern, the grain and auto-pan worklets, the shifter and phaser LFOs and the app's LFOs; - copies the settings, the pattern and the effect order;
- subscribes to the store: from then on every change is timestamped on the audio clock and appended to the log.
On Stop the arpeggiator and sequencer stop, but recording goes on until the reverb and delays have died away (0.4 s below -66 dBFS, or 30 s at most). The take is then encoded and saved to IndexedDB.
Re-render plays the performance again through the current sample, key and scale:
- Clear every effect's memory, so no tail from before leaks in.
- Apply the take's snapshot, except key and scale, which come from the screen. A setting added in a later version than the take gets its default, which is how the take sounded.
- Use the take's pattern and effect order, without touching the ones on screen.
- Start a new take with the same seed, so every random source restarts identically.
- Replay the event log on a 4 ms timer against the audio clock, through the same code paths as live input, then stop at the stored stop time.
saved take: snapshot ─┐ seed ─┐ events: t=0.00 pad down (0.31, 0.62)
│ │ t=0.42 filter.cutoff = 0.55
▼ ▼ t=1.10 lfo2.target = 14 …
re-render: apply ─► reseed every random source ─► dispatch each event when audio time ≥ t
│
└─► arp Rnd pattern, grain spray, reverb shimmer,
auto-pan wave, shifter & phaser phase, LFO shapes & jitter
Every timestamp, from the log to the LFO phases and the worklets' epochs, is relative to the take's start on the audio clock, and nothing uses unseeded randomness. A replay only has to recreate the inputs: the scheduler, the modulation and the DSP then produce the same notes at the same offsets. In testing, a re-render with the same settings matched the original take with an envelope correlation above 0.999. The new take is saved as a take of its own.
7. The Mac app: Electron, updates and releases
electron/, .github/workflows/release.yml
The same web app inside a locked-down window. The Mac app loads the same build as the browser version. It is served under a custom privileged scheme, app://local/, rather than file://, so the page has a real origin: IndexedDB persists, worklets load with addModule() and absolute asset paths work. A small pure function maps each address to a file and refuses anything that would escape the build folder (../, the same percent-encoded, null bytes). The page runs sandboxed with context isolation and no Node access. Its only bridge to the Mac side is a preload script that exposes a fixed set of update calls. Links to other sites open in the default browser, and leaving the app is blocked.
The window has one fixed size, 1400 × 780, like a plugin window, which fits the 13-inch MacBook Air with the menu bar and Dock. Only on a screen too small for that does it shrink, keeping its shape. There is no title bar: close and minimize sit in the app's own top bar. Electron can't hide only the green zoom button, so a tiny native addon (Objective-C, Node-API) does that. --smoke-test opens the app hidden, waits until the audio engine reports that every worklet has loaded, and exits 0 or 1. The release pipeline uses it on the packaged app.
Self-updates. The app checks the public releases 5 seconds after start, every 10 minutes and when you come back to it. If a newer zip is there, a banner offers Update, Later or Skip this version. An update is only installed after these checks:
GitHub releases API ─► newest ADNS-SPACE-<x.y.z>-mac.zip above the running version (not skipped)
─► can this copy replace itself? (not on the disk image, not translocated, folder writable)
─► download to a temp folder ─► full size? ─► unzip (ditto)
─► codesign --verify --deep --strict, with the requirement
"anchor apple generic and certificate leaf[subject.OU] = <the running app's team>"
─► is its version the one announced?
─► Restart now: a small detached script waits for the app to quit, moves the old app aside,
moves the new one in (restoring the old one if that fails), and reopens it
The team requirement means a signed app only accepts an update signed by the same Apple Developer team through Apple's certificate chain. An older ad-hoc signed version accepts any valid signature, so it could update to the first signed one. All of this logic is plain functions with no Electron imports, so it is unit-tested, including the swap script.
Releases. Pushing a version tag starts a workflow on a macOS runner:
tag X.Y.Z ─► tag matches package.json? ─► type-check ─► unit tests ─► build
─► sign with the Developer ID (keychain created for this run) ─► notarize
─► verify: team, hardened runtime, stapled ticket, Gatekeeper
─► start the packaged app (--smoke-test) ─► end-to-end tests against the packaged app
─► notarize the disk image ─► publish to the public download repository
The source repository is private. Releases go to a separate public repository that only holds the downloads. The signing keychain and API key exist only for the length of the run.
8. How it is tested
- Unit tests (Vitest, jsdom) sit next to the code. The schedulers, LFOs, envelopes and recorder run against a fake engine whose audio clock the test moves by hand, in step with Vitest's fake timers. So "this note lands at exactly 0.6250 s" and "this lock applies at the start of step 5, not when it was scheduled" are ordinary assertions. The real worklet files run sample by sample in Node to check, for example, that a phaser notch sits at Center and that maximum feedback stays stable. Every effect is also checked against a recording fake of Web Audio. Undo and Redo are tested gesture by gesture: a drag, a slide across steps, a typed BPM followed by a drag, a pick from a menu, and a re-render that must leave no step behind. The build runs all of them.
- End-to-end tests (Playwright) start the real Mac app with a temporary profile and use it like a person: load a WAV, play the pad, record, re-render, download, restart. The output meter checks that sound really comes out. Separate tests run a real self-update on a packaged app, and screenshot tests compare the effect strips, the LFO and envelope panels and the Saturator bar with saved pictures.
- CI type-checks, tests and builds every pull request. The release workflow repeats the end-to-end tests on the packaged, signed app before anything is published.