Skip to content

Commit 83c32a7

Browse files
authored
Merge pull request #1096 from shorepine/claude/sweet-kepler-b77fdb
OLED: diffed partial updates + background I2C flush task (no more ~300ms display stalls)
2 parents d017622 + 40e475e commit 83c32a7

7 files changed

Lines changed: 360 additions & 19 deletions

File tree

AMY_AGENTS.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,89 @@ def loop():
9393
9494
---
9595

96+
## Repeating patterns that must stay tight: put them in `sequencer.AMYSequence`, not `loop()`
97+
98+
Notes sent from `loop()` fire only when Python gets there — anything slow in `loop()`
99+
(display writes, file/World access, heavy math) pushes them late and the groove flams.
100+
An `AMYSequence` event lives **inside the audio engine** and repeats sample-accurately no
101+
matter what Python is doing. Prefer it for any fixed, repeating pattern (drum grooves,
102+
arps, ostinatos); keep `loop()` for UI, knobs, and evolving the pattern.
103+
104+
`sequencer.AMYSequence(length, divider)` is a looping grid of `length` slots, each
105+
`1/divider` of a whole note (so `AMYSequence(32, 32)` = one 4/4 bar of 32nd-note slots).
106+
`seq.add(position, amy.send, synth=…, note=…, vel=…)` schedules a repeating note; it
107+
returns an event with `.update(position, amy.send, …)` to change it and `.remove()` to
108+
delete it.
109+
110+
### Example — four-on-the-floor kick + 8th hats that keep time no matter what loop() does
111+
112+
```python
113+
import amy, sequencer
114+
115+
sequencer.tempo(120)
116+
amy.send(synth=10, patch=384, num_voices=6, synth_flags=3) # TR-808 GM kit
117+
118+
seq = sequencer.AMYSequence(32, 32) # one 4/4 bar, 32nd-note grid
119+
for pos in (0, 8, 16, 24):
120+
seq.add(pos, amy.send, synth=10, note=36, vel=1) # kick on every beat
121+
for pos in range(0, 32, 4):
122+
seq.add(pos, amy.send, synth=10, note=42, vel=0.3) # 8th-note closed hats
123+
124+
def loop():
125+
pass # free for UI/display/knobs -- the pattern plays itself
126+
```
127+
128+
> Source of truth: `tulip/shared/py/sequencer.py` (`AMYSequence`) and
129+
> `tulip/shared/py/drums.py` (`app.drum_seq.add(position=…, func=amy.send, synth=…,
130+
> note=…, vel=…)` — the drum machine app schedules every switch this way).
131+
132+
---
133+
134+
## The OLED display: draw via `amyboard.display`, refresh with `.show()` — redraws are diffed, but update on musical boundaries, not every `loop()`
135+
136+
`amyboard.display` is a 128×128 grayscale framebuffer: `.text(s, x, y, col)`,
137+
`.fill(col)`, `.fill_rect(x, y, w, h, col)`, `.rect`, `.line`, `.pixel`, plus
138+
`.message(s, row=n)` (one text line with background, includes the refresh). `col` is
139+
0–255. Text rows are 8px tall; `message()` rows are 12px. All methods are safe no-ops
140+
when no display is attached, and draw to the on-screen panel in the web simulator.
141+
142+
`display.show()` is cheap: the driver diffs against what the panel already shows and
143+
transfers **only the rows that changed**, and the transfer itself runs on a background
144+
firmware task — `show()` queues it and returns in ~1ms instead of blocking Python for
145+
the 100–200ms a full-screen I2C push takes on the wire. The clear-everything-and-redraw
146+
idiom is fine, and a `show()` where nothing changed costs ~nothing. Still: refresh when
147+
the *content* changes (once per bar/beat, on a knob turn), not unconditionally every
148+
`loop()` call — redrawing in Python isn't free — and put must-stay-tight patterns in
149+
`AMYSequence` (section above).
150+
151+
### Example — kit name + bar counter, updated once per bar
152+
153+
```python
154+
import amy, amyboard, sequencer
155+
156+
sequencer.tempo(120)
157+
amy.send(synth=10, patch=384, num_voices=6, synth_flags=3)
158+
d = amyboard.display
159+
step = -1
160+
161+
def loop():
162+
global step
163+
step += 1
164+
if step % 8 == 0:
165+
amy.send(synth=10, note=36, vel=1) # (better: AMYSequence, see above)
166+
if step % 32 == 0: # once per bar, content changed
167+
d.fill(0)
168+
d.text("TR-808", 0, 0, 255)
169+
d.text("bar %d" % (step // 32), 0, 16, 255)
170+
d.show()
171+
```
172+
173+
> Source of truth: the `Display` class in `tulip/shared/amyboard-py/amyboard.py`;
174+
> shadow-diff partial updates in `tulip/shared/py/ssd1327.py` / `sh1107.py`;
175+
> `tulip/amyboardweb/sketches/house_generator.py` (bar-boundary display updates).
176+
177+
---
178+
96179
## MIDI-controlled parameters: map the CC inside AMY (`midi_cc` / `ic`), don't poll in `loop()`
97180

