Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,251 @@ track Bell id bell gen basicOsc * 2
The channel strip — `mix`, and `fx`'s filter, drive and reverb send — is host-side and applies to
every generator, so it works the same on a chip voice as it does here.

`basicOsc` is the plainest of thirty-three. The rest of the catalog is in
[Instruments](../synths/catalog/); the examples below are one from each family.

### Hard sync

A sync voice runs two oscillators and lets the first reset the second's phase. `slave_base` is how
far above the note the slave starts, in semitones, and `sweep_amt` is how far it sweeps down — that
sweep *is* the sound, so it wants tens of semitones rather than a couple.

```deck
deck 1
bpm 124

track Lead id lead gen syncLead * 2
gen slave_base 19 sweep_amt 30 sweep_decay 0.25 cutoff 4200 resonance 3
adsr a 0.02 d 0.2 s 13 r 0.3
mix gain 0.5 pan -0.15
fx reverb_send 0.25
note 69 0 1.5 v 104
note 76 1.5 0.5 v 92
note 74 2 2 v 100
note 72 4 1.5 v 98
note 69 5.5 0.5 v 88
note 64 6 2 v 94

track Choir id choir gen syncChoir * 2
gen vowel_shift 12 morph_rate 0.8 morph_amt 6 ensemble_detune 6 vib_rate 6.5 vib_amt 14 highpass 400
adsr a 0.3 d 0.5 s 12 r 0.8
mix gain 0.3 pan 0.1
fx reverb_send 0.5
note 57 0 3.8 v 74
note 55 4 3.8 v 76
```

### The analog rack

A 303's character is `env_mod` — a filter envelope in Hz, not a 0–1 amount, so it runs in the
thousands. The 808's `punch` is also Hz, and its `drive` is 0–1.

```deck
deck 1
bpm 128
song_seed 303

track Acid id acid gen acid303 * 2
gen waveform sawtooth cutoff 480 resonance 20 env_mod 5000 decay 0.25
mix gain 0.4 pan 0.15
note 40 0 0.25 v 118
note 52 0.75 0.25 v 110
note 40 1.5 0.25 v 92
note 47 2.25 0.25 v 104
note 38 4 0.25 v 118
note 50 4.75 0.25 v 108
note 45 5.5 0.25 v 96
note 38 6.5 1 v 100

track Sub id sub gen sub808 * 2
gen punch 48 decay 2 drive 0.15 glide 0.08
mix gain 0.5
note 40 0 2 v 120
note 38 4 2.5 v 112

track Kick id kick gen drumSynth * 1
gen tone sine pitch_env 30 pitch_decay 0.035 decay 0.3 noise 0.05 drive 0.2
mix gain 0.55
step_pitch 36
steps x . . . | x . . . | x . . . | x . . .

track Clap id clap gen clap * 1
gen hands 2 spread 0.2 size 0.3 tone 0.6 claps 1 gap 0.2 tail 0.25 body 0.15
mix gain 0.3 pan 0.2
step_pitch 60
steps . . . . | x . . . | . . . . | x . . .
```

### Atmospheric

Long attacks, and nothing competing for the same register.

```deck
deck 1
bpm 76

track Pad id pad gen pad * 4
gen wave1 triangle wave2 sine detune 14 cutoff 1200
adsr a 0.6 d 0.8 s 11 r 2
mix gain 0.26 pan -0.3
fx reverb_send 0.5
note 52 0 7.6 v 72
note 50 8 7.6 v 74

track Halo id halo gen halo * 4
gen temper 0.5 ring 0.35 mallet 0.6 bloom 1.2 lows 0.3
adsr a 0.02 d 2.5 s 2 r 1.6
mix gain 0.26 pan 0.3
fx reverb_send 0.6 cutoff 6000
note 79 0 2 v 76
note 84 4 2 v 72
note 81 8 2 v 78
note 88 12 3 v 82

track Bell id bell gen bell * 4
gen partial 3.4 highpass 900 decay 2.6
mix gain 0.3 pan -0.3
fx reverb_send 0.65
note 91 6 2 v 62
note 86 14 2 v 58
```

### Other consoles

The Game Boy is one of six chip emulations. These are five others, in one bar each.

