Skip to content
Open
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
4 changes: 2 additions & 2 deletions tulip/server/refdocs/amy/_VENDORED_FROM.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
Auto-generated by tulip/server/sync_amy_docs.py — do not edit by hand.
Source: amy@d97d230ba59021715adbd52b8419481607c764dc
Files: api.md, arduino.md, billie_jean.md, godot.md, juno_patches.md, midi.md, synth.md, upgrading.md
Source: amy@c4c3df7359b4ae3b1e64e921bf3d8d66c281879c
Files: api.md, arduino.md, billie_jean.md, distortions.md, godot.md, juno_patches.md, midi.md, synth.md, upgrading.md
86 changes: 84 additions & 2 deletions tulip/server/refdocs/amy/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,19 @@ A note on list parameters: When an argument is a list of parameters, you can in

| Wire code | C `amy_event` | Python / JS | Type-range | Notes |
| ------ | -------- | ---------- | ---------- | ------------------------------------- |
| `v` | `osc` | `osc` | uint 0 to OSCS-1 | Which oscillator to control |
| `v` | `osc` | `osc` | uint 0 to OSCS-1 | Which oscillator to control. In a command addressed to a synth this is relative to the voice, as are `chained_osc`, `mod_source` and `algo_source`; a number outside the voice is refused (see [synth.md](synth.md)) |
| `w` | `wave` | `wave` | uint 0-21 | Waveform: [0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE, KS, PCM, ALGO, PARTIAL, BYO_PARTIALS, INTERP_PARTIALS, AUDIO_IN0, AUDIO_IN1, AUDIO_EXT0, AUDIO_EXT1, AMY_MIDI, PCM_LEFT, PCM_RIGHT, WAVETABLE, CUSTOM, OFF]. default: 0/SINE |
| `ww` | `mode` | `mode` | uint | Wave-specific modes. For PCM, we have `PCM_PLAY` (plays through to end), `PCM_PLAY_STOP` (stops immediately on note-off, default), `PCM_LOOP` (observes loop marks in wave, plays through release on note-off), `PCM_LOOP_STOP` (plays loop, stops immediately on note-off), `PCM_LOOP_FOREVER` (loops, and ignores note-offs, expected to be used with an EG). |
| `S` | `reset_osc`| `reset` | uint | Resets given oscillator. set to RESET_ALL_OSCS to reset all oscillators, gain and EQ. RESET_TIMEBASE resets the clock and sequencer tick count to zero. It is an ordinary event, so it works from `amy_add_event()` as well as the wire, and can be scheduled with `ticks=` (or `amy_event.time` from the C API) like any other event; the counters restart at the next audio block boundary, so the reset cannot race the render thread, and events already queued for a future time keep their relative timing across it. (RESET_AMY and RESET_EVENTS are the exceptions: they act the moment the message is parsed, since neither restarting AMY nor emptying the event queue can be carried in a queued event.) RESET_SYNTHS is a deprecated alias for RESET_ALL_OSCS; prefer RESET_ALL_OSCS. RESET_AMY restarts AMY. RESET_SEQUENCER clears the sequencer.|
| `A` | `eg0_times[]`, `eg0_values[]` | `bp0` | string (wire) / arrays (`amy_event`) | Envelope Generator 0 breakpoints as time(ms),value pairs. Wire/Python format remains comma-separated, e.g. `100,0.5,50,0.25,200,0`. In C `amy_event`, use typed arrays (`eg0_times[i]`, `eg0_values[i]`). The last pair is release (triggers on note off). |
| `B` | `eg1_times[]`, `eg1_values[]` | `bp1` | string (wire) / arrays (`amy_event`) | Envelope Generator 1 breakpoints. Wire/Python format remains comma-separated; in C `amy_event`, use typed arrays (`eg1_times[i]`, `eg1_values[i]`). |
| `b` | `feedback` | `feedback` | float 0-1 | Use for the ALGO synthesis type in FM or for karplus-strong, or to indicate PCM looping (0 off, >0, on) |
| `c` | `chained_osc` | `chained_osc` | uint 0 to OSCS-1 | Chained oscillator. Note/velocity events to this oscillator will propagate to chained oscillators. VCF is run only for first osc in chain, but applies to all oscs in chain. |
| `G` | `filter_type` | `filter_type` | 0-6 | Filter type: 0 = none (default.) 1 = lowpass, 2 = bandpass, 3 = highpass, 4 = double-order lowpass, 5 = notch, 6 = phaser. |
| `G` | `filter_type` | `filter_type` | 0-6 | Filter type: 0 = none (default.) 1 = lowpass, 2 = bandpass, 3 = highpass, 4 = double-order lowpass, 5 = notch, 6 = phaser. A letter instead of a digit selects a distortion sub-command; see the `G` distortion rows below. |
| `GC` / `GF` | `dist_type` | `dist_clip` / `dist_fold` | 0 or 1 | Per-oscillator distortion, applied before the filter. `GC1` enables soft clip, `GF1` enables wavefold; 0 turns the stage off. |
| `GH` | `dist_type`, `dist_bits`, `dist_rate` | `dist_crush` | list of 2 ints | Enables the bitcrusher as [bits, rate]. bits: bit depth 1-24, >= 24 leaves bit depth unchanged. rate: sample-hold length in samples, 1 disables. `GH0` turns the stage off. |
| `GD` | `dist_drive` | `dist_drive` | float 0-16 | Distortion pre-gain (fold depth for wavefold), shared by all types; default 1. |
| `GM` | `dist_mix` | `dist_mix` | float 0-1 | Distortion wet/dry, shared by all types; default 1 (full wet). |
| `I` | `ratio` | `ratio` | float | For ALGO types, ratio of modulator frequency to base note frequency |
| `L` | `mod_source` | `mod_source` | 0 to OSCS-1, up to two, comma-separated | Which oscillator(s) are used as modulation/LFO sources for this oscillator. Source oscillators will be silent. The first feeds the `mod0` control coefficient and the second `mod1`, so `mod_source=[3, 4]` makes osc 3 the `mod0` input and osc 4 the `mod1` input. |
| `m` | `portamento`| `portamento` | uint | Time constant (in ms) for pitch changes when note is changed without intervening note-off. default 0 (immediate), 100 is good. |
Expand Down Expand Up @@ -355,6 +359,84 @@ These per-oscillator parameters use [CtrlCoefs](synth.md) notation
| `zS` | **TODO**| `start_sample` | uint x 6 | Start sampling to a stereo PCM preset from source. Params: preset number, source, max length in frames, midinote, loopstart, loopend. source = 1 is AMY mixed output. source = 2 is AUDIO_IN0 + 1. Will sample until max length is reached, `stop_sample` is issued, or a new `start_sample` is issued. |
| `zO` | **TODO**| `stop_sample` | uint | Stop sampling. Does nothing if no sampling active. param ignored. |

