|
| 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. |
0 commit comments