· apps · soundbox · web-audio · synthesis · technical

Soundbox: Web Audio engine, TR-808, Rad, tone presets

Line-cited map of AudioEngineService.trigger, unlock(), ToneService localStorage, and ExportService WAV — read next to soundbox/src/services/.

No samples required — until you want them. Synthesis first.


Read with the repo: local post after cd marketing && npm run dev. Files: soundbox/src/services/AudioEngineService.ts, ToneService.ts, tr808Pattern.ts, radPattern.ts, ExportService.ts.


Every node sound goes through AudioEngineService.trigger(...). The switch is the ground truth — if you add a node type, this is the first compile error you will touch:

// soundbox/src/services/AudioEngineService.ts — lines 64–94
  trigger(nodeType: NodeType, soundConfig?: SoundConfig, nodeId?: string, context?: { bpm: number }): void {
    if (!this.context || this.context.state !== "running") {
      return;
    }

    switch (nodeType) {
      case "kick":
        this.playKick(soundConfig);
        break;
      case "snare":
        this.playSnare(soundConfig);
        break;
      case "hat":
        this.playHat(soundConfig);
        break;
      case "tone":
      case "bass":
        this.playToneWithConfig(soundConfig, nodeId);
        break;
      case "transport":
        this.playClick(520, 0.025, 0.02);
        break;
      case "tr808":
        this.playTr808Sequence(soundConfig, context?.bpm ?? 120);
        break;
      case "rad":
        this.playRadSequence(soundConfig, context?.bpm ?? 120);
        break;
      default:
        break;
    }
  }

Notice the early return if AudioContext is missing or not running — that is why the UI always funnels gestures through unlock() first.


Bootstrapping audio

unlock() allocates the context, noise buffer, and calls resume() — same file, search async unlock:

// soundbox/src/services/AudioEngineService.ts — lines 45–54
  async unlock(): Promise<void> {
    if (!this.context) {
      this.context = new AudioContext();
      this.noiseBuffer = this.createNoiseBuffer(this.context);
    }

    if (this.context.state !== "running") {
      await this.context.resume();
    }
  }

Tone colors come from ToneService (TonePreset in graph.ts). Bootstrap wires the resolver by identity (see bootstrap.ts lines 37–38 in the overview post’s citation block). Presets you save go to localStorage key soundbox:tone-presets — see ToneService.ts top-of-file STORAGE_KEY.


Drums and percussion

Kick / snare / hat paths use filtered noise bursts, oscillators, and short envelopes — classic drum-machine ergonomics without shipping a multi-megabyte sample pack. The transport node fires a short click (high beep) so you can hear grid alignment when debugging patterns.


TR-808 node: step grids

SoundConfig carries tr808Steps (legacy 16-step lane), tr808LayoutMode (free vs fixed), tr808RowCount, tr808FreeGrid, and tr808FixedRows. getTr808HitsAtStep in tr808Pattern.ts normalizes layouts so AudioEngineService can ask “what voices fire on this sixteenth?” and schedule hits at the current BPM.

Here is the actual TR-808 Rhythm Composer card in the UI (cropped from the running app) — the colored strip is the 16-step lane row; the help text under the title states the lane order (BD, SD, hats, toms, clap, cowbell, cymbal):

Close-up of the TR-808 Rhythm Composer card in Soundbox: label 'TR-808 Rhythm Composer' with a 16-cell colored step strip beneath; caption reads 'Pulse triggers one 16-step bar. BD, SD, hats, toms, clap, cowbell, cymbal'
TR-808 card from /soundbox/. ~6 KB WebP + ~13 KB JPEG fallback.

Rad node: melodic lanes and piano

Rad is Soundbox’s flexible melodic sequencer: lane view with per-cell preset IDs, or piano view with per-row MIDI and a boolean step grid (radView, radFreeGrid, radFixedRows, radPianoMidi, radPianoGrid, radPianoPresetId). radPattern.ts exposes getRadLaneHitsAtStep and getRadPianoMidiHitsAtStep so the audio engine can mirror the same rules the UI shows.


Presets: save, preview, drag

Users can preview a preset (previewPreset) on hover or selection, save named presets to localStorage (soundbox:tone-presets in ToneService), and drag preset + node metadata via application/x-soundbox-tone-preset and application/x-soundbox-node MIME types in the overlay (overlay.ts) — so the palette feels like a physical rack of cards.


Export

exportBeat is a async method on ExportService — it builds OfflineAudioContext(2, sampleRate * durationSeconds, sampleRate) at 44100 Hz, schedules events, then startRendering() and returns encodeWav(renderedBuffer) as a Blob. That is the same graph rules as live playback, without the real-time clock:

// soundbox/src/services/ExportService.ts — lines 59–73
  async exportBeat(project: Project, durationSeconds: number): Promise<Blob> {
    const sampleRate = 44100;
    const offlineCtx = new OfflineAudioContext(2, sampleRate * durationSeconds, sampleRate);
    const noiseBuffer = this.createNoiseBuffer(offlineCtx);

    const events = this.scheduleEvents(project, durationSeconds);
    const arpStepIndex = new Map<string, number>();

    for (const event of events) {
      this.renderSound(offlineCtx, noiseBuffer, event, arpStepIndex);
    }

    const renderedBuffer = await offlineCtx.startRendering();
    return this.encodeWav(renderedBuffer);
  }

Bootstrap’s overlay hands that blob to a temporary <a download> — see the onExportBeat block in soundbox-web-audio’s sibling post soundbox-pulses-graph-threejs.md (or grep onExportBeat in bootstrap.ts).


Sources: soundbox/src/services/AudioEngineService.ts, soundbox/src/services/ToneService.ts, soundbox/src/services/tr808Pattern.ts, soundbox/src/services/radPattern.ts, soundbox/src/models/graph.ts.