### Sampler timing and time-stretch (experimental)

These hang off the `p` (preset) prefix rather than taking top-level codes of
their own: 44 of the 52 single letters are already allocated, and the sampler
corner is still growing. `p<n>` still selects PCM preset `n` as before; a
sub-letter after `p` addresses a PCM parameter instead.

| code | AMY parameter | Python/JS keyword | type | description |
| ---- | ------------- | ----------------- | ---- | ----------- |
| `po` | `sample_offset` | `sample_offset` | uint 0 to BLOCK_SIZE-1 | PCM only. Start this note-on at a sample offset *within* the render block it fires in, leaving the head of the block silent. Events fire on block (256-sample) boundaries; `sample_offset` supplies the sub-block remainder, so slices of arbitrary length can be scheduled to butt-join sample-accurately (e.g. reconstructing a chopped break with no gaps). Sticky per osc like other params; set 0 to clear. |
| `pF` | `fit_ticks` | `fit` | float | PCM only, in-memory presets. Engage the granular time/pitch engine at the next note-on. `fit=N` (N>0): play the sample in exactly N sequencer ticks with a pitch-invariant time stretch; `note` still transposes without changing duration. Because the target is in ticks, it tracks `tempo` *while the note is sounding*, not just at note-on: change the tempo mid-note and the stretch rate follows, so the note still ends N ticks after it started. `fit=0`: time-invariant pitch shift — `note` transposes but the sample keeps its original duration. `fit=-1`: turn the engine off. Non-destructive and real-time (~2.5x the render cost of plain PCM). |
| `pS` | `fit_search` | `fit_search` | uint frames, 0 to 512 | PCM only, and only meaningful while the `fit` engine is running. Half-width, in input frames, of the WSOLA correlation search that aligns each new grain against the one still playing (see `fit` below). Default 64; `0` turns the search off entirely, leaving a fixed-grid overlap-add. Read every block, so it can be changed while a note sounds. Clamped to 512. |

