Skip to content

Commit 671d240

Browse files
authored
Merge pull request #1125 from shorepine/claude/amyboard-world-download-3784df
Add world.amyboard.download: run AMYboard World sketches on Tulip too
2 parents 3c5860e + ebb65e5 commit 671d240

9 files changed

Lines changed: 222 additions & 3 deletions

File tree

docs/amyboard/online.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,15 @@ You can search or see everyone's uploaded sketches. They contain both patches an
240240

241241
You can upload your own environments or patches here. Choose a memorable username and describe your work!
242242

243+
You can also fetch and run AMYboard World sketches straight from any Python REPL — on AMYboard, on Tulip CC / Tulip Desktop, and on both web ports:
244+
245+
```python
246+
world.amyboard.ls() # list recent sketches
247+
world.amyboard.download("eno_ambient", "dpwe") # download the sketch and start it
248+
```
249+
250+
On Tulip, the sketch starts the same way it does on AMYboard (synths reset, saved knob state applied, `loop()` on the sequencer); CV in/out calls no-op, while I2C accessories like the OLED display and rotary encoders work.
251+
243252

244253

245254
[Back to Getting Started](README.md)

docs/tulip_api.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,19 @@ world.download(package_name) # Downloads a package and extracts it
162162

163163
world.ls() # lists most recent unique filenames/usernames
164164
world.ls(100) # optional count (most recent)
165+
166+
# AMYboard World: sketches shared on amyboard.com also run on Tulip.
167+
# download() fetches the latest sketch.py into user/current/ and starts it the
168+
# AMYboard way: synths reset, the sketch's saved knob state applied, and its
169+
# loop() scheduled on the sequencer. CV in/out calls no-op on Tulip; I2C
170+
# accessories (OLED display, rotary encoders) work.
171+
world.amyboard.download(sketch_name) # e.g. world.amyboard.download("eno_ambient")
172+
world.amyboard.download(sketch_name, username) # latest version by a specific user
173+
world.amyboard.download(sketch_name, username, start=False) # just download, don't run
174+
world.amyboard.ls() # lists most recent AMYboard World sketches
175+
176+
import amyboard
177+
amyboard.stop_sketch() # stops a running sketch's loop()
165178
```
166179

167180
Big note: Tulip World is hosted by a bot running on the [Tulip/AMY/Alles Discord](https://discord.gg/TzBFkUb8pG). If there's any abuse of the system, I'll revoke the key. I'd love more help making Tulip World a more stable and fun experience for everyone.

tulip/esp32s3/boards/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
require("umqtt.simple")
2222

2323
freeze("$(PORT_DIR)/../shared/py")
24+
# AMYboard's python module, so Tulip can run AMYboard World sketches
25+
# (world.amyboard.download). CV helpers no-op on Tulip; I2C accessories work.
26+
freeze("$(PORT_DIR)/../shared/amyboard-py", "amyboard.py")
2427
package("amy", base_path="$(MPY_DIR)/../amy")
2528

2629
#freeze("$(MPY_DIR)/lib/micropython-lib/micropython/utarfile", "utarfile.py")

tulip/linux/variants/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
include("$(MPY_DIR)/extmod/asyncio")
22
freeze("$(MPY_DIR)/../tulip/shared/py")
3+
# AMYboard's python module, so Tulip Desktop can run AMYboard World sketches
4+
# (world.amyboard.download). CV helpers and I2C accessories no-op on desktop.
5+
freeze("$(MPY_DIR)/../tulip/shared/amyboard-py", "amyboard.py")
36
package("amy", base_path="$(MPY_DIR)/../amy")
47

58
require("bundle-networking")

tulip/macos/variants/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11

22
include("$(MPY_DIR)/extmod/asyncio")
33
freeze("$(MPY_DIR)/../tulip/shared/py")
4+
# AMYboard's python module, so Tulip Desktop can run AMYboard World sketches
5+
# (world.amyboard.download). CV helpers and I2C accessories no-op on desktop.
6+
freeze("$(MPY_DIR)/../tulip/shared/amyboard-py", "amyboard.py")
47
package("amy", base_path="$(MPY_DIR)/../amy")
58
require("bundle-networking")
69

tulip/shared/amyboard-py/amyboard.py

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

15+
def cv_capable():
16+
"""True on boards with AMYboard CV hardware. This module is also frozen
17+
into Tulip builds (so AMYboard World sketches run there via
18+
world.amyboard.download()); on Tulip the CV helpers below just no-op."""
19+
return tulip.board() in ("AMYBOARD", "AMYBOARD_WEB")
20+
1521

1622
class _BGWriteI2C:
1723
"""i2c-shaped adapter that enqueues writes on the firmware's background
@@ -671,6 +677,16 @@ def report_version(*_args):
671677

672678
def run_sketch():
673679
"""Apply knobs from sketch.py, then import it (top-level code runs), start loop()."""
680+
global display
681+
if display is None:
682+
# AMYboard boots init the display in _boot.py; on Tulip we only get
683+
# here via world.amyboard.download(), so probe the I2C OLED now. If
684+
# none is attached, amyboard.display is a no-op Display, so sketches
685+
# that draw still run.
686+
try:
687+
init_display()
688+
except Exception:
689+
pass
674690
_env_dir = _ensure_current_env_layout()
675691
from upysh import cd
676692
tulip.stderr_write("cding to %s" % (_env_dir))
@@ -809,8 +825,14 @@ def restart_sketch():
809825
def get_i2c():
810826
global i2c
811827
if(i2c is None):
812-
from machine import I2C
813-
i2c = I2C(0, freq=400000)
828+
try:
829+
from machine import I2C
830+
i2c = I2C(0, freq=400000)
831+
except Exception as e:
832+
# Ports without machine.I2C (Tulip Desktop, web): surface this as
833+
# OSError so every accessory helper's missing-hardware path treats
834+
# it like an absent device instead of crashing the sketch.
835+
raise OSError("I2C unavailable: %s" % e)
814836
return i2c
815837

816838
def ssd1327_oled():
@@ -937,7 +959,11 @@ def set_cv_out(channel=0, synth=1):
937959
the CV waveform shape, frequency, etc.
938960
939961
Call set_cv_out(channel, synth=0) to clear the mapping.
962+
963+
No-op on boards without CV hardware (Tulip).
940964
"""
965+
if not cv_capable():
966+
return
941967
tulip.set_cv_synth(synth, channel + 1 if synth > 0 else 0)
942968

943969
def edit(filename=None):
@@ -949,7 +975,11 @@ def edit(filename=None):
949975
pye()
950976

951977
def cv_out(volts, channel=0):
952-
"""Output -10.0v to +10.0v (nominal) on CV1 (channel=0) or 2 (channel=1)"""
978+
"""Output -10.0v to +10.0v (nominal) on CV1 (channel=0) or 2 (channel=1).
979+
980+
No-op on boards without CV hardware (Tulip)."""
981+
if not cv_capable():
982+
return
953983
addr = 88 # GP8413
954984
# With rev1 scaling, 0x0000 -> -10v, 0x7fff -> +10v
955985
val = int(((volts + 10)/20.0) * 0x8000)
@@ -965,6 +995,9 @@ def cv_out(volts, channel=0):
965995
get_i2c().writeto_mem(addr, ch, bytes([b0,b1]))
966996

967997
def cv_in(channel=0):
998+
# Boards without CV hardware (Tulip) read a constant 0.
999+
if not cv_capable():
1000+
return 0.0
9681001
return tulip.cv_in(channel)
9691002

9701003
# --- Web encoder emulation helpers (called from JS) ---

tulip/shared/py/world.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,75 @@ def post_message(message):
225225
_post_json("/api/tulipworld/messages", {"username": u, "content": message[:MAX_MESSAGE_SIZE]})
226226
except Exception:
227227
print("Could not post message.")
228+
229+
230+
# A M Y B O A R D ~ W O R L D
231+
# Sketches shared on amyboard.com. world.amyboard.download("eno_ambient", "dpwe")
232+
# fetches the latest sketch.py by that name (username optional) into
233+
# user/current/sketch.py and starts it the AMYboard way: synths reset,
234+
# _auto_generated_knobs applied, loop() scheduled on the sequencer.
235+
# Works on AMYboard and on Tulip — on Tulip the CV in/out calls in a sketch
236+
# no-op, while I2C accessories (OLED display, rotary encoders) still work.
237+
238+
def _amyboard_sketch_filename(sketch):
239+
return sketch if sketch.endswith(".py") else sketch + ".py"
240+
241+
242+
class _AmyboardWorld:
243+
def ls(self, count=10):
244+
for f in _get_json("/api/amyboardworld/files", limit=count)["items"]:
245+
name = f["filename"]
246+
if name.endswith(".py"):
247+
name = name[:-3]
248+
print(
249+
"% 20s % 10s % 8s %s"
250+
% (
251+
name,
252+
f["username"][:MAX_USERNAME_SIZE],
253+
nice_time(f["age_ms"]),
254+
f["description"][:MAX_DESCRIPTION_SIZE],
255+
)
256+
)
257+
258+
def download(self, sketch, username=None, start=True):
259+
filename = _amyboard_sketch_filename(sketch)
260+
# Resolve via the files listing (q= narrows server-side, then exact
261+
# filename match here): items come back newest-first, so the first
262+
# hit is the latest revision.
263+
got = None
264+
try:
265+
if username is None:
266+
items = _get_json("/api/amyboardworld/files", q=filename, limit=50, latest_per_user_env="false")["items"]
267+
else:
268+
items = _get_json("/api/amyboardworld/files", q=filename, username=username, limit=50, latest_per_user_env="false")["items"]
269+
for f in items:
270+
if f["filename"].lower() == filename.lower():
271+
got = f
272+
break
273+
except Exception:
274+
got = None
275+
276+
if got is None:
277+
if username is None:
278+
print("Could not find %s on AMYboard World" % (filename))
279+
else:
280+
print("Could not find %s by %s on AMYboard World" % (filename, username))
281+
return
282+
283+
import amyboard as _amyboard
284+
env_dir = _amyboard.ensure_user_environment()
285+
url = got["download_url"]
286+
if not url.startswith("http"):
287+
url = WORLD_API_BASE + url
288+
r = requests.get(url)
289+
r.save(env_dir + "/sketch.py")
290+
print(
291+
"Downloaded sketch %s by %s [%d bytes, last updated %s] from AMYboard World."
292+
% (filename[:-3], got["username"], got["size"], nice_time(got["age_ms"]).lstrip())
293+
)
294+
if start:
295+
print("Starting sketch. Stop it with amyboard.stop_sketch()")
296+
_amyboard.restart_sketch()
297+
298+
299+
amyboard = _AmyboardWorld()

tulip/shared/py/world_web.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,83 @@ def clean_up(x):
187187
os.remove(filename)
188188

189189
world_upload_file(pwd() + "/", filename, username, description).then(lambda x: clean_up(x))
190+
191+
192+
# A M Y B O A R D ~ W O R L D
193+
# Async version of world.amyboard for the web ports. See world.py for details:
194+
# world.amyboard.download("eno_ambient", "dpwe") fetches the latest sketch.py
195+
# into user/current/sketch.py and starts it via amyboard.restart_sketch().
196+
197+
class _AmyboardWorldWeb:
198+
def ls(self, count=10):
199+
def do_next(payload):
200+
for f in payload.get("items", []):
201+
name = f["filename"]
202+
if name.endswith(".py"):
203+
name = name[:-3]
204+
print(
205+
"% 20s % 10s % 8s %s"
206+
% (
207+
name,
208+
f["username"][:MAX_USERNAME_SIZE],
209+
nice_time(f["age_ms"]),
210+
f["description"][:MAX_DESCRIPTION_SIZE],
211+
)
212+
)
213+
214+
return js.fetch(
215+
world_api_url("/api/amyboardworld/files?limit=%d" % (count))
216+
).then(lambda r: r.text()).then(lambda x: json.loads(x)).then(do_next)
217+
218+
def download(self, sketch, username=None, start=True):
219+
filename = sketch if sketch.endswith(".py") else sketch + ".py"
220+
# Resolve via the files listing (q= narrows server-side, then exact
221+
# filename match below): items come back newest-first, so the first
222+
# hit is the latest revision.
223+
query = "/api/amyboardworld/files?limit=50&latest_per_user_env=false&q=%s" % (js.encodeURIComponent(filename))
224+
if username is not None:
225+
query = query + "&username=%s" % (js.encodeURIComponent(username))
226+
227+
def on_missing(_=None):
228+
if username is None:
229+
print("Could not find %s on AMYboard World" % (filename))
230+
else:
231+
print("Could not find %s by %s on AMYboard World" % (filename, username))
232+
233+
def on_got(got):
234+
def save_finished(x):
235+
import amyboard as _amyboard
236+
env_dir = _amyboard.ensure_user_environment()
237+
f = open(env_dir + "/sketch.py", "wb")
238+
f.write(bytes(x))
239+
f.close()
240+
print(
241+
"Downloaded sketch %s by %s [%d bytes, last updated %s] from AMYboard World."
242+
% (filename[:-3], got["username"], got["size"], nice_time(got["age_ms"]).lstrip())
243+
)
244+
if start:
245+
print("Starting sketch. Stop it with amyboard.stop_sketch()")
246+
_amyboard.restart_sketch()
247+
248+
grab_url = got["download_url"]
249+
if not str(grab_url).startswith("http"):
250+
grab_url = world_api_url(grab_url)
251+
return grab("urlget", url=grab_url).then(lambda x: save_finished(x))
252+
253+
def on_items(payload):
254+
for f in payload.get("items", []):
255+
if f["filename"].lower() == filename.lower():
256+
return on_got(f)
257+
on_missing()
258+
return js.Promise.resolve(None)
259+
260+
def on_resp(resp):
261+
if not resp.ok:
262+
on_missing()
263+
return js.Promise.resolve(None)
264+
return resp.text().then(lambda x: on_items(json.loads(x)))
265+
266+
return js.fetch(world_api_url(query)).then(lambda resp: on_resp(resp))
267+
268+
269+
amyboard = _AmyboardWorldWeb()

tulip/web/variants/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
# runtime (with setTimeout an Promise's) to contrtol the scheduling.
44

55
freeze("../../shared/py")
6+
# AMYboard's python module, so Tulip Web can run AMYboard World sketches
7+
# (world.amyboard.download). CV helpers and I2C accessories no-op on web.
8+
freeze("../../shared/amyboard-py", "amyboard.py")
69
package("amy", base_path="$(MPY_DIR)/../amy")
710

811

0 commit comments

Comments
 (0)