Architecture¶
Synthwave Surfer is one self-contained file, synthwave_surfer.html (~3.9k
lines): markup, CSS, all the music engines, the audio graph, the UI controller,
and an in-file test suite. The only runtime dependency is
Tone.js 14.8.49, loaded from a CDN. There is no
build step.
Line numbers below are pointers into synthwave_surfer.html and will drift as
the file changes; treat them as a map, not a contract.
The core idea: Engine + Voicing¶
Every genre is two independent things:
- Composition — what notes and structure. Lives in
ALGORITHMS[genre](:1303). Pure data + generator functions: which form builder runs, how the bass riff and lead are generated, the pad chord progression, the drum pattern, swing, sidechain, default BPM/mode, and (for EDM genres) anarrangement. No synth or FX data lives here. - Voicing — what it sounds like. Lives in
ENGINE_VOICINGS[genre](:2855). The synth model + full ADSR/filter/LFO per track (bass/lead/pad) plus a master FX block (reverb, delay, sidechain depth, tape saturation, master gain).
They are deliberately decoupled so a genre's algorithm and its tone can change without touching each other.
ALGORITHMS[genre] ENGINE_VOICINGS[genre]
form / bass / lead model + ADSR + filter + LFO (per track)
pad / drums / swing master: reverb / delay / sidechain / sat
sidechain / arrangement
│ │
└──────────► mergeVoicing ◄──┘
│
generate() → currentState.form
mergeVoicing — sparse deltas¶
mergeVoicing(defaultVoicing, deltas) (:2911) shallow-merges, per track, a
canonical voicing with a sparse delta object and returns
{ trackParams: { bass, lead, pad }, masterState }. It never mutates its inputs
(spreads only). A preset therefore stores only the few values it overrides;
improvements to an engine's canonical voicing automatically propagate to every
preset built on it. applyGenreVoicing(genre) (:2926) is just
mergeVoicing(voicing, null) — load the canonical voicing with no deltas.
Genre cards are presets¶
There is no separate "factory presets" UI anymore. Each genre card is a
curated preset. Clicking a card (:2946) clears the others, then:
loadDrumKit(algo.drumKit) → set the curated GENRE_SEED → set
algo.defaultBpm / algo.defaultMode → applyGenreVoicing(genre) →
generate(). GENRE_SEED (:2945) values are thematic (style birth years,
Roland model numbers, signature tempos).
A separate
FACTORY_PRESETSsystem predated this; it was removed once every genre card became its own preset — the card-preset path above is now the only preset mechanism.
Composition layer¶
Forms¶
Most genres have a bespoke 16-bar form builder (buildOutrunForm,
buildNoirForm, buildCarpenterForm, buildAcidForm, …). The EDM genres use
the shared 32-bar buildArrangedForm (:1283) instead — see
Arrangement.
Harmony¶
buildChordMap(padProg, totalBars) (:518) splits the song into equal chord
spans and returns a per-bar { root, tones }. Bass, lead, and pad all read
the same chord map, so they share one harmonic rhythm (this fixed an earlier
clash where the pad progression cycled every bar). Scales live in SCALES
(:386): aeolian, phrygian, dorian, pentatonic minor — all genres are rooted on
A.
Melody — motif engine¶
Leads are not random walks. MELODY_PROFILES + generateMelody state a motif
and develop it (transpose / invert / ornament) along a contour
(arch / wave / rise), landing on harmony at phrase ends. Some genres use
specialised generators instead: generateSustainedLead (Noir/Acid, long held
Vangelis notes), generateArpLead (Carpenter's continuous "Halloween" arp), or
no lead at all (Dubstep).
Bass — riff engine¶
BASS_PROFILES + generateRiff build a root-anchored one-bar ostinato with a
bassFill turnaround every fourth bar. Grooves range from Outrun's driving
8th-notes to Noir's sparse held notes to Carpenter's pure repetition
(octaveProb / moveProb = 0) to Dubstep's single drone.
Drum patterns¶
Six named patterns are dispatched in the scheduler (:1949):
| Pattern | Used by | Feel |
|---|---|---|
driving |
Outrun | kick 1 & 3 (all-four when intense), snare 2 & 4, 8th hats |
sparse |
Noir | kick alternating bars, soft snare on 3, quiet hats |
carpenter |
Carpenter | kick on 1, snare on 3 (odd bars), no hats |
shuffle |
Dreamwave | 16th shuffled hats, snare on 3 |
fourfloor |
Techno/Prog House/Trance/Acid | four-on-the-floor kick, clap 2 & 4, closed hat on-beat + open hat offbeat |
halftime |
Dubstep | kick 1, ghost kick, snare on 3, gapped hats, 1/32 fill |
Arrangement engine¶
buildArrangedForm (:1283) + arrPhase(arr, bar) (:1275) give the EDM
genres a song arc. arrPhase classifies each bar as build (before the first
drop), main (after a drop), or breakdown.
The four EDM engines (Techno / Prog House / Trance, plus the parked Dubstep) share an identical
arrangement = { dropBars: [8, 24], breakdownStart: 16, breakdownEnd: 24 } over
32 bars — two drops, one breakdown (bars 16–23). The arc is built from these
rules:
- Bass plays only in
mainbars (:1290) — it cuts out during build and breakdown and slams back at each drop. - Lead phrase A lands two bars after the first drop; phrase B opens the breakdown.
- Kick is filtered out during the breakdown and for the single bar before
each drop (
:1991), so kick + bass + crash hit together on the drop. - A noise riser sweeps 150 Hz → 6 kHz over the two bars into a drop, and a
dedicated crash voice lands on the drop (
:2017). Crash is a separate voice from the open-hat to avoid a schedule clash.
Quirk: because the four-on-the-floor kick runs through the whole build, the build can measure louder (RMS) than the main section. The drop is felt as the break-then-slam, not as a level jump.
Voicing layer¶
Synth models¶
buildSynthModel(name, poly) (:1418) is a flat if-chain returning
{ synth, character, characterAmount, type }. Models in active use:
| Model | Flavour | Build |
|---|---|---|
crystal |
DX7 FM bell | PolySynth(FMSynth) + Chorus |
vapor |
Juno-106 pad | 3× fat saw + heavy Chorus |
alkali |
MS-20 bass | 2× fat saw, Q≈5 + Distortion |
acid |
TB-303 | always MonoSynth, saw Q10 + portamento + Chebyshev |
ether |
Juno-106 lead | 3× fat saw + Chorus |
supersaw |
Anthem lead | 7× fat saw, wide spread + Chorus |
stratos (Jupiter-8 PWM bass) is legacy — no genre voicing references it,
and the pwm LFO target it depended on is force-baked to off — but it is still
selectable in the per-track Model dropdown.
MDL_LABELS(:2192) must have an entry for every model. A missing label crashes the info panel (padEndonundefined). Coverage is currently 1:1.
Applying a voicing¶
applyTrackParams(model, params, isPoly, lfoNodes) (:1561) pushes a voicing
onto a live synth: rebuilds the envelope, filter Q (resonance * 18) and
filter envelope (baseFreq = 50 + cutoff*4500, octaves = 1 + fenvAmount*5),
detune, character (chorus/distortion wet), and LFO routing
(off / pitch / filter / pwm / amp). The poly path uses synth.set()
and skips the filter for crystal (FM has no filter); the mono path sets
properties directly.
Audio graph¶
buildAudioGraph() (:1661) wires everything:
per-track bus: tremGain → duckGain(sidechain) → volume ─┬─ dry ────────────────┐
└─ send → reverbSend ───┤
drum voices ──────────────────────────────────────────────────────────────────┤
delaySend → delay ─────┤ (delay feeds reverb)
reverbSend → reverb ───────────────────┤
▼
compressor → tapeSat → limiter(-3) → masterGain(0.8) → meter → Destination
- Send buses:
reverb= Freeverb,delay= a feedback delay (Tone.FeedbackDelay); the delay output feeds into the reverb. Each per-track bus splits into a dry path (straight to the compressor) and a send path. - Drums bypass the send buses entirely and route straight to the compressor.
- An FFT analyser taps the master for the spectrum display.
Drum kits and the drum graph¶
DRUM_KITS (:1655) defines four kits — linn, 909, 808, dubstep —
each with kick / snare / hat parameters. But the drum voices are actually
built from the editable drumParams object (:1802), not the kit directly:
- Tunable knobs (kick decay/boom, snare whip/tone, hat decay) come from
drumParams. - Structural fields (kick
octaves, snarefilterType, voice volumes, gated-reverb decay) come from the selected_kitand are read only at graph build time. openhat,riser, andcrashare hardcoded extra voices, not param-driven.- The gated-reverb chain (
snareGateGain → snareGateReverb → …) is always built but stays silent unlesssnareGatedis set;triggerGatedReverb(:1853) fires a hard 0→1→0 envelope per snare hit. Only thelinnanddubstepkits gate by default (plus the manual snare-gate toggle).
loadDrumKit(name) (:2477) copies a kit preset into drumParams (mapping
the kit's field names to the differently-named drumParams fields), then syncs
the UI and calls applyDrumParams.
Apply-flow¶
Three paths override constructor defaults; they are separate:
applyAllToGraph(g)(:1812) — pushesmasterState(reverb/delay/sat/gain) and per-tracktrackParams(callingapplyTrackParamsper model), and applies mute state. Does not touch drums.applyTrackParams(...)(:1561) — the per-synth voicing push (above).applyDrumParams(g)(:2466) — live-mutates the tunable drum fields. The structural kit fields require a graph rebuild to take effect.
The .swmd codec¶
.swmd is a Markdown serialisation of a composition (swmdParse :1098,
swmdSerialize :1127). A file is:
- YAML frontmatter — name, genre/algo, BPM, mode, pattern-mode, roots, mood, energy, default phase.
- One
## Phase: <name>block per phase, each with### Bass/### Lead/### Pad/### Drums, and under each a### Settingsblock plus a### Pattern/### Progression. - Patterns are ASCII piano-roll tables; drums are fenced
```swdrumblocks (kick/snare/hihat/open, 16 steps ofx/.); pad is a degree progression table; FX is anEffect | Param | Valuetable. - Instrument sections without a
## Phaseheader collapse into an implicitdefaultphase (backwards compat).
Files are written with a .md extension so Obsidian renders them; the
importer accepts both .swmd and .md. This is the bridge format to the planned
Obsidian plugin.
Public API¶
window.synthwaveSurfer (:2701) is Object.freezed and exposes exactly five
members:
| Member | Returns | Notes |
|---|---|---|
setPhase(name) |
boolean |
only active in fixed/SWMD mode; regenerates + notifies |
getPhases() |
string[] |
phase names; [] in generative mode |
getCurrentPhase() |
string \| null |
|
onPhaseChange(cb) |
unsubscribe fn | |
getMeta() |
object | null |
the imported SWMD's frontmatter |
The phase state machine is only live when patternMode === 'fixed' and a SWMD
has been imported — in generative mode getPhases() returns [] and setPhase
is inert, and there is no transport/beat-position callback yet. This API is the
hook for syncing external visuals (e.g. a Kuro animation) to the music.
Presets and state¶
- Card preset —
ENGINE_VOICINGS[genre]+GENRE_SEED[genre](above). - User presets —
captureFullState/applyFullState, persisted tolocalStorageundersynthwave_surfer_presets_v1, with Save / Export-JSON / Import-JSON / delete. (Note: the live drum-knob tweaks are not part of the captured state.) - State JSON — a one-shot snapshot of the full current state, including the serialised SWMD.
Determinism¶
Generation is driven by a seeded PRNG, so seed + genre fully determines
currentState.form. Re-generating with the same inputs reproduces the exact same
note events; changing the seed explores the same style.
Tests¶
- In-browser:
?test=1runs the in-filetestsarray (51 real tests) to the console. - Headless logic:
node scripts/fullsuite.mjsextracts the same tests plus the pure functions and runs them under Node →real: 47 passed, 0 failed(4 DOM tests skipped).node scripts/check-syntax.mjsis the syntax gate. - Headless audio: Playwright + Chrome measures master-output RMS to confirm an engine actually produces sound. See CONTRIBUTING.md.
Known limitations¶
- Audio requires an HTTP origin — Tone.js does not initialise on
file://. - Legacy code —
stratosis a legacy synth model (still selectable in the Model dropdown, but used by no genre voicing). - Dubstep is parked — hidden from the genre selector. Its wobble needs per-bar
bass-filter modulation, but the bus LFO can't reach the
alkaliPolySynth's per-voice filter; bringing it back needs a dedicated filter node in the bass bus.
WAV export renders offline in a dedicated OfflineContext: it swaps the global
context with setContext() so buildAudioGraph()/scheduleAll() (which use
getDestination()/getTransport()) build and schedule inside it, then restores
the live context. (Connecting to the frozen global Tone.Destination from inside
an offline render is the cross-context trap to avoid.)