Two parameters turn AMY's PCM oscillators into a "real" sampler (see
`experiments/sampler/` for worked examples):

- **`sample_offset` (`po`)** gives note-ons sub-block placement. AMY events
execute on block boundaries (256 samples, ~5.8 ms); a PCM note-on with
`sample_offset=k` starts at sample `k` of its block. Schedule a slice of
length `L` starting at absolute sample `S`, then its successor at
`S + L` (block `(S+L)//256`, offset `(S+L)%256`), and the two butt-join
with no gap — a chopped break plays back bit-exact against the original.
Untransposed playback (no `note`, no freq mods) is drift-free: AMY uses
the preset's native rate exactly in that case.

- **`fit` (`pF`)** decouples duration from pitch, non-destructively, at
note-on time. `fit=N` plays the sample over exactly N sequencer ticks
(so it tracks `tempo`, including tempo changes that land part-way
through a note -- the timeline rate is rescaled in the render loop, so
a sounding note re-aims at the same tick rather than finishing at the
tempo it started under) without changing pitch; `fit=0` changes pitch
(via `note`) without changing duration. The engine is a fixed-point
granular overlap-add (two 1024-sample Hann grains, 50% overlap) with a
WSOLA-style correlation search aligning each new grain's phase against
the one still playing — no FFT, no float in the render path, so it
targets every AMY platform. Looping modes (`ww`) work: the input
timeline wraps at the loop marks. Streamed `disk_sample` presets can't
`fit` (no random access).

- **`fit_search` (`pS`)** sets how far that correlation search may roam. It
has to be able to reach half a period of the lowest pitch in the sample or
it can't find the aligned splice at all, and the default 64 frames only
covers periods down to about `AMY_SAMPLE_RATE/128` — ~345 Hz at 44.1 kHz.
Below that the aligner locks onto the nearest line of the comb spaced at the
hop rate (~86 Hz at 44.1 kHz) instead of the note: a 110 Hz tone stretched 2x
comes out at 141 Hz. `fit_search=256` puts it back at 110 Hz, and takes the
ratio of energy at the tone to everything else from −15 dB to +26 dB (a
440 Hz tone, already in reach of the default, goes from 20 dB to 38 dB —
`experiments/sampler/fit_search_test.py` measures both). The cost
is `(2 * fit_search + 1) * 64` multiplies per 512-sample hop — linear in the
setting, so 256 is 4x the default's search cost — which is why it is opt-in
rather than the default. Broadband material (drums, breaks) has no
periodicity to align to and does not care; leave it alone there.

The comparison window widens with the setting (it also has to span roughly a
period), but the number of taps stays at 64 and the stride opens up instead,
so the cost stays linear rather than quadratic. That decimation aliases —
acceptable because wide settings exist for bass, where the taps still land
many per period.

`fit_search=0` disables alignment and gives you a plain fixed-grid
overlap-add: every grain lands on the nominal hop no matter what the
waveform is doing. That is roughly what the 90s Akai samplers' CYCLIC time
stretch did, and it fails the same characteristic way — the splice error
repeats at exactly the hop rate, so the artifact is a stable pitched comb
rather than WSOLA's diffuse warble. On tonal material it is wrong by tens of
dB; as an effect it is the "sampler timestretch" sound.

