Skip to content

Commit 3c1d819

Browse files
bwhitmanclaude
andcommitted
c_dsp: body-only snippets + user C oscillators (install_c_osc / c_osc)
install_c_process/install_c_osc now accept just the function body -- the signature and standard includes are wrapped on automatically, with #line so tcc errors keep the user's own line numbers. Full programs still work (detected by a process(/render( mention). New oscillator kind: install_c_osc(name, src) compiles void render(int16_t *buf, int frames, int osc, int phase_inc_q16, int amp_q15) and c_osc(name, osc) makes that AMY osc play it -- dispatched from amy_external_render_hook (registered on desktop run_amy), which derives the Q16 phase step and Q15 envelope level from msynth so pitch bend and ADSR just work; AMY still applies pan and mixing. State between calls = statics in the body; per-osc state (polyphony) = static arrays indexed by the osc param. Ships tulip/fs/tulip/ex/c_dsp_demo.py: a body-form bitcrusher and a CZ-101-style phase distortion oscillator, on osc 200 (low oscs belong to midi.py's default synths, which silence raw events -- now documented). Verified headless: a muting user osc zeroes the bus during a held note (override proven on the live path), hot-swapping to the CZ body restores full-scale audio, and the effect path still passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a8d1296 commit 3c1d819

6 files changed

Lines changed: 263 additions & 34 deletions

File tree

docs/user_c_dsp_design.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,25 @@ v2; don't block on wire-protocol design.
168168
out of that build).
169169

170170
```python
171-
tulip.install_c_process("crush", src) # -> slot; ValueError on bad C
171+
tulip.install_c_process("crush", src) # effect -> slot; ValueError on bad C
172172
tulip.c_process("crush", True, 0) # enable on bus 0 (on, bus=0)
173+
tulip.install_c_osc("cz", osc_src) # oscillator kind
174+
tulip.c_osc("cz", 20) # AMY osc 20 now plays your C code
173175
tulip.c_process_calls("crush") # blocks processed so far
174176
tulip.uninstall_c_process("crush")
175177
```
176178

