DP-303 is a browser-native TB-303 bass synthesizer and curated pattern library. Every sound is generated in real time using the Web Audio API: no audio files stream from the server, no plugins are required, and the entire app loads as a single HTML file with inlined assets. This document explains the decisions behind the audio engine, the TR-909 drum section, the pattern data model, and the visual design.
The original Roland TB-303 Bass Line (1981) produced its sound through a deceptively simple analog signal chain: a single oscillator running sawtooth or square wave, fed through an 18 dB/octave resonant lowpass filter (the CEM 3394 chip), shaped by a VCA envelope, with a separate accent circuit and a portamento (slide) mechanism that carried pitch between triggered notes.
DP-303 replicates that chain in the Web Audio API without external libraries:
| Parameter | Range | Notes |
|---|---|---|
| Cutoff | 200 Hz — 4 kHz | Filter frequency at rest. The envelope modulates upward on every triggered note. |
| Resonance | 1 — 25 | BiquadFilter Q. At high values the filter self-oscillates, producing the acid scream. |
| Env Mod | 0.2× — 4× | Multiplier applied to the envelope peak. Controls how far the filter opens on attack. |
| Decay | 10 ms — 400 ms | Exponential filter envelope decay. Short decay = percussive stab; long = slow bloom. |
| Accent | 1× — 3× | Per-step gain boost. Accented steps also receive an extended envelope peak, matching 303 behavior. |
| Drive | 0 — 1 | WaveShaperNode with a tanh-based soft-clip curve. 0 = clean; 1 = saturated distortion pre-filter. |
Slide is implemented by scheduling a linearRampToValueAtTime on the oscillator frequency across the full step duration when a slide flag is set on a step. This produces the portamento glide between notes that defines the 303 sound, without needing a separate portamento node.
Waveform switching between sawtooth and square is live: clicking the
selector updates osc.type on the running OscillatorNode with no restart or
click. The original 303 required a physical switch; here it's instant.
Web Audio event scheduling is sample-accurate, but JavaScript timers are not. The
standard approach is a lookahead scheduler: a setTimeout loop runs every
25 ms, inspects the audio clock, and schedules any notes that fall within the next
100 ms. This decouples the imprecise JS event loop from the precise audio graph.
A consequence of lookahead scheduling: any code that reads a Web Audio parameter value
with .value during scheduling will get the current value, not the future
scheduled value. This caused the original hat choke bug (see the drums section below)
and is worth understanding when extending the engine.
The UI step indicator fires via a setTimeout calculated from the difference
between the scheduled audio time and the current wall clock. It will drift slightly with
system load but is accurate enough for visual feedback.
The drum sounds are sample-based, sourced from the TR-909 ROM sample archive at hyperreal.org. Four voices are used: kick (BTAAADA), snare (STATASA), closed hat (HHCDA), and open hat (HHODA). These are the maximum-parameter samples from the 909's original D/A converter — longest decay, most open tone, giving the most material for the envelope shaping done in software.
Each voice is controlled by 2 to 4 normalized (0–1) parameters that map to audio values:
| Voice | Controls | Implementation |
|---|---|---|
| Kick | Tune, Level, Attack, Decay | Playback rate shift (±1 octave via pow(2, x)), gain envelope with configurable attack ramp and exponential decay. |
| Snare | Tune, Level, Tone, Snappy | Playback rate, lowpass BiquadFilter (800–4000 Hz for Tone), exponential decay length controlled by Snappy. |
| Closed Hat | Level, Decay | Short exponential decay (10–130 ms). Routes to the CH bus. Chokes the OH bus on trigger. |
| Open Hat | Level, Decay | Longer exponential decay (100–700 ms). Routes to the OH bus. Restores OH bus gain on trigger. |
Hat choke is the behavior on real hardware where triggering the closed
hat silences the open hat immediately. The implementation uses two persistent GainNodes
as buses: _chBus for closed hat output, _ohBus for open hat output.
When a closed hat fires, it schedules a 4 ms linear ramp on _ohBus.gain to
zero. When an open hat fires, it cancels any scheduled values and restores
_ohBus.gain to 1.
.stop()
on them, or read gain.value during lookahead. Both fail: stopping a source
mid-playback causes a click, and gain.value returns the current value even
when future values are scheduled on the parameter. The bus GainNode approach works because
cancelScheduledValues + setValueAtTime + linearRampToValueAtTime
are all sample-accurate scheduled operations on the audio thread.
The original TR-909 WAV files are 44.1 kHz, 16-bit mono — about 125 KB total. Loading them requires four separate HTTP requests and a brief wait before the first beat. To eliminate the load wait and add character, both a lo-fi and hi-fi version are bundled directly into a JavaScript module as base64 data URIs.
| Mode | Sample Rate | Bit Depth | Total Size |
|---|---|---|---|
| Lo-Fi (default) | 11,025 Hz | 8-bit unsigned | ~16 KB WAV / ~22 KB base64 |
| Hi-Fi | 44,100 Hz | 16-bit PCM | ~125 KB WAV / ~167 KB base64 |
Both sets are decoded into AudioBuffers at startup via the Web Audio
decodeAudioData API. Switching between lo-fi and hi-fi is instantaneous
mid-playback because both buffer sets are held in memory. The lo-fi mode is the
default: at 11 kHz the samples have a gritty, vintage character that suits the 303
aesthetic, and the 8x smaller decode payload means the kit is ready before the first
bar plays.
Each pattern is a plain JavaScript object. Steps are variable-length arrays; the sequencer loops them independently of the 16-step drum grid, creating polyrhythm automatically when the pattern length is not a power of two.
Frequency is computed as: midiToFrequency(pitchToMidi(pitch) + (octave * 12)).
The base octave is MIDI 36 (C2, ~65 Hz) — the 303 plays in bass register.
Pattern length interacts with the fixed 16-step drum grid to produce polyrhythmic phasing. A 3-step bass pattern against 16-step drums reaches coincidence (LCM) at 48 steps — three bars before the cycle resets. The POLY category exploits this systematically with prime lengths 2, 3, 5, 7, 11, and 13.
Patterns were chosen to represent the range of the instrument and the history of acid house and related genres. All reconstructions are based on community consensus, published transcriptions, or direct analysis.
The palette is derived from the Roland TR-909 and TB-303 hardware. The main body color is RAL 9006 "White Aluminium" (#A5A8A6), a warm silver-grey used on both machines. Panel recesses step down to a darker silver-panel tone; the footer rail and knob housings use the darkest grey before reaching the near-black borders that separate the machine from the page background.
Knobs are SVG with a rotation transform on the pointer line, driven by mouse drag (delta-Y mapped to angle). The tick marks are static SVG lines at 7 positions (−135° to +135° in 45° steps). No external SVG libraries or canvas are used.
The step grid uses a two-group color treatment from the 909 front panel: steps 1–4 and 9–12 use the main cell color; steps 5–8 and 13–16 (the B group) use a slightly darker background. This makes beat positions legible without bar lines.
Typography is Arial Narrow for body text and Arial Black for headings — the same compressed grotesque family Roland used for panel labeling on hardware from this era. No web fonts are loaded.