```deck
deck 1
bpm 140

track NES id nes gen nes2a03 * 2
gen type pulse duty 25 vol 12 env_mode adsr vib_rate 5.5 vib_amt 22
adsr a 0.002 d 0.12 s 8 r 0.1
mix gain 0.5 pan -0.3
note 76 0 0.5 v 104
note 79 0.5 0.5 v 92
note 83 1 1 v 100
note 76 2 2 v 96

track SID id sid gen c64sid * 2
gen waveform pulse pulse_width 0.28 filter_type lowpass cutoff 2400 resonance 8
adsr a 0.004 d 0.2 s 7 r 0.15
mix gain 0.45 pan 0.3
note 52 0 1 v 96
note 57 2 2 v 100
note 50 4 1 v 94
note 55 6 2 v 98

track FM id fm gen ym2612 * 2
gen algorithm 4 feedback 5 op1_mul 1 op1_tl 22 op2_mul 3 op2_tl 30 op3_mul 2 op3_tl 26 op4_mul 1 op4_tl 12
mix gain 0.4
note 40 0 2 v 106
note 45 4 2 v 100

track SNES id snes gen spc700 * 2
gen waveform triangle echo_enable 1 echo_delay 0.16 echo_feedback 0.3
adsr a 0.01 d 0.4 s 6 r 0.3
mix gain 0.35 pan -0.15
note 64 2 1 v 82
note 67 6 2 v 86

track PSG id psg gen sn76489 * 1
gen type noise noise_mode white noise_freq 3 vol 8
adsr a 0 d 0.05 s 0 r 0.02
mix gain 0.25 pan 0.15
steps x . . x | . . x . | x . . x | . x . .
```

### Acoustic models

`voice` on `arco` names an instrument — `violin`, `cello`, `fiddle`, `bass` — rather than taking a
number.

```deck
deck 1
bpm 88

track Cello id vc gen arco * 4
gen voice cello pressure 0.6 bow 0.5 vibrato 0.5 rosin 0.2 body 0.75
adsr a 0.35 d 0.3 s 12 r 0.7
mix gain 0.32
fx cutoff 1600 reverb_send 0.35
note 50 0 3.8 v 88
note 46 4 3.8 v 84
note 53 8 3.8 v 86
note 48 12 3.8 v 82

track Rhodes id tine gen tine * 4
gen bark 0.5 tine 0.65 tremolo 0.3 decay 1.1 drive 0.2
adsr a 0.003 d 0.9 s 3 r 0.5
mix gain 0.26 pan 0.3
fx reverb_send 0.35 cutoff 4600
note 62 1.5 1.2 v 74
note 65 1.5 1.2 v 70
note 58 5.5 1.2 v 74
note 62 5.5 1.2 v 70
note 65 9.5 1.2 v 74
note 69 9.5 1.2 v 70
note 60 13.5 1.2 v 74
note 64 13.5 1.2 v 70

track Nylon id gtr gen guitar * 4
gen tone 0.6 decay 1.4 damping 0.35 drive 0.15 body 0.7 mute 0
mix gain 0.3 pan -0.2
fx reverb_send 0.3
note 69 0 1 v 86
note 74 1 0.75 v 80
note 77 4 1.5 v 88
note 72 8 1 v 84
note 77 10 1.5 v 90
note 76 12 1.5 v 86
note 74 14 1.8 v 82
```

### An operator graph

`matrixFm` is the one voice whose patch does not fit on a `gen` line, so it takes a `gen_block`:
operators, the modulation between them, a filter, and how they route to the output. This is the
factory *Supersaw Stack* — three detuned saws cross-modulated into a wide lowpass.

```deck
deck 1
bpm 128

track Saws id saw gen matrixFm * 2
gen_block matrix_fm
op 1 wave saw ratio 1
op 2 wave saw ratio 1.008
op 3 wave saw ratio 2
env op 1 a 0.006 d 0.22 s 0.72 r 0.32
env op 2 a 0.006 d 0.22 s 0.72 r 0.32
env op 3 a 0.006 d 0.22 s 0.68 r 0.32
mod fm 2 1 0.45
mod fm 3 1 0.28
filter 1 type lp24 cutoff 6800 res 0.32
route op 1 filter 1 0.38
route op 2 filter 1 0.36
route op 3 filter 1 0.34
route filter 1 out 1
end gen_block
mix gain 0.34 pan -0.1
fx reverb_send 0.3
note 65 0.5 0.4 v 104
note 68 0.5 0.4 v 98
note 72 0.5 0.4 v 96
note 65 1.5 0.4 v 96
note 68 1.5 0.4 v 90
note 61 4.5 0.4 v 104
note 65 4.5 0.4 v 98
note 68 4.5 0.4 v 96

track Bass id bass gen reeseBass * 2
gen voices 2 detune 15 cutoff 800 wobble 0 decay 1.2
mix gain 0.4
note 41 0 1.5 v 112
note 39 4 1.5 v 108
```

## Mixing and effects