179+
`src` can be a **full program or just the function body** — bodies are
180+
wrapped in the signature + standard includes automatically (with `#line`
181+
so tcc errors keep the user's line numbers). Effect body sees
182+
`buf/frames/chans`; osc body sees `buf/frames/osc/phase_inc_q16/amp_q15`
183+
(mono buf; the dispatcher derives phase step + envelope from `msynth` so
184+
pitch bend and ADSR just work, and AMY still applies pan). **State between
185+
calls** = `static` variables in the body; polyphonic per-osc state =
186+
static arrays indexed by `osc`. See the shipped example
187+
`tulip/fs/tulip/ex/c_dsp_demo.py` (bitcrusher + CZ-101-style phase
188+
distortion osc).
189+
177190
### Failure modes, honestly
178191

179192
This is native code in the audio task. A wild pointer = panic + reboot, same as

tulip/fs/tulip/ex/c_dsp_demo.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# c_dsp_demo.py
2+
# User C DSP demo: write audio effects and oscillators in C, from Python.
3+
# (Tulip Desktop only for now -- AMYboard/web support is coming.)
4+
#
5+
# You give tulip just the *body* of a C function. Samples are plain 16-bit
6+
# PCM, -32767..32767, like a WAV file. No fixed point math needed.
7+
#
8+
# Effect body variables: int16_t *buf, int frames, int chans
9+
# buf is chans sequential channel blocks of frames samples; edit in place.
10+
# Osc body variables: int16_t *buf, int frames, int osc,
11+
# int phase_inc_q16, int amp_q15
12+
# Fill buf (mono) with frames samples. phase_inc_q16 is the per-sample
13+
# phase step for the note's current pitch (65536 = one full cycle) and
14+
# amp_q15 is the envelope level right now (0..32767) -- multiply by it so
15+
# your osc follows AMY's ADSR.
16+
#
17+
# KEEPING STATE BETWEEN CALLS: declare variables `static`. They live as long
18+
# as the install. For oscillators, many AMY oscs can play your code at once
19+
# (polyphony!), so keep per-osc state in a static array indexed by `osc`,
20+
# like the phase[] array below.
21+
22+
import tulip, amy, time
23+
24+
# --- An effect: 10-bit bitcrusher ------------------------------------------
25+
26+
CRUSH = """
27+
int i = 0;
28+
while (i < frames * chans) {
29+
buf[i] = (buf[i] >> 10) << 10;
30+
i = i + 1;
31+
}
32+
"""
33+
34+
# --- An oscillator: CZ-101-style phase distortion "saw" --------------------
35+
# Casio's CZ series makes bright waves without a filter: read a plain cosine
36+
# with a *bent* phase. The first `dcw` fraction of the cycle sweeps the
37+
# cosine's whole first half, the rest sweeps the second half. Small dcw =
38+
# buzzy and bright (like an open filter); dcw = 32768 = pure cosine.
39+
40+
CZ = """
41+
static int phase[256]; // per-osc phase, Q16: state between calls
42+
int dcw = 6554; // bend point (0.1 of a cycle). edit me live!
43+
int i = 0;
44+
while (i < frames) {
45+
int p = phase[osc];
46+
int wp; // warped phase
47+
if (p < dcw) wp = (int)(((long long)p * 32768) / dcw);
48+
else wp = 32768 + (int)(((long long)(p - dcw) * 32768) / (65536 - dcw));
49+
float angle = (float)wp * (6.2831853f / 65536.0f);
50+
buf[i] = (int16_t)(cosf(angle) * (float)amp_q15);
51+
p = p + phase_inc_q16;
52+
if (p >= 65536) p = p - 65536;
53+
phase[osc] = p;
54+
i = i + 1;
55+
}
56+
"""
57+
58+
def run():
59+
tulip.install_c_process('crush', CRUSH)
60+
tulip.install_c_osc('cz', CZ)
61+
62+
# Point AMY osc 200 at our oscillator and set up its envelope. Use a HIGH
63+
# osc number: low ones belong to midi.py's default synths, which fight
64+
# you for control of them (raw events to an owned osc go silent).
65+
tulip.c_osc('cz', 200)
66+
amy.send(osc=200, wave=amy.SINE, bp0='10,1,500,0.2,250,0', vel=0)
67+
68+
print("CZ phase distortion osc...")
69+
for note in (48, 52, 55, 60):
70+
amy.send(osc=200, note=note, vel=1)
71+
time.sleep(0.4)
72+
time.sleep(1)
73+
74+
print("same notes through the bitcrusher...")
75+
tulip.c_process('crush', True)
76+
for note in (48, 52, 55, 60):
77+
amy.send(osc=200, note=note, vel=1)
78+
time.sleep(0.4)
79+
time.sleep(1)
80+
tulip.c_process('crush', False)
81+
tulip.c_osc('cz', 200, False)
82+
print("done. try editing CZ's dcw and re-running -- reinstalls hot-swap.")
83+
84+
run()

tulip/shared/amy_connector.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,8 @@ void run_amy(uint8_t capture_device_id, uint8_t playback_device_id) {
507507
#ifdef TULIP_USER_C_DSP
508508
extern void tulip_bus_postprocess_hook(uint8_t bus, SAMPLE *buf, uint16_t len);
509509
amy_config.amy_external_bus_postprocess_hook = tulip_bus_postprocess_hook;
510+
extern uint8_t tulip_user_render_hook(uint16_t osc, SAMPLE *buf, uint16_t len);
511+
amy_config.amy_external_render_hook = tulip_user_render_hook;
510512
#endif
511513
amy_config.features.default_synths = 0; // midi.py does this for us
512514
amy_config.capture_device_id = capture_device_id;

tulip/shared/modtulip.c

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -423,23 +423,48 @@ MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_pcm_load_file_obj, 0, 1, tulip_pcm_loa
423423
#ifdef TULIP_USER_C_DSP
424424
#include "user_c_dsp.h"
425425

426-
// install_c_process(name, src): compile a C string defining
426+
// install_c_process(name, src): compile a C effect —
427427
// void process(int16_t *buf, int frames, int chans) — plain 16-bit PCM,
428-
// -32767..32767 — and install it under name.
428+
// -32767..32767 — and install it under name. src may be a full program or
429+
// just the function body (the wrapper and includes are added for you).
429430
STATIC mp_obj_t tulip_install_c_process(mp_obj_t name_obj, mp_obj_t src_obj) {
430431
char err[512];
431-
int slot = user_c_dsp_install(mp_obj_str_get_str(name_obj), mp_obj_str_get_str(src_obj), err, sizeof(err));
432+
int slot = user_c_dsp_install(mp_obj_str_get_str(name_obj), mp_obj_str_get_str(src_obj), USER_C_DSP_KIND_EFFECT, err, sizeof(err));
432433
if (slot < 0) mp_raise_msg_varg(&mp_type_ValueError, MP_ERROR_TEXT("install_c_process: %s"), err);
433434
return mp_obj_new_int(slot);
434435
}
435436
MP_DEFINE_CONST_FUN_OBJ_2(tulip_install_c_process_obj, tulip_install_c_process);
436437

438+
// install_c_osc(name, src): compile a C oscillator —
439+
// void render(int16_t *buf, int frames, int osc, int phase_inc_q16, int amp_q15)
440+
// — and install it under name. src may be a full program or just the body.
441+
STATIC mp_obj_t tulip_install_c_osc(mp_obj_t name_obj, mp_obj_t src_obj) {
442+
char err[512];
443+
int slot = user_c_dsp_install(mp_obj_str_get_str(name_obj), mp_obj_str_get_str(src_obj), USER_C_DSP_KIND_OSC, err, sizeof(err));
444+
if (slot < 0) mp_raise_msg_varg(&mp_type_ValueError, MP_ERROR_TEXT("install_c_osc: %s"), err);
445+
return mp_obj_new_int(slot);
446+
}
447+
MP_DEFINE_CONST_FUN_OBJ_2(tulip_install_c_osc_obj, tulip_install_c_osc);
448+
449+
// c_osc(name, osc, on=True): replace AMY osc number osc's waveform with the
450+
// named user oscillator (or restore it with on=False).
451+
STATIC mp_obj_t tulip_c_osc(size_t n_args, const mp_obj_t *args) {
452+
int on = (n_args > 2) ? mp_obj_is_true(args[2]) : 1;
453+
int r = user_c_dsp_bind_osc(mp_obj_str_get_str(args[0]), mp_obj_get_int(args[1]), on);
454+
if (r == -1) mp_raise_ValueError(MP_ERROR_TEXT("no such c_osc"));
455+
if (r == -2) mp_raise_ValueError(MP_ERROR_TEXT("bad osc number"));
456+
if (r == -3) mp_raise_ValueError(MP_ERROR_TEXT("not an osc (installed with install_c_process?)"));
457+
return mp_const_none;
458+
}
459+
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_c_osc_obj, 2, 3, tulip_c_osc);
460+
437461
// c_process(name, on, bus=0): enable/disable a named process on a bus.
438462
STATIC mp_obj_t tulip_c_process(size_t n_args, const mp_obj_t *args) {
439463
int bus = (n_args > 2) ? mp_obj_get_int(args[2]) : 0;
440464
int r = user_c_dsp_set(mp_obj_str_get_str(args[0]), bus, mp_obj_is_true(args[1]));
441465
if (r == -1) mp_raise_ValueError(MP_ERROR_TEXT("no such c_process"));
442466
if (r == -2) mp_raise_ValueError(MP_ERROR_TEXT("bad bus"));
467+
if (r == -3) mp_raise_ValueError(MP_ERROR_TEXT("not an effect (installed with install_c_osc?)"));
443468
return mp_const_none;
444469
}
445470
MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_c_process_obj, 2, 3, tulip_c_process);
@@ -1773,7 +1798,9 @@ STATIC const mp_rom_map_elem_t tulip_module_globals_table[] = {
17731798

17741799
#ifdef TULIP_USER_C_DSP
17751800
{ MP_ROM_QSTR(MP_QSTR_install_c_process), MP_ROM_PTR(&tulip_install_c_process_obj) },
1801+
{ MP_ROM_QSTR(MP_QSTR_install_c_osc), MP_ROM_PTR(&tulip_install_c_osc_obj) },
17761802
{ MP_ROM_QSTR(MP_QSTR_c_process), MP_ROM_PTR(&tulip_c_process_obj) },
1803+
{ MP_ROM_QSTR(MP_QSTR_c_osc), MP_ROM_PTR(&tulip_c_osc_obj) },
17771804
{ MP_ROM_QSTR(MP_QSTR_c_process_calls), MP_ROM_PTR(&tulip_c_process_calls_obj) },
17781805
{ MP_ROM_QSTR(MP_QSTR_uninstall_c_process), MP_ROM_PTR(&tulip_uninstall_c_process_obj) },
17791806
#endif

0 commit comments

Comments
 (0)