Skip to content

Commit 45ce320

Browse files
bwhitmanclaude
andauthored
tulip: make external_midi_sync work on the web builds via the AMY wire protocol (#1073)
tulip.external_midi_sync() was a C binding compiled out on __EMSCRIPTEN__ (micropython doesn't link AMY on web), so sketches using it raised AttributeError on Tulip Web and AMYboard Web simulate mode -- even though both apps already forward incoming WebMIDI bytes (including 0xF8 clock) into the AMY worklet. It's now a Python function in tulip.py that sends the new AMY wire command (external_midi_sync / zC, shorepine/amy#788), one code path on all platforms, same pattern as sequencer.start/stop. - amy submodule -> a8ab375 (PR commit; latest amy main + the zC command) - amy_api.generated.js / patches.generated.js regenerated from the new pin (also picks up the previously missing sequencer_run kwarg) - refdocs/amy refreshed via sync_amy_docs.py Tested: tulip/web build, amyboardweb stage build, macos build, AMYBOARD idf build; in the web simulator tulip.external_midi_sync(True/False) sends zC1Z/zC0Z to the AMY module; amy-message (native) accepts zC1/zC0. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent dc81e7f commit 45ce320

9 files changed

Lines changed: 74 additions & 37 deletions

File tree

tulip/amyboardweb/static/amy_api.generated.js

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ var AMY_KW_MAP = {
4848
portamento: {wire: "m", type: "I"},
4949
sequence: {wire: "H", type: "L"},
5050
tempo: {wire: "j", type: "F"},
51+
sequencer_run: {wire: "zY", type: "I"},
52+
external_midi_sync: {wire: "zC", type: "I"},
5153
synth: {wire: "i", type: "I"},
5254
pedal: {wire: "ip", type: "I"},
5355
synth_flags: {wire: "if", type: "I"},
@@ -113,24 +115,26 @@ var AMY_KW_PRIORITY = {
113115
portamento: 41,
114116
sequence: 42,
115117
tempo: 43,
116-
synth: 44,
117-
pedal: 45,
118-
synth_flags: 46,
119-
num_voices: 47,
120-
oscs_per_voice: 48,
121-
to_synth: 49,
122-
grab_midi_notes: 50,
123-
note_source: 51,
124-
synth_delay: 52,
125-
preset: 53,
126-
num_partials: 54,
127-
start_sample: 55,
128-
stop_sample: 56,
129-
bus: 57,
130-
midi_cc: 58,
131-
midi_note_cmd: 59,
132-
cv_trigger: 60,
133-
patch_string: 61
118+
sequencer_run: 44,
119+
external_midi_sync: 45,
120+
synth: 46,
121+
pedal: 47,
122+
synth_flags: 48,
123+
num_voices: 49,
124+
oscs_per_voice: 50,
125+
to_synth: 51,
126+
grab_midi_notes: 52,
127+
note_source: 53,
128+
synth_delay: 54,
129+
preset: 55,
130+
num_partials: 56,
131+
start_sample: 57,
132+
stop_sample: 58,
133+
bus: 59,
134+
midi_cc: 60,
135+
midi_note_cmd: 61,
136+
cv_trigger: 62,
137+
patch_string: 63
134138
};
135139

136140
var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod", "bend", "ext0", "ext1"];
@@ -379,6 +383,9 @@ var AMY = {
379383
RESET_SYNTHS: 262144,
380384
RESET_PATCH: 524288,
381385
RESET_QUEUE: 1048576,
386+
SYNTH_FLAGS_NOTES_VIA_MIDI: 1,
387+
SYNTH_FLAGS_IGNORE_NOTE_OFFS: 2,
388+
SYNTH_FLAGS_NEGATE_PEDAL: 4,
382389
AMY_OK: 0,
383390
AMY_AUDIO_IS_NONE: 0,
384391
AMY_AUDIO_IS_I2S: 1,

tulip/amyboardweb/static/patches.generated.js

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
Auto-generated by tulip/server/sync_amy_docs.py — do not edit by hand.
2-
Source: amy@ff59eb2726645d9469d2a034363b57fa59dee4eb
2+
Source: amy@f1277729f479648ace3f6b3b475c3e6ffdbffeae
33
Files: api.md, arduino.md, billie_jean.md, godot.md, juno_patches.md, midi.md, synth.md, upgrading.md

tulip/server/refdocs/amy/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ A note on list parameters: When an argument is a list of parameters, you can in
217217
| `if` | `synth_flags` | `synth_flags` | uint | Flags for synth creation: 1 = Use MIDI drum note->preset translation; 2 = Drop note-off events. |
218218
| `ig` | `cv_trigger` | `cv_trigger` | uint,uint | Configure external CV event triggering: Gate CV, trigger threshold, reset threshold, pitch CV, pitch scale, pitch offset, wire command template, %v gets pitch value. |
219219
| `im` | `grab_midi_notes` | `grab_midi_notes` | 0/1 | Use `amy.send(synth=CHANNEL, grab_midi_notes=0)` to prevent the default direct forwarding of MIDI note-on/offs to synth CHANNEL. |
220-
| `iM` | `note_source` | 0/1 | Mark the note-ons for this synth as MIDI-derived, meaning they won't get forwarded to MIDI out (or clear that marking). |
220+
| `iM` | `note_source` | `note_source` | 0/1 | Mark the note-ons for this synth as MIDI-derived, meaning they won't get forwarded to MIDI out (or clear that marking). |
221221
| `in` | `oscs_per_voice` | `oscs_per_voice` | >0 | Reserve this many oscs for each voice. Needed when initializing a synth (or voice) withouth an initial patch. Setting `oscs_per_voice` on an existing synth resets all oscs to their default state. |
222222
| `io` | **TODO** | `midi_note_cmd` | M,L,N,X,O,CMD | MIDI Note on/off command for this synth. M=MIDI note number, or -1 for all notes. Other args map the velocity, as for `ic`. `%n` is substituted with the note number. |
223223
| `ip` | `pedal` | `pedal` | int | Non-zero means pedal is down (i.e., sustain). Must be used with `synth`. |

tulip/server/refdocs/amy/godot.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,6 @@ AMY works on web exports. The native GDExtension isn't used on web — instead,
123123
web_audio/ # AMY audio engine
124124
amy.js
125125
amy.wasm
126-
amy.aw.js
127-
amy.ww.js
128126
godot_amy_bridge.js
129127
enable-threads.js
130128
```

tulip/server/refdocs/amy/midi.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,37 @@ AMY supports MIDI realtime transport for the internal sequencer:
4949
- `0xFA` (Start): starts sequencer playback from tick 0 in external clock mode.
5050
- `0xFC` (Stop): stops sequencer playback.
5151

52+
External realtime sync is **off by default** — AMY ignores incoming clock/start/stop and keeps its own tempo. Enable it with the C API `amy_external_midi_sync(1)`, or over the wire with `amy.send(external_midi_sync=1)` (wire command `zC1`). Set it back to 0 to return to the internal clock.
53+
5254
MIDI Timing Clock is 24 PPQ. AMY's sequencer runs at 48 PPQ, so AMY advances two sequencer ticks per one MIDI clock pulse.
5355

54-
If you stop the sequencer with MIDI (using `0xFC`) the only way to start the sequencer again is with MIDI `0xFA`. We don't (yet) have AMY controls to stop / start the sequencer.
56+
You can also start and stop the sequencer transport directly, independent of MIDI clock sync, with `amy.send(sequencer_run=1)` / `amy.send(sequencer_run=0)` (wire command `zY1` / `zY0`).
57+
58+
## Sending MIDI out
59+
60+
As well as responding to incoming MIDI, AMY can _send_ MIDI out. Set any oscillator's `wave` to `amy.AMY_MIDI` and it stops producing audio — instead, every note you play on that osc is emitted as a MIDI message out whatever MIDI interface you've configured (`amy_config.midi`, e.g. UART or USB gadget).
61+
62+
```python
63+
amy.send(osc=0, wave=amy.AMY_MIDI) # osc 0 is now a MIDI sender, not an audio oscillator
64+
amy.send(osc=0, note=60, vel=1) # -> MIDI note on: channel 1, note 60, velocity 127
65+
amy.send(osc=0, note=60, vel=0) # -> MIDI note off (sent as a note on with velocity 0)
66+
```
67+
68+
- Messages always go out on **MIDI channel 1** (status byte `0x90`).
69+
- The event's `note` (`midi_note`) and `vel` become the MIDI note number and velocity. Velocity is scaled from AMY's `0.0``1.0` range to `0``127`. A note-off is sent as a note-on with velocity 0.
70+
- Notes that AMY received over MIDI *in* are **not** echoed back out, so you won't create a feedback loop. Only notes you generate inside AMY — directly, or from the sequencer — are forwarded.
71+
72+
### Sequencing MIDI out
73+
74+
Because an `AMY_MIDI` osc emits MIDI in response to ordinary note events, you can drive it from [AMY's sequencer](synth.md) to get tightly-timed MIDI output — a step sequencer or drum pattern clocked by AMY that plays an external synth. Schedule note-ons and note-offs on the osc just like you would schedule audio notes:
75+
76+
```python
77+
amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender once
78+
79+
# Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note.
80+
amy.send(osc=0, note=60, vel=1, sequence="0,48,1") # note on at tick 0 of each 48-tick period
81+
amy.send(osc=0, note=60, vel=0, sequence="24,48,2") # note off at tick 24 of each 48-tick period
82+
```
83+
84+
AMY keeps sending those MIDI messages out the port at the configured tempo until you remove them (by their `tag`) or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details.
5585

tulip/shared/modtulip.c

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,9 @@ STATIC mp_obj_t tulip_midi_callback(size_t n_args, const mp_obj_t *args) {
9090
}
9191
STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_midi_callback_obj, 1, 1, tulip_midi_callback);
9292

93-
#ifndef __EMSCRIPTEN__
94-
STATIC mp_obj_t tulip_external_midi_sync(size_t n_args, const mp_obj_t *args) {
95-
amy_external_midi_sync(mp_obj_is_true(args[0]) ? 1 : 0);
96-
return mp_const_none;
97-
}
98-
STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_external_midi_sync_obj, 1, 1, tulip_external_midi_sync);
99-
#endif
100-
101-
93+
// tulip.external_midi_sync() is defined in tulip.py: it sends the AMY wire
94+
// command (external_midi_sync / zC) so it also works on the web builds, where
95+
// AMY is a separate WASM module reachable only via the wire protocol.
10296

10397
#ifndef __EMSCRIPTEN__
10498
extern int amy_get_output_buffer(int16_t *samples);
@@ -1664,7 +1658,6 @@ STATIC const mp_rom_map_elem_t tulip_module_globals_table[] = {
16641658
{ MP_ROM_QSTR(MP_QSTR_seq_remove_callbacks), MP_ROM_PTR(&tulip_seq_remove_callbacks_obj) },
16651659
{ MP_ROM_QSTR(MP_QSTR_midi_callback), MP_ROM_PTR(&tulip_midi_callback_obj) },
16661660
#ifndef __EMSCRIPTEN__
1667-
{ MP_ROM_QSTR(MP_QSTR_external_midi_sync), MP_ROM_PTR(&tulip_external_midi_sync_obj) },
16681661
{ MP_ROM_QSTR(MP_QSTR_amy_block_done_callback), MP_ROM_PTR(&tulip_amy_block_done_callback_obj) },
16691662
{ MP_ROM_QSTR(MP_QSTR_amy_get_input_buffer), MP_ROM_PTR(&tulip_amy_get_input_buffer_obj) },
16701663
{ MP_ROM_QSTR(MP_QSTR_amy_get_output_buffer), MP_ROM_PTR(&tulip_amy_get_output_buffer_obj) },

tulip/shared/py/tulip.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,14 @@ def seq_bpm(bpm=None):
5454
def seq_ppq(ppq=None):
5555
if ppq is not None:
5656
print("You can no longer set PPQ in Tulip. It's fixed at %d" % (amy.AMY_SEQUENCER_PPQ))
57-
return amy.AMY_SEQUENCER_PPQ
57+
return amy.AMY_SEQUENCER_PPQ
58+
59+
# Follow (or stop following) external MIDI realtime clock/start/stop. Sent as an
60+
# AMY wire command (external_midi_sync / zC), so it works identically on firmware
61+
# and the web builds, where AMY is a separate WASM module reachable only via the
62+
# wire protocol.
63+
def external_midi_sync(enabled):
64+
amy.send(external_midi_sync=1 if enabled else 0)
5865

5966
def remap():
6067
if board() == "WEB":

0 commit comments

Comments
 (0)