ADNS SPACE

How it's built

Download page

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:

  1. Two threads, two clocks
  2. The lookahead note scheduler
  3. The audio graph and the effect order
  4. AudioWorklets
  5. The store and modulation
  6. Takes: recording and exact re-render
  7. The Mac app: Electron, updates and releases
  8. 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:

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:

  1. a timer set for the moment the step starts, which hands its locks to the modulation layer;
  2. 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:

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:

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

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:


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:

  1. picks a seed and notes the audio time t0;
  2. sends that seed and t0 to the arpeggiator's random pattern, the grain and auto-pan worklets, the shifter and phaser LFOs and the app's LFOs;
  3. copies the settings, the pattern and the effect order;
  4. 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:

  1. Clear every effect's memory, so no tail from before leaks in.
  2. 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.
  3. Use the take's pattern and effect order, without touching the ones on screen.
  4. Start a new take with the same seed, so every random source restarts identically.
  5. 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

Download for Mac Free · macOS 13 or later