`fit` composes with `phase` to start a sample part-way through, which is what
a "drop the playhead into the middle of a loop" transport needs. `phase` sets
the note-on's start frame (`start_frame / 2^23`) and the stretcher picks the
input up from there — but it stretches *whatever input is left* over
*whatever `fit` it is given*, so `fit` has to be the REMAINING ticks, not the
clip's full length; pass the full value and the tail plays at half speed.
Entering a 16-beat loop at its midpoint is `phase = (frames/2) / 2^23` with
`fit` set to 8 beats of ticks, and it ends on the same grid line the
uninterrupted loop would have.

### WAVETABLE wave type

`wave=WAVETABLE` is available when AMY is built with `-DAMY_WAVETABLE`.
Expand Down
153 changes: 153 additions & 0 deletions tulip/server/refdocs/amy/distortions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Distortion in AMY

AMY has a per-oscillator distortion stage with three waveshapers: a soft
clipper, a wavefolder, and a bitcrusher. The stage runs inside each osc's
signal chain, after the amplitude envelope and before the filter, so note
dynamics drive the shaper: a hard hit pushes further into the nonlinearity
than a soft one, and a decaying tail cleans up on its own. On a `SILENT`
chained-osc head the same stage shapes the voice's summed chain instead, which
is a musically different effect - see [Per-osc versus the voice's
sum](#per-osc-versus-the-voices-sum) below.

All the clips on this page are rendered offline through the Python module and
peak-normalized to -6 dBFS, so louder never reads as "better" in an A/B.

## Commands

Distortion rides `G` sub-commands on the wire (a digit after `G` is
`filter_type` as ever; a letter is a distortion sub-command):

| Wire | Python kwarg | Values | Meaning |
| ---- | ------------ | ------ | ------- |
| `GC1` / `GC0` | `dist_clip=1` / `0` | 0 or 1 | Enable / disable the soft clipper. |
| `GF1` / `GF0` | `dist_fold=1` / `0` | 0 or 1 | Enable / disable the wavefolder. |
| `GH<bits>,<rate>` | `dist_crush=[bits, rate]` | ints; bits 1-24, rate 1-1024 | Enable the bitcrusher: quantize to `bits` magnitude bits (24 leaves bit depth unchanged) and hold each sample for `rate` samples (1 disables the sample-and-hold). `GH0` disables. |
| `GD<drive>` | `dist_drive=` | float 0-16 | Pre-gain into the shaper (fold depth for the wavefolder), shared by all types. Default 1. |
| `GM<mix>` | `dist_mix=` | float 0-1 | Wet/dry mix, shared by all types. Default 1 (full wet). |

One type is active per osc; enabling one replaces another. Drive and mix keep
their values across type changes.

```python
amy.send(osc=0, wave=amy.SINE, dist_clip=1, dist_drive=3, dist_mix=1)
amy.send(osc=0, note=45, vel=1)
```

is the wire message `v0w0GC1GD3GM1Z` followed by `v0n45l1Z`.

## Soft clip (`GC`)

Pre-gain into a cubic soft knee: unity gain for small signals, saturating
toward the rails. Because the stage is post-envelope, the harmonic content is
dynamic per note - here the same note played at velocity 1.0 then 0.2: the
hard hit barks and audibly cleans up as the envelope walks the signal back
down the transfer curve; the soft hit never reaches the knee.

https://github.com/user-attachments/assets/59a768f8-1ac5-4168-a971-9e3a27cd6591

```python
amy.send(osc=0, wave=amy.SINE, bp0='0,1,2500,0.02,300,0',
dist_clip=1, dist_drive=3, dist_mix=1)
amy.send(osc=0, note=45, vel=1.0) # barks, then cleans up
amy.send(osc=0, note=45, vel=0.2) # stays clean
```

Drive into the clipper also makes a convincing kick treatment - the ROM 808
kick with drive stepping 1 to 16, saturation compressing the pitch sweep into
a harder, longer-sustaining hit:

https://github.com/user-attachments/assets/8d52fca9-05fc-4d05-a8fb-512508a6584f

## Wavefolder (`GF`)

A triangle folder: identity on [-1, 1], reflecting back on itself beyond.
Drive is the fold depth. Sweeping drive 0 to 8 on a pure sine blooms a fan of
partials with no filter involved:

https://github.com/user-attachments/assets/0b015871-ce23-4b9c-81c5-4fb83447f902

The same envelope interaction as the clipper, opposite direction of travel: a
fast-decay pluck attacks folded and relaxes toward a pure tone inside its own
decay - every note ends purer than it started.

https://github.com/user-attachments/assets/464f0861-39e7-4ccf-933d-d889d697e667

```python
amy.send(osc=0, wave=amy.TRIANGLE, bp0='0,1,900,0,0,0',
dist_fold=1, dist_drive=5, dist_mix=1)
```

## Bitcrusher (`GH`)

Bit-depth and sample-rate reduction in one stage; drive is a pre-gain into a
saturator ahead of the quantizer. The two parameters make distinct sounds.

**Rate** holds each sample for `rate` samples. The alias partials fall near
multiples of `sample_rate / rate`, so they move against the melody - lo-fi
that tracks the notes rather than sitting at a fixed fizz (a DC blocker on
the wet path keeps near-coincident partials from folding down to a
subsonic swing):

https://github.com/user-attachments/assets/f074844b-b636-4291-8fcb-1bbaf3103313

**Bits** quantizes the amplitude. Its audible signature is the tail: because
the envelope scales the signal before the stage, a release decays through
ever fewer quantization levels and finally gates out. One note per bit depth,
8 down to 1 - by 2 bits the tail is gone mid-release:

https://github.com/user-attachments/assets/5f0e8403-b7c4-420a-a105-4800c3f96c31

Drive interacts with bits: holding 2 bits and stepping drive 1 to 16 lifts
the decaying tail back over the quantization steps and squares the sustain:

https://github.com/user-attachments/assets/671b1ec0-5fe6-4579-9517-06ed513a9668

```python
amy.send(osc=0, wave=amy.SAW_DOWN, dist_crush=[6, 5], dist_mix=1)
```

## Per-osc versus the voice's sum

Distortion scope matters because the nonlinearity is what generates
intermodulation: it matters whether oscs sit inside the shaper together. An
equal-tempered A-major triad, same drive, two ways - clip on each osc
independently keeps sparse, separate harmonic stacks per note:

https://github.com/user-attachments/assets/ebcb7caf-71f1-43fc-9904-179aa017250a

Chaining the three oscs into a `SILENT` head that carries the distortion
shapes the sum once: the gaps fill with sum/difference intermodulation and a
low difference-tone band - the power-chord growl.

https://github.com/user-attachments/assets/0e2ea972-e957-4928-8228-4224cd815b0f

```python
amy.send(osc=0, wave=amy.SILENT, chained_osc=1, bp0='0,1,300,1,150,0',
dist_clip=1, dist_drive=4, dist_mix=1)
amy.send(osc=1, wave=amy.SINE, freq=220.00, chained_osc=2)
amy.send(osc=2, wave=amy.SINE, freq=277.18, chained_osc=3)
amy.send(osc=3, wave=amy.SINE, freq=329.63)
amy.send(osc=0, note=60, vel=0.6) # note/vel at the head drive the chain
```

Both scopes compose - for example crush per osc for grit, clip on the head as
glue. Per-osc scope is also what makes the stage usable as a drum-kit effect:
each drum is shaped against its own level, not a bus mix. A two-bar 808
pattern with 6-bit crush on every drum osc:

https://github.com/user-attachments/assets/cce8013c-0a24-4cb2-9763-407a63362010

## Distortion and the filter

Per-osc distortion runs before that osc's filter, so a resonant sweep stays a
clean whistle over the distorted wave, with the filter taming everything
above the cutoff:

https://github.com/user-attachments/assets/00118699-6d68-4f71-bcce-1034e7417730

The reverse order is expressible with the same pieces: put the filter on a
chain member and the clip on its `SILENT` head, and the shaper regenerates
harmonics from the resonant peak itself - the spectrum fills to the top, the
"303 into a distortion pedal" sound:

https://github.com/user-attachments/assets/94364b18-ee81-4f89-afea-87f1b1e11f32
Loading
Loading