98181
When the user asks for a synth parameter (resonance, filter cutoff, amp, pan,

tulip/amyboard/amyboard_support.c

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include <stdio.h>
22
#include <inttypes.h>
3+
#include <string.h>
34
#include "amy.h"
45

56
// Stuff just for amyboard -- cv in/out direct , i2c in/out
@@ -8,6 +9,7 @@
89
#include "sdkconfig.h"
910
#include "freertos/FreeRTOS.h"
1011
#include "freertos/task.h"
12+
#include "freertos/message_buffer.h"
1113
#include "esp_chip_info.h"
1214
#include "esp_flash.h"
1315
#include "esp_system.h"
@@ -79,6 +81,76 @@ void i2c_check_for_data() {
7981

8082

8183

84+
// ---------------------------------------------------------------------------
85+
// Background I2C write queue. The OLED framebuffer flush takes ~200ms of bus
86+
// time at 400kHz; done synchronously from MicroPython it froze Python (and
87+
// starved the CV tasks' port mutex) for that long. Python instead enqueues
88+
// each transaction here and returns immediately; this low-priority task plays
89+
// them out in order on I2C_NUM_0. The legacy IDF driver's per-port mutex
90+
// serializes us against machine.I2C and the CV read/write hooks, and because
91+
// Python chunks large payloads into small transactions, those users interleave
92+
// between chunks instead of timing out. Single producer (the MicroPython
93+
// task) / single consumer (this task), which is all a MessageBuffer allows.
94+
95+
#define I2C_BG_QUEUE_BYTES 16384 // > one full 8KB OLED frame incl. overhead
96+
#define I2C_BG_MAX_PAYLOAD 1024 // per-transaction cap (Python sends ~256B)
97+
#define I2C_BG_TASK_STACK_SIZE 4096
98+
#define I2C_BG_TASK_PRIORITY (ESP_TASK_PRIO_MIN + 1) // same as cv_read_task
99+
#define I2C_BG_TASK_COREID (0) // MicroPython runs on core 1
100+
101+
static MessageBufferHandle_t i2c_bg_mb = NULL;
102+
static volatile uint32_t i2c_bg_errors_count = 0;
103+
static volatile uint8_t i2c_bg_in_flight = 0;
104+
105+
static void i2c_bg_task(void *pvParameter) {
106+
static uint8_t msg[I2C_BG_MAX_PAYLOAD + 1]; // [addr][payload...]
107+
for(;;) {
108+
size_t n = xMessageBufferReceive(i2c_bg_mb, msg, sizeof(msg), portMAX_DELAY);
109+
if(n < 2) continue;
110+
i2c_bg_in_flight = 1;
111+
esp_err_t ret = i2c_master_write_to_device(I2C_NUM_0, msg[0], msg + 1, n - 1, pdMS_TO_TICKS(100));
112+
i2c_bg_in_flight = 0;
113+
if(ret != ESP_OK) i2c_bg_errors_count++;
114+
}
115+
}
116+
117+
// Enqueue one I2C write transaction. Returns 0 on success, 1 if it had to be
118+
// dropped (queue stayed full / payload too big) -- the failure also bumps
119+
// i2c_bg_errors() so Python can schedule a full panel resync.
120+
uint8_t amyboard_i2c_bg_write(uint8_t addr, const uint8_t *buf, uint32_t len) {
121+
// Only ever called from the MicroPython task (single producer).
122+
static uint8_t msg[I2C_BG_MAX_PAYLOAD + 1];
123+
if(len == 0 || len > I2C_BG_MAX_PAYLOAD) {
124+
i2c_bg_errors_count++;
125+
return 1;
126+
}
127+
if(i2c_bg_mb == NULL) {
128+
i2c_bg_mb = xMessageBufferCreate(I2C_BG_QUEUE_BYTES);
129+
xTaskCreatePinnedToCore(i2c_bg_task, "i2c_bg", I2C_BG_TASK_STACK_SIZE / sizeof(StackType_t),
130+
NULL, I2C_BG_TASK_PRIORITY, NULL, I2C_BG_TASK_COREID);
131+
}
132+
msg[0] = addr;
133+
memcpy(msg + 1, buf, len);
134+
// Blocking here is backpressure: it only happens when more than a whole
135+
// queued frame is outstanding, and it bounds how far Python can run ahead.
136+
if(xMessageBufferSend(i2c_bg_mb, msg, len + 1, pdMS_TO_TICKS(500)) != len + 1) {
137+
i2c_bg_errors_count++;
138+
return 1;
139+
}
140+
return 0;
141+
}
142+
143+
// Bytes still queued (plus any transaction currently on the wire); 0 == idle.
144+
uint32_t amyboard_i2c_bg_pending(void) {
145+
if(i2c_bg_mb == NULL) return 0;
146+
uint32_t queued = I2C_BG_QUEUE_BYTES - (uint32_t)xMessageBufferSpacesAvailable(i2c_bg_mb);
147+
return queued + i2c_bg_in_flight;
148+
}
149+
150+
uint32_t amyboard_i2c_bg_errors(void) {
151+
return i2c_bg_errors_count;
152+
}
153+
82154
#define ADS1015_REGISTER_CONFIG (0x01)
83155
#define ADS1015_CQUE_DISABLE (0x0003)
84156

tulip/amyboardweb/sketches/drum_machine.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# AMYboard Sketch
2-
# DESCRIPTION: Gamma9001 drum machine: three drum channels at once -- a main kit that rotates through all six banks, TR-909 hats, and a tabla/shaker percussion layer.
2+
# DESCRIPTION: Gamma9001 drum machine: three drum channels at once -- a main kit that rotates through all six banks, TR-909 hats, and a tabla/shaker percussion layer. The OLED shows the current kit, section position and beat.
33
# Top-level code runs once at boot. loop() is called every 32nd note.
4-
import amy, sequencer
4+
import amy, amyboard, sequencer
55
import random
66

77
sequencer.tempo(118)
@@ -22,6 +22,20 @@
2222

2323
amy.send(reverb="0.2,0.5,0.1")
2424

25+
# --- OLED display: kit name, section position, beat. Safe no-ops with no
26+
# display attached; draws to the panel in the simulator. show() only sends
27+
# the rows that changed, in the background, so per-beat updates are cheap.
28+
d = amyboard.display
29+
30+
def draw_kit():
31+
d.fill_rect(0, 16, 128, 8, 0)
32+
d.text(KITS[kit_idx][1], 0, 16, 255)
33+
34+
d.fill(0)
35+
d.text("DRUM MACHINE", 0, 0, 255)
36+
draw_kit()
37+
d.show()
38+
2539
# GM drum note numbers
2640
KICK, SNARE, CLAP, RIM = 36, 38, 39, 37
2741
CHAT, OHAT, CRASH, RIDE = 42, 46, 49, 51
@@ -54,6 +68,7 @@ def loop():
5468
kit_idx = (kit_idx + 1) % len(KITS)
5569
amy.send(synth=10, patch=KITS[kit_idx][0], num_voices=6, synth_flags=3)
5670
amy.send(synth=10, note=CRASH, vel=0.7) # announce the new kit
71+
draw_kit()
5772

5873
# --- Main kit (synth 10) ---
5974
kicks = kick_synco if bar % 4 == 3 else kick_pattern
@@ -85,3 +100,17 @@ def loop():
85100
amy.send(synth=12, note=TRIANGLE, vel=0.3)
86101
if bar == 0 and bar_step == 12:
87102
amy.send(synth=12, note=COWBELL, vel=0.5)
103+
104+
# --- Display: bar dots + beat blocks, refreshed once per beat ---
105+
if bar_step % 8 == 0:
106+
beat = bar_step // 8
107+
if beat == 0: # new bar: move the section-position dot
108+
for b in range(BARS_PER_SECTION):
109+
x = 4 + b * 15
110+
d.fill_rect(x, 32, 11, 8, 255 if b == bar else 0)
111+
d.rect(x, 32, 11, 8, 255)
112+
for b in range(4):
113+
x = 4 + b * 32
114+
d.fill_rect(x, 52, 22, 12, 255 if b == beat else 0)
115+
d.rect(x, 52, 22, 12, 255)
116+
d.show()

tulip/shared/amyboard-py/amyboard.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,46 @@ def web():
1313
return (tulip.board()=="AMYBOARD_WEB")
1414

1515

16+
class _BGWriteI2C:
17+
"""i2c-shaped adapter that enqueues writes on the firmware's background
18+
I2C task (tulip.i2c_bg_write) instead of blocking Python on the bus, so a
19+
display refresh returns immediately and the bytes clock out at 400kHz in
20+
the background. Payloads are chunked so the CV tasks' small transactions
21+
on the same port interleave between chunks instead of timing out.
22+
23+
writevto() assumes bufs[0] is a one-byte OLED control prefix that must be
24+
repeated on each chunk (true of the ssd1327/sh1107 drivers' write_data);
25+
the panel's address pointer carries across transactions, so consecutive
26+
chunks land contiguously on the glass."""
27+
_CHUNK = 256
28+
29+
def writeto(self, addr, buf):
30+
tulip.i2c_bg_write(addr, buf)
31+
32+
def writevto(self, addr, bufs):
33+
bufs = list(bufs)
34+
prefix = bytes(bufs[0])
35+
data = bufs[1] if len(bufs) == 2 else b''.join(bytes(b) for b in bufs[1:])
36+
step = self._CHUNK - len(prefix)
37+
if len(data) <= step:
38+
tulip.i2c_bg_write(addr, prefix + bytes(data))
39+
return
40+
mv = memoryview(data)
41+
for off in range(0, len(data), step):
42+
tulip.i2c_bg_write(addr, prefix + bytes(mv[off:off + step]))
43+
44+
45+
def _i2c_bg_available():
46+
return hasattr(tulip, 'i2c_bg_write')
47+
48+
49+
def _i2c_bg_drain(max_ms=500):
50+
"""Wait (briefly, bounded) for the background I2C queue to empty."""
51+
while tulip.i2c_bg_pending() and max_ms > 0:
52+
time.sleep_ms(5)
53+
max_ms -= 5
54+
55+
1656
class Display:
1757
"""Unified display interface for ssd1327, sh1107, and web framebuffer.
1858
@@ -29,12 +69,18 @@ def __init__(self, rotate=0):
2969
self._fb = None # framebuf used for drawing
3070
self._buf = None # raw byte buffer backing the framebuf
3171
self._is_web = web()
72+
self._bg = False # panel writes routed through the background I2C task
73+
self._last_bg_err = 0
3274

3375
if self._is_web:
3476
import framebuf
3577
self._buf = bytearray(self.WIDTH * self.HEIGHT // 2)
3678
self._fb = framebuf.FrameBuffer(self._buf, self.WIDTH, self.HEIGHT, framebuf.GS4_HMSB)
3779
else:
80+
if _i2c_bg_available():
81+
# a previous Display instance may still have queued panel
82+
# writes; let them finish before probing/re-initing
83+
_i2c_bg_drain()
3884
# Try ssd1327 first, then sh1107. rotate only applies to the sh1107
3985
# (the panel is square, so WIDTH/HEIGHT are unchanged by rotation).
4086
try:
@@ -50,6 +96,14 @@ def __init__(self, rotate=0):
5096
self._fb = hw # sh1107 *is* a FrameBuffer subclass
5197
except Exception:
5298
pass # no physical display
99+
if self._hw is not None and _i2c_bg_available():
100+
# Probe + init above ran synchronously (so a missing panel
101+
# raises and we fall through). From here on, route panel
102+
# writes through the background queue: show() then returns
103+
# immediately instead of blocking on the I2C transfer.
104+
self._hw.i2c = _BGWriteI2C()
105+
self._bg = True
106+
self._last_bg_err = tulip.i2c_bg_errors()
53107

54108
@property
55109
def buffer(self):
@@ -115,14 +169,28 @@ def message(self, message, row=0, inverse=False):
115169

116170
# -- refresh --
117171

118-
def show(self):
119-
"""Push the framebuffer to the display hardware (or web bridge)."""
172+
def show(self, full=False):
173+
"""Push the framebuffer to the display hardware (or web bridge).
174+
175+
Both hardware drivers diff against what the panel already shows and
176+
transfer only the changed portion, and on firmware with the
177+
background I2C task the transfer itself happens off the Python
178+
thread — show() just queues it and returns. Pass full=True to force
179+
the entire framebuffer out.
180+
"""
120181
if self._fb is None:
121182
return
122183
if self._is_web:
123184
tulip.framebuf_web_update(self._buf)
124185
elif self._hw is not None:
125-
self._hw.show()
186+
if self._bg:
187+
err = tulip.i2c_bg_errors()
188+
if err != self._last_bg_err:
189+
# a queued write failed, so the panel may no longer match
190+
# the driver's shadow -- resync everything this refresh
191+
self._last_bg_err = err
192+
self._hw._shadow_valid = False
193+
self._hw.show(full)
126194

127195
# convenience alias so old code calling display_refresh() on a Display works
128196
refresh = show

tulip/shared/modtulip.c

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,33 @@ STATIC mp_obj_t tulip_amyboard_send(size_t n_args, const mp_obj_t *args) {
632632
}
633633
STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_amyboard_send_obj, 1, 1, tulip_amyboard_send);
634634

635+
#ifdef ESP_PLATFORM
636+
// Background I2C write queue (amyboard_support.c): the OLED drivers enqueue
637+
// their transactions here so display refreshes don't block Python while the
638+
// bytes clock out at 400kHz.
639+
extern uint8_t amyboard_i2c_bg_write(uint8_t addr, const uint8_t *buf, uint32_t len);
640+
extern uint32_t amyboard_i2c_bg_pending(void);
641+
extern uint32_t amyboard_i2c_bg_errors(void);
642+
643+
STATIC mp_obj_t tulip_i2c_bg_write(mp_obj_t addr_obj, mp_obj_t buf_obj) {
644+
mp_buffer_info_t bufinfo;
645+
mp_get_buffer_raise(buf_obj, &bufinfo, MP_BUFFER_READ);
646+
amyboard_i2c_bg_write((uint8_t)mp_obj_get_int(addr_obj), (const uint8_t *)bufinfo.buf, (uint32_t)bufinfo.len);
647+
return mp_const_none;
648+
}
649+
STATIC MP_DEFINE_CONST_FUN_OBJ_2(tulip_i2c_bg_write_obj, tulip_i2c_bg_write);
650+
651+
STATIC mp_obj_t tulip_i2c_bg_pending(void) {
652+
return mp_obj_new_int(amyboard_i2c_bg_pending());
653+
}
654+
STATIC MP_DEFINE_CONST_FUN_OBJ_0(tulip_i2c_bg_pending_obj, tulip_i2c_bg_pending);
655+
656+
STATIC mp_obj_t tulip_i2c_bg_errors(void) {
657+
return mp_obj_new_int(amyboard_i2c_bg_errors());
658+
}
659+
STATIC MP_DEFINE_CONST_FUN_OBJ_0(tulip_i2c_bg_errors_obj, tulip_i2c_bg_errors);
660+
#endif // ESP_PLATFORM
661+
635662

636663

637664

@@ -1705,6 +1732,11 @@ STATIC const mp_rom_map_elem_t tulip_module_globals_table[] = {
17051732
{ MP_ROM_QSTR(MP_QSTR_amyboard_start), MP_ROM_PTR(&tulip_amyboard_start_obj) },
17061733
{ MP_ROM_QSTR(MP_QSTR_amyboard_set_midi_out), MP_ROM_PTR(&tulip_amyboard_set_midi_out_obj) },
17071734
{ MP_ROM_QSTR(MP_QSTR_bootloader_mode), MP_ROM_PTR(&tulip_bootloader_mode_obj) },
1735+
#ifdef ESP_PLATFORM
1736+
{ MP_ROM_QSTR(MP_QSTR_i2c_bg_write), MP_ROM_PTR(&tulip_i2c_bg_write_obj) },
1737+
{ MP_ROM_QSTR(MP_QSTR_i2c_bg_pending), MP_ROM_PTR(&tulip_i2c_bg_pending_obj) },
1738+
{ MP_ROM_QSTR(MP_QSTR_i2c_bg_errors), MP_ROM_PTR(&tulip_i2c_bg_errors_obj) },
1739+
#endif
17081740
#else
17091741
#ifndef AMYBOARD_WEB
17101742
{ MP_ROM_QSTR(MP_QSTR_display_clock), MP_ROM_PTR(&tulip_display_clock_obj) },

0 commit comments

Comments
 (0)