Skip to content

Commit 905a4d7

Browse files
bwhitmanclaude
andcommitted
docs: user guide for writing audio effects and oscillators in C
A user-facing companion to the user_c_dsp_design.md design doc, now that install_c_process / install_c_osc are live on all platforms: quick start, full Python API, the S8.23 sample format and helpers, effect and oscillator contracts, keeping state with statics, and the portable xcc700 C subset — plus four worked examples (bitcrusher, heavy distortion, CZ-101 phase distortion, bytebeat), each verified running in the browser build before landing here. Linked from music.md's capability list, an AMYboard python.md section with a taste of the API, and a pointer at the top of the design doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8cfa638 commit 905a4d7

4 files changed

Lines changed: 314 additions & 1 deletion

File tree

docs/amyboard/python.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ amy.send(synth=1, vel=0)
9797
# You can also play polyphonically with a MIDI keyboard (connected via the "MIDI in" jack) or your computer (writing to the AMYboard USB MIDI gadget) sending notes on channel 1.
9898
```
9999

100+
### Writing your own DSP in C
101+
102+
AMYboard can compile C code **on the board itself** and run it inside the
103+
synthesizer — custom effects at the end of a bus' FX chain, or whole new
104+
oscillator types, hot-swapped from Python while audio keeps playing:
105+
106+
```python
107+
import tulip
108+
tulip.install_c_process('crush', """
109+
int i = 0;
110+
while (i < frames * chans) {
111+
buf[i] = (buf[i] >> 18) << 18; // bitcrush!
112+
i = i + 1;
113+
}
114+
""")
115+
tulip.c_process('crush', True)
116+
```
117+
118+
See [Writing audio effects and oscillators in C](../user_c_dsp.md) for the
119+
full guide — sample format, oscillator API, and worked examples (bitcrusher,
120+
heavy distortion, a CZ-101 phase-distortion oscillator, bytebeat).
121+
100122
## The `amyboard` module
101123

102124
The `amyboard` module provides functions specific to the AMYboard hardware:

docs/music.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Tulip is a **music computer** where everything about the underlying synthesis an
2525
- Add global EQ, chorus, echo or reverb to the audio output
2626
- A scale and chord library to define musical notes in code, e.g. `music.Chord("F:min")`
2727
- Have total low level control of all oscillators, specifying their filters, waveform, modulation sources, ADSRs
28+
- Write your own audio effects and oscillators **in C**, compiled and hot-swapped at runtime from Python -- see [Writing audio effects and oscillators in C](user_c_dsp.md)
2829

2930
## A small note about Tulip Desktop and Tulip Web
3031

docs/user_c_dsp.md

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# Writing audio effects and oscillators in C
2+
3+
Tulip and AMYboard let you write **audio DSP in C, from Python, at runtime**.
4+
You hand `tulip.install_c_process()` a string of C code and a moment later it
5+
is compiled and running inside AMY's audio engine — on the actual hardware,
6+
with no toolchain, no reboot, no re-flash. Edit the string and install it
7+
again to hot-swap the running code.
8+
9+
This works everywhere Tulip runs:
10+
11+
| Platform | Compiler underneath |
12+
| -------- | ------------------- |
13+
| Tulip CC / AMYboard (ESP32-S3) | `xcc700`, a tiny on-device C compiler |
14+
| Tulip Desktop (macOS) | `libtcc` in-memory JIT |
15+
| Tulip Web / AMYboard Web | `xcc700` again, emitting WebAssembly into AMY's AudioWorklet |
16+
17+
The Python API and the C you write are the same on all of them. A runnable
18+
demo with the examples from this guide ships at `/sys/ex/c_dsp_demo.py`.
19+
(For how this is implemented, see the [design doc](user_c_dsp_design.md).)
20+
21+
## Quick start: a bitcrusher in six lines
22+
23+
```python
24+
import tulip
25+
26+
tulip.install_c_process('crush', """
27+
int i = 0;
28+
while (i < frames * chans) {
29+
buf[i] = (buf[i] >> 18) << 18; // keep the top 5 fraction bits
30+
i = i + 1;
31+
}
32+
""")
33+
tulip.c_process('crush', True) # enable it on bus 0 -- everything you play is now crushed
34+
```
35+
36+
Play some notes. When you've had enough:
37+
38+
```python
39+
tulip.c_process('crush', False)
40+
```
41+
42+
You gave Tulip just the **body** of a C function; the signature and helper
43+
declarations are wrapped around it for you. Edit the string (try `>> 20`) and
44+
call `install_c_process` again — the new code replaces the old one in place,
45+
keeping its bus assignment, without interrupting audio.
46+
47+
## The API
48+
49+
| Call | What it does |
50+
| ---- | ------------ |
51+
| `tulip.install_c_process(name, src)` | Compile `src` as an **effect** and install it under `name`. Returns the slot number, raises `ValueError` with the compiler message if it doesn't compile. |
52+
| `tulip.install_c_osc(name, src)` | Same, but as an **oscillator**. |
53+
| `tulip.c_process(name, on, bus=0)` | Enable/disable an effect on a bus (0-3). An effect can run on several buses at once. |
54+
| `tulip.c_osc(name, osc, on=True)` | Point AMY oscillator number `osc` at your oscillator (or un-point it). Many oscs can play one installed program. |
55+
| `tulip.c_process_calls(name)` | How many audio blocks your code has processed since install. The quickest "is it actually running?" check. |
56+
| `tulip.uninstall_c_process(name)` | Remove it (either kind) and free the compiled code. |
57+
58+
There are 8 slots, shared between effects and oscillators. Reinstalling an
59+
existing name reuses its slot and keeps its bus enables / osc bindings, so
60+
your edit-listen loop is just: edit string, install, listen.
61+
62+
## The sample format, and the helpers you always have
63+
64+
Samples are AMY's native fixed point: **`int` in S8.23**, where 1.0 is
65+
`1 << 23` (8388608). No floats — these run on machines without fast float,
66+
and the fixed-point discipline is part of the fun. Four helpers are always
67+
in scope:
68+
69+
| Helper | Meaning |
70+
| ------ | ------- |
71+
| `fxmul(a, b)` | Multiply two S8.23 numbers (`a*b` with 1.0 == `1<<23`). |
72+
| `cos_lut(phase)` | AMY's cosine table. `phase` is S8.23 normalized to one cycle (0 .. `1<<23`); returns S8.23 in -1.0..1.0. |
73+
| `to_int16(s)` | S8.23 → familiar 16-bit PCM (-32767..32767), clamped. |
74+
| `from_int16(v)` | 16-bit PCM → S8.23. |
75+
76+
If you'd rather think in 16-bit samples like a tracker, convert on the way
77+
in and out: `buf[i] = from_int16(my_math(to_int16(buf[i])));`.
78+
79+
## Effects
80+
81+
An effect body sees:
82+
83+
```c
84+
void process(int *buf, int frames, int chans)
85+
```
86+
87+
`buf` is the live bus buffer **after** AMY's own FX chain (EQ, chorus, echo,
88+
reverb) — you are the last thing before the speaker. It holds `chans`
89+
sequential channel blocks of `frames` samples each (not interleaved): left
90+
is `buf[0] .. buf[frames-1]`, right starts at `buf[frames]`. Modify it in
91+
place. Enable on buses with `tulip.c_process(name, True, bus)`.
92+
93+
## Oscillators
94+
95+
An oscillator body sees:
96+
97+
```c
98+
void render(int *buf, int frames, int osc,
99+
int phase_inc_q16, int amp)
100+
```
101+
102+
Fill `buf` (mono) with `frames` samples; AMY then applies the osc's pan and
103+
mixes it like any other oscillator. You get two musical inputs per block:
104+
105+
- `phase_inc_q16` — the per-sample phase step for the note's **current
106+
pitch**, with 65536 meaning one full cycle. It tracks pitch bends and
107+
portamento live.
108+
- `amp` — the envelope level right now, in S8.23. `fxmul` your output by it
109+
and your oscillator follows AMY's ADSR.
110+
111+
Bind it to an AMY osc and play it like any oscillator:
112+
113+
```python
114+
tulip.install_c_osc('mine', SRC)
115+
tulip.c_osc('mine', 200)
116+
amy.send(osc=200, wave=amy.SINE, bp0='10,1,500,0.2,250,0', vel=0)
117+
amy.send(osc=200, note=60, vel=1)
118+
```
119+
120+
**Use a high osc number (200 is a good habit).** The low oscillators belong
121+
to the default MIDI synths, which will fight you for control of them —
122+
events sent to an osc a synth owns go silent.
123+
124+
Polyphony: bind several oscs to the same name and keep per-osc state in a
125+
static array indexed by the `osc` argument (see the CZ example below).
126+
127+
## Keeping state between calls
128+
129+
Declare variables `static` in your body and they live as long as the
130+
install. This is how filters keep their memory, oscillators keep their
131+
phase, and delays keep their buffers:
132+
133+
```c
134+
static int last; // one persistent int
135+
static int phase[256]; // per-osc state, indexed by `osc`
136+
static int16_t delayline[22050]; // half a second of mono at 16-bit
137+
```
138+
139+
Everything starts at zero on (re)install.
140+
141+
## The C you can write
142+
143+
On Tulip Desktop the compiler is a full C JIT (`libtcc`) — includes, floats,
144+
structs, the lot. On Tulip CC, AMYboard and the web builds it's `xcc700`, a
145+
deliberately small C:
146+
147+
- `int` and `int16_t`, pointers and arrays of both; `enum`; `static`
148+
- `while`, `if`/`else`, `return`; functions (you can define helpers above
149+
your `process`/`render` and call them)
150+
- the full integer expression zoo: `* / % + - << >> < <= > >= == != & ^ |`
151+
`&& || ! ~`, assignment
152+
- **no** floats, structs, `for`, or preprocessor (`#define` etc.)
153+
154+
Write to the subset and the same string runs on every platform. (If a
155+
formula seems to misbehave, add parentheses — then check your precedence
156+
assumptions.)
157+
158+
Two warnings that keep their promises:
159+
160+
- **You can crash your synth.** This is real machine code in the audio
161+
path. Write past a buffer or divide by zero and the whole synth can stop
162+
(on hardware, it reboots). That's the fun part; save your work.
163+
- **Keep it fast.** Your code runs once per block per bus (or per playing
164+
osc) on the audio thread. Integer math is cheap; huge loops are not.
165+
166+
## Example: bitcrusher (effect)
167+
168+
The quick start above, spelled out: shifting right then left throws away
169+
low bits, quantizing the waveform into crunchy steps. Try different depths
170+
live — each install hot-swaps the last.
171+
172+
```python
173+
tulip.install_c_process('crush', """
174+
int i = 0;
175+
while (i < frames * chans) {
176+
buf[i] = (buf[i] >> 18) << 18;
177+
i = i + 1;
178+
}
179+
""")
180+
tulip.c_process('crush', True)
181+
```
182+
183+
## Example: heavy distortion (effect)
184+
185+
Overdrive: slam the signal into a ceiling. Multiply by a drive gain, clamp
186+
to ±1.0, then round the corners off with the classic cubic saturator
187+
(`y = x - x³/3`) so it growls instead of buzzing. The knee tops out at 2/3,
188+
so scale by 3/2 to bring peaks back to full scale.
189+
190+
```python
191+
tulip.install_c_process('dist', """
192+
int drive = 10; // 1 = clean boost, 10 = heavy
193+
int i = 0;
194+
while (i < frames * chans) {
195+
int s = buf[i] * drive;
196+
if (s > 8388608) s = 8388608; // clamp to +/-1.0 (S8.23)
197+
if (s < -8388608) s = -8388608;
198+
int y = s - fxmul(fxmul(s, s), s) / 3;
199+
buf[i] = y + y / 2;
200+
i = i + 1;
201+
}
202+
""")
203+
tulip.c_process('dist', True)
204+
```
205+
206+
Stateless, so no statics needed. Crank `drive` for more filth.
207+
208+
## Example: CZ-101 phase distortion (oscillator)
209+
210+
Casio's CZ series made bright, filter-like tones with no filter at all:
211+
read a plain cosine with a *bent* phase. The first `dcw` fraction of the
212+
cycle sweeps the cosine's whole first half, the rest sweeps the second
213+
half. Small `dcw` = buzzy and bright, like an open filter; `dcw = 32768` =
214+
pure cosine. Phase bookkeeping is in Q16 (65536 = one cycle); `<< 7`
215+
converts to `cos_lut`'s S8.23 phase at the lookup.
216+
217+
```python
218+
tulip.install_c_osc('cz', """
219+
static int phase[256]; // per-osc phase: polyphony for free
220+
int dcw = 6554; // bend point (0.1 of a cycle)
221+
int i = 0;
222+
while (i < frames) {
223+
int p = phase[osc];
224+
int wp = 0; // warped phase, Q16
225+
if (p < dcw) wp = p * 32768 / dcw;
226+
else wp = 32768 + (p - dcw) * 32768 / (65536 - dcw);
227+
buf[i] = fxmul(cos_lut(wp << 7), amp);
228+
p = p + phase_inc_q16;
229+
if (p >= 65536) p = p - 65536;
230+
phase[osc] = p;
231+
i = i + 1;
232+
}
233+
""")
234+
tulip.c_osc('cz', 200)
235+
amy.send(osc=200, wave=amy.SINE, bp0='10,1,500,0.2,250,0', vel=0)
236+
amy.send(osc=200, note=60, vel=1)
237+
```
238+
239+
Sweep `dcw` between installs (or extend the body to modulate it with
240+
another counter) and you've built a CZ envelope's worth of timbre.
241+
242+
## Example: bytebeat (oscillator)
243+
244+
[Bytebeat](https://canonical.org/~kragen/bytebeat/) is the genre where one
245+
line of integer C *is* the whole composition: run a counter `t` up once per
246+
sample, mash it through shifts and masks, and play the low bits. Here's a
247+
classic formula as a Tulip oscillator — the note's pitch is ignored (the
248+
formula is the melody), but velocity and the envelope still gate it
249+
through `amp`:
250+
251+
```python
252+
tulip.install_c_osc('beat', """
253+
static int t[256];
254+
int i = 0;
255+
while (i < frames) {
256+
int n = t[osc];
257+
int v = (n * (n >> 12 | n >> 8) & 63 & n >> 4) - 32;
258+
buf[i] = fxmul(from_int16(v * 1024), amp);
259+
t[osc] = n + 1;
260+
i = i + 1;
261+
}
262+
""")
263+
tulip.c_osc('beat', 200)
264+
amy.send(osc=200, wave=amy.SINE, bp0='10,1,10000,1,500,0', vel=0)
265+
amy.send(osc=200, note=60, vel=1) # hold a note open and listen to it unfold
266+
```
267+
268+
The formula (`t*(t>>12|t>>8)&63&t>>4`) produces 6-bit output, recentered
269+
and scaled to full range through `from_int16`. Swap in any bytebeat you
270+
like — the whole integer operator set is available. Ideas: make the
271+
keyboard transpose it by stepping `t` with something derived from
272+
`phase_inc_q16` instead of 1, or run two formulas and mix them.
273+
274+
## Is it working? Debugging
275+
276+
- `tulip.c_process_calls(name)` counts processed blocks. If it's
277+
increasing, your code is being called. Effects tick on every block while
278+
enabled; oscillators only while a bound osc is audible.
279+
- Compile errors raise `ValueError` at install time, with the compiler's
280+
message and line number pointing into your string. Nothing is replaced
281+
until the new code compiles.
282+
- Hearing nothing from an oscillator? Check the osc has an envelope set up
283+
(the `bp0=...` line), that you bound the same osc number you're playing,
284+
and that you `fxmul` by `amp` — a zero envelope gives you a perfect
285+
silence generator.
286+
- Silence, but `c_process_calls` is climbing? Your math probably collapsed
287+
to zero — `int` division truncates; multiply before you divide.

docs/user_c_dsp_design.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# User C DSP plugins for AMYboard / Tulip — design & feasibility
22

3-
*Status: proposal, 2026-07-11. Core unknowns validated by experiment (see §3).*
3+
*Status: proposal, 2026-07-11. Core unknowns validated by experiment (see §3).
4+
Since shipped on all platforms — if you want to **use** this rather than read
5+
about how it's built, see the user guide: [Writing audio effects and
6+
oscillators in C](user_c_dsp.md).*
47

58
The idea: let people write a tiny C function — a bit-crusher, a weird oscillator —
69
as a **string in Python**, compile it **on the device itself**, and inject it into

0 commit comments

Comments
 (0)