`mix` places a track and sets its level; `fx` shapes it. Cutoff and resonance are a filter sweep's
Expand Down
118 changes: 118 additions & 0 deletions docs/RENDERING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Rendering to audio

A `.deck` file can be rendered to a `.wav` from the command line, without opening the site or
pressing play on anything.

```bash
node scripts/render-wav.mjs song.deck -o song.wav
```

```
song.wav 5.87s 2 tracks peak -2.0 dBFS rms -8.1 dBFS
```

## Why it needs a browser

The voices are Web Audio. They are built from `OscillatorNode`, `BiquadFilterNode`,
`WaveShaperNode`, `DelayNode` and — for the sync oscillators — an `AudioWorklet`. Node has none of
these, so there is no pure-Node path from a song to a buffer, and reimplementing the voices for a
second runtime is exactly the duplication this repo spent its effort removing.

So the renderer drives a headless Chrome instead. It serves `packages/player/dist`, calls the
player's own [`renderDeckToBuffer()`](#rendering-from-your-own-code) inside an `OfflineAudioContext`,
and copies the samples back out. Nothing is recorded from a sound device: an `OfflineAudioContext`
computes the buffer as fast as it can, which is many times quicker than real time, and it is
deterministic — the same song renders to the same samples every run.

You need Chrome or Chromium installed. The script looks in the usual places; set `CHROME` to
override.

## Options

| flag | meaning |
| --- | --- |
| `-o`, `--out <path>` | output WAV — required |
| `--beats <n>` | how many beats to render. Defaults to the song's own length |
| `--gain <g>` | master gain, `0`–`1`. Default `0.9` |
| `--sample-rate <n>` | default `44100` |
| `--no-reverb` | bypass the reverb send |
| `--normalize` | scale the result to peak at −1 dBFS |

`--beats` is how you render a slice: a two-bar audition of a long song, or one loop of a piece whose
`totalBeats` is unset.

```bash
node scripts/render-wav.mjs song.deck -o loop.wav --beats 32 --normalize
```

## Reading the output line

`peak` and `rms` are reported so a bad render is visible without opening the file.

- **peak at 0.0 dBFS** means it is clipping — lower `--gain`.
- **rms below about −30 dBFS** on a dense arrangement usually means most of it never triggered.
- **A silent render** (`peak -inf`) means nothing played at all: check the song has notes on the
beats you asked for.

If any track names a generator with no voice behind it, the substitution is listed:

```
substituted (no voice for these ids):
someGeneratorId
```

An empty list is the thing to want — it means every track is being played by its real voice rather
than standing in as a plain oscillator.

## Rendering from your own code

The CLI is a thin wrapper. In a browser, the player exports the same call:

```js
import { renderDeckToBuffer } from '@spacedevin/deck-player'

const buffer = await renderDeckToBuffer(source, {
beats: 32,
sampleRate: 44100,
gain: 0.9,
reverb: true,
})
```

It returns a rendered `AudioBuffer`, which you can encode, analyse, or play back.

### Sync voices need their processor registered

The sync family — `syncLead`, `syncChoir`, `obSync`, `laserSync` — is built on an `AudioWorklet`.
A worklet module registers **asynchronously**, and a voice whose processor is not yet registered
falls back to a plain oscillator rather than failing, so the symptom is a render that sounds thin
instead of one that errors.

`renderDeckToBuffer()` handles this: it waits for the module before scheduling a single note. If you
are driving `buildAudioGraph()` and `playStep()` yourself, register it first and await it:

```js
import { ensureSyncWorklet, buildAudioGraph, playStep } from '@spacedevin/deck-player'

await ensureSyncWorklet(ctx) // null when the context has no worklet support
const graph = buildAudioGraph(ctx, song, { gain: 0.9 })
```

`buildAudioGraph()` also kicks registration off on its own, which is enough for live playback —
there, the module lands well inside the gap between the user pressing play and the first note. It is
not enough for an offline render, which gets no such gap.

## Rendering every example on this site

Each fenced block in [Examples](EXAMPLES.md) is a complete song. To render them all:

```bash
node -e '
const { readFileSync, writeFileSync, mkdirSync } = require("fs")
const md = readFileSync("docs/EXAMPLES.md", "utf8")
mkdirSync("out/examples", { recursive: true })
;[...md.matchAll(/```deck\n([\s\S]*?)```/g)].forEach((m, i) =>
writeFileSync(`out/examples/${String(i + 1).padStart(2, "0")}.deck`, m[1]))
'
for f in out/examples/*.deck; do node scripts/render-wav.mjs "$f" -o "${f%.deck}.wav"; done
```
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading