Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/amyboard/online.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,15 @@ You can search or see everyone's uploaded sketches. They contain both patches an

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

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:

```python
world.amyboard.ls() # list recent sketches
world.amyboard.download("eno_ambient", "dpwe") # download the sketch and start it
```

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.



[Back to Getting Started](README.md)
13 changes: 13 additions & 0 deletions docs/tulip_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ world.download(package_name) # Downloads a package and extracts it

world.ls() # lists most recent unique filenames/usernames
world.ls(100) # optional count (most recent)

# AMYboard World: sketches shared on amyboard.com also run on Tulip.
# download() fetches the latest sketch.py into user/current/ and starts it the
# AMYboard way: synths reset, the sketch's saved knob state applied, and its
# loop() scheduled on the sequencer. CV in/out calls no-op on Tulip; I2C
# accessories (OLED display, rotary encoders) work.
world.amyboard.download(sketch_name) # e.g. world.amyboard.download("eno_ambient")
world.amyboard.download(sketch_name, username) # latest version by a specific user
world.amyboard.download(sketch_name, username, start=False) # just download, don't run
world.amyboard.ls() # lists most recent AMYboard World sketches

import amyboard
amyboard.stop_sketch() # stops a running sketch's loop()
```

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.
Expand Down
3 changes: 3 additions & 0 deletions tulip/esp32s3/boards/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
require("umqtt.simple")

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

#freeze("$(MPY_DIR)/lib/micropython-lib/micropython/utarfile", "utarfile.py")
3 changes: 3 additions & 0 deletions tulip/linux/variants/manifest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
include("$(MPY_DIR)/extmod/asyncio")
freeze("$(MPY_DIR)/../tulip/shared/py")
# AMYboard's python module, so Tulip Desktop can run AMYboard World sketches
# (world.amyboard.download). CV helpers and I2C accessories no-op on desktop.
freeze("$(MPY_DIR)/../tulip/shared/amyboard-py", "amyboard.py")
package("amy", base_path="$(MPY_DIR)/../amy")

require("bundle-networking")
Expand Down
3 changes: 3 additions & 0 deletions tulip/macos/variants/manifest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@

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

39 changes: 36 additions & 3 deletions tulip/shared/amyboard-py/amyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
def web():
return (tulip.board()=="AMYBOARD_WEB")

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


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

def run_sketch():
"""Apply knobs from sketch.py, then import it (top-level code runs), start loop()."""
global display
if display is None:
# AMYboard boots init the display in _boot.py; on Tulip we only get
# here via world.amyboard.download(), so probe the I2C OLED now. If
# none is attached, amyboard.display is a no-op Display, so sketches
# that draw still run.
try:
init_display()
except Exception:
pass
_env_dir = _ensure_current_env_layout()
from upysh import cd
tulip.stderr_write("cding to %s" % (_env_dir))
Expand Down Expand Up @@ -809,8 +825,14 @@ def restart_sketch():
def get_i2c():
global i2c
if(i2c is None):
from machine import I2C
i2c = I2C(0, freq=400000)
try:
from machine import I2C
i2c = I2C(0, freq=400000)
except Exception as e:
# Ports without machine.I2C (Tulip Desktop, web): surface this as
# OSError so every accessory helper's missing-hardware path treats
# it like an absent device instead of crashing the sketch.
raise OSError("I2C unavailable: %s" % e)
return i2c

def ssd1327_oled():
Expand Down Expand Up @@ -937,7 +959,11 @@ def set_cv_out(channel=0, synth=1):
the CV waveform shape, frequency, etc.

Call set_cv_out(channel, synth=0) to clear the mapping.

No-op on boards without CV hardware (Tulip).
"""
if not cv_capable():
return
tulip.set_cv_synth(synth, channel + 1 if synth > 0 else 0)

def edit(filename=None):
Expand All @@ -949,7 +975,11 @@ def edit(filename=None):
pye()

def cv_out(volts, channel=0):
"""Output -10.0v to +10.0v (nominal) on CV1 (channel=0) or 2 (channel=1)"""
"""Output -10.0v to +10.0v (nominal) on CV1 (channel=0) or 2 (channel=1).

No-op on boards without CV hardware (Tulip)."""
if not cv_capable():
return
addr = 88 # GP8413
# With rev1 scaling, 0x0000 -> -10v, 0x7fff -> +10v
val = int(((volts + 10)/20.0) * 0x8000)
Expand All @@ -965,6 +995,9 @@ def cv_out(volts, channel=0):
get_i2c().writeto_mem(addr, ch, bytes([b0,b1]))

def cv_in(channel=0):
# Boards without CV hardware (Tulip) read a constant 0.
if not cv_capable():
return 0.0
return tulip.cv_in(channel)

# --- Web encoder emulation helpers (called from JS) ---
Expand Down
72 changes: 72 additions & 0 deletions tulip/shared/py/world.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,75 @@ def post_message(message):
_post_json("/api/tulipworld/messages", {"username": u, "content": message[:MAX_MESSAGE_SIZE]})
except Exception:
print("Could not post message.")


# A M Y B O A R D ~ W O R L D
# Sketches shared on amyboard.com. world.amyboard.download("eno_ambient", "dpwe")
# fetches the latest sketch.py by that name (username optional) into
# user/current/sketch.py and starts it the AMYboard way: synths reset,
# _auto_generated_knobs applied, loop() scheduled on the sequencer.
# Works on AMYboard and on Tulip — on Tulip the CV in/out calls in a sketch
# no-op, while I2C accessories (OLED display, rotary encoders) still work.

def _amyboard_sketch_filename(sketch):
return sketch if sketch.endswith(".py") else sketch + ".py"


class _AmyboardWorld:
def ls(self, count=10):
for f in _get_json("/api/amyboardworld/files", limit=count)["items"]:
name = f["filename"]
if name.endswith(".py"):
name = name[:-3]
print(
"% 20s % 10s % 8s %s"
% (
name,
f["username"][:MAX_USERNAME_SIZE],
nice_time(f["age_ms"]),
f["description"][:MAX_DESCRIPTION_SIZE],
)
)

def download(self, sketch, username=None, start=True):
filename = _amyboard_sketch_filename(sketch)
# Resolve via the files listing (q= narrows server-side, then exact
# filename match here): items come back newest-first, so the first
# hit is the latest revision.
got = None
try:
if username is None:
items = _get_json("/api/amyboardworld/files", q=filename, limit=50, latest_per_user_env="false")["items"]
else:
items = _get_json("/api/amyboardworld/files", q=filename, username=username, limit=50, latest_per_user_env="false")["items"]
for f in items:
if f["filename"].lower() == filename.lower():
got = f
break
except Exception:
got = None

if got is None:
if username is None:
print("Could not find %s on AMYboard World" % (filename))
else:
print("Could not find %s by %s on AMYboard World" % (filename, username))
return

import amyboard as _amyboard
env_dir = _amyboard.ensure_user_environment()
url = got["download_url"]
if not url.startswith("http"):
url = WORLD_API_BASE + url
r = requests.get(url)
r.save(env_dir + "/sketch.py")
print(
"Downloaded sketch %s by %s [%d bytes, last updated %s] from AMYboard World."
% (filename[:-3], got["username"], got["size"], nice_time(got["age_ms"]).lstrip())
)
if start:
print("Starting sketch. Stop it with amyboard.stop_sketch()")
_amyboard.restart_sketch()


amyboard = _AmyboardWorld()
80 changes: 80 additions & 0 deletions tulip/shared/py/world_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,83 @@ def clean_up(x):
os.remove(filename)

world_upload_file(pwd() + "/", filename, username, description).then(lambda x: clean_up(x))


# A M Y B O A R D ~ W O R L D
# Async version of world.amyboard for the web ports. See world.py for details:
# world.amyboard.download("eno_ambient", "dpwe") fetches the latest sketch.py
# into user/current/sketch.py and starts it via amyboard.restart_sketch().

class _AmyboardWorldWeb:
def ls(self, count=10):
def do_next(payload):
for f in payload.get("items", []):
name = f["filename"]
if name.endswith(".py"):
name = name[:-3]
print(
"% 20s % 10s % 8s %s"
% (
name,
f["username"][:MAX_USERNAME_SIZE],
nice_time(f["age_ms"]),
f["description"][:MAX_DESCRIPTION_SIZE],
)
)

return js.fetch(
world_api_url("/api/amyboardworld/files?limit=%d" % (count))
).then(lambda r: r.text()).then(lambda x: json.loads(x)).then(do_next)

def download(self, sketch, username=None, start=True):
filename = sketch if sketch.endswith(".py") else sketch + ".py"
# Resolve via the files listing (q= narrows server-side, then exact
# filename match below): items come back newest-first, so the first
# hit is the latest revision.
query = "/api/amyboardworld/files?limit=50&latest_per_user_env=false&q=%s" % (js.encodeURIComponent(filename))
if username is not None:
query = query + "&username=%s" % (js.encodeURIComponent(username))

def on_missing(_=None):
if username is None:
print("Could not find %s on AMYboard World" % (filename))
else:
print("Could not find %s by %s on AMYboard World" % (filename, username))

def on_got(got):
def save_finished(x):
import amyboard as _amyboard
env_dir = _amyboard.ensure_user_environment()
f = open(env_dir + "/sketch.py", "wb")
f.write(bytes(x))
f.close()
print(
"Downloaded sketch %s by %s [%d bytes, last updated %s] from AMYboard World."
% (filename[:-3], got["username"], got["size"], nice_time(got["age_ms"]).lstrip())
)
if start:
print("Starting sketch. Stop it with amyboard.stop_sketch()")
_amyboard.restart_sketch()

grab_url = got["download_url"]
if not str(grab_url).startswith("http"):
grab_url = world_api_url(grab_url)
return grab("urlget", url=grab_url).then(lambda x: save_finished(x))

def on_items(payload):
for f in payload.get("items", []):
if f["filename"].lower() == filename.lower():
return on_got(f)
on_missing()
return js.Promise.resolve(None)

def on_resp(resp):
if not resp.ok:
on_missing()
return js.Promise.resolve(None)
return resp.text().then(lambda x: on_items(json.loads(x)))

return js.fetch(world_api_url(query)).then(lambda resp: on_resp(resp))


amyboard = _AmyboardWorldWeb()
3 changes: 3 additions & 0 deletions tulip/web/variants/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
# runtime (with setTimeout an Promise's) to contrtol the scheduling.

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


Expand Down
Loading