Here you can see the API Tulip currently ships with.
NOTE: This page represents the APIs in the latest commit of our main branch. Builds for the Tulip hardware (tulip.upgrade()) and the macOS build of Tulip Desktop may lag behind these changes. Tulip Web should always be up to date with our main branch.
Tulip boots right into a Python prompt and all interaction with the system happens there. You have your own space to store code and files in /user and we keep system examples and programs in /sys. (On Tulip Desktop or Web, the sys folder is actually ../sys from where it boots.)
You can make your own Python programs with Tulip's built in editor and execute them, or just experiment on the Tulip REPL prompt in real time.
# Interact with the filesystem.
# Supported: ls, head, cat, newfile, cp, mv, rm, pwd, cd, mkdir, rmdir
ls
mkdir('directory')
cd('directory')
# Clears the REPL screen and formatting
clear
# If you want something to run when Tulip boots, add it to boot.py
edit("boot.py")
# You can upgrade the firmware over-the-air over wifi
tulip.upgrade()
# Takes a screenshot and saves to disk. The screen will blank for a moment
# If no filename given will upload to Tulip World (needs wifi)
tulip.screenshot("screenshot.png")
tulip.screenshot()
# You can optionally pass x,y,w,h to screenshot to only capture part of the screen
tulip.screenshot("middle.png", x=400,y=200,w=200,h=200)
# Return the current CPU usage (% of time spent on CPU tasks like Python code, sound, some display)
usage = tulip.cpu() # or use tulip.cpu(1) to show more detail in a connected UART
ms = tulip.ticks_ms() # returns the milliseconds since epoch, aka Arduino millis()
ms = tulip.amy_ticks_ms() # returns the audio engine's ms since boot
board = tulip.board() # Returns the board type, e.g. "TDECK", "N16R8" etcTulip can run different types of programs that you make or you can download from Tulip World. They range from simple Python scripts or modules, to full-screen "apps" with multitasking and UIs. You can edit and create these apps on Tulip itself using our editor, and upload them to Tulip World for others to use.
You can run any Python script in your current directory with execfile:
>>> execfile("hello.py")
Hello worldYou can also create Python libraries and import them from your current directory:
>>> import my_library
>>> my_library.do_something()
Doing itIf you have a program that relies on mulitple files (graphics, sounds or multiple Python files) you'll want to create a Tulip package. A package is just a folder with your files in it, like:
rabbit_game/
... rabbit_game.py # the main script should have the same name as the package
... extra.py # can put any other python files in here
... rabbit_pic.png
... rabbit_pic1.png
... rabbit_sample.wav
The main Python script must be the name of the package. This script needs to explicitly import tulip or amy or others if you are using those. Then, you and your users can start the package by run('rabbit_game') from the directory that has the folder in it. The package will be cleaned up after when they exit.
By default, a package is imported (for example, import rabbit_game.) If your rabbit_game.py has code that runs on import, it will run. If it has a def run(app): method, a UIScreen full screen window will be created that the user can switch to or quit.
We ship a couple of game-like examples, check them out:
The Tulip World BBS supports uploading and downloading packages as tar files: just world.upload('package', username) or world.download('package').
We put a few examples in /sys/ex, and if you run('app'), it will look in your current folder and the /sys/ex folder.
If you want your package to run alongside other apps, and show a task bar with a quit and app-switch button, you need to use a package that implements UIScreen. UIScreen's API is detailed below, but a simplest example is:
# my switchable program, program.py
def run(app):
# Setup my app
app.present() # I'm ready, show my appPut that in a package called program, and when run('program') is called, your app will start and show a task bar. Multitasking apps have to return immediately after setup (the run function) and rely on callbacks to process data and user input. We have callbacks for everything you'd need: keyboard input, MIDI input, music sequencer ticks and touch input. UIScreen also sets up callbacks for "activating" (switching to the app or first run), "deactivating" (switching away from the app) or quitting.
If you set your UIScreen up as a game (by setting app.game = True in your def run(app): before app.present()), it will handle things like clearing the sprites and BG, and making sure keypresses only go to the full screen window.
You can also hide the task bar for games by setting app.hide_task_bar=True. That means users will have to know to use control-Tab and control-Q to switch and quit from your game.
UIScreen apps should use LVGL/tulip.UIX classes for their UI, so that the UI appears and disappears automatically during switching. This is especially important on Tulip CC hardware, where we ensure the UI switching drawing does not interrupt music or other time sensitive callbacks. You can also use other Tulip drawing commands for the UI, but be mindful that the BG (and often TFB) will be cleared on switching away from your app, so you'll have to redraw those on your activate callback. If you have a game mode on, the deactivate callback will clear the BG and sprite layer for you.
The REPL itself is treated as a (special) multitasking app, always first in the list and cannot be quit.
You can switch apps with the keyboard: control-tab, and quit apps with control-Q.
We ship a few examples of multitasking apps, please check them out here:
On your Tulip, you can find these in editable form as my_X, for example, /sys/ex/my_drums.py. This lets you edit the drum machine. The original one is read-only and always baked into Tulip, so it can't be harmed.
Please see the music tutorial for a tutorial on UIScreen.
Still very much early days, but Tulip supports a native chat and file sharing BBS called TULIP ~ WORLD where you can hang out with other Tulip owners. You're able to pull down the latest messages and files and send messages and files yourself.
Try it out with run('worldui'). You'll first want to run world.username="my_name" to choose a username.
You can also call the underlying Tulip World APIs:
# On Tulip Web, you should use world_web
if(tulip.board()=="WEB"):
import world_web as world
else:
import world
messages = world.messages(n=500, mtype='files') # returns a list of latest files (not unique ones)
messages = world.messages(n=100, mtype='text') # returns a list of latest chat messages
# On Tulip web, you can't assign the output of messages.
# If you want to do somethign other than print them, use your own done callback:
world.messages(n=25, done=do_something)
# When posting messages or files you set a username, minimum 1 character, maximum 10
world.post_message("hello!!") # Sends a message to Tulip World. username required. will prompt if not set.
world.upload(filename, description) # Uploads a file to Tulip World. username required. description optional (25 characters)
world.upload(folder, description) # Packages a folder and uploads it to Tulip World as a package
world.download(filename) # Downloads the latest file named filename from Tulip World if it exists
world.download(filename, username) # Downloads the latest file named filename from username from Tulip World if it exists
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)Big note: Tulip World is hosted by a bot running on the Tulip/AMY/Alles Discord. 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 ships with a text editor, based on pico/nano. It supports syntax highlighting, search, save/save-as.
# Opens the Tulip editor to the given filename.
# Control-X saves the file, if no filename give will prompt for one.
# Control-O is save as -- will write to new filename given
# Control-W searches
# Control-R prompts for a filename to read into the current buffer
edit("game.py")
edit() # no filenameWe include LVGL 9 for use in making your own user interface. LVGL is optimized for constrained hardware like Tulip. You can build nice UIs with simple Python commands. You can use LVGL directly by simply import lvgl and setting up your own widgets. Please check out LVGL's examples page for inspiration. (As of this writing, their Python examples have not been ported to our version of LVGL (9.0.0) but most things should still work.)
It's best to build user interfaces inside a UIScreen multitasking Tulip package. Our UIScreen will handle placing elements on your app and dealing with multitasking.
For more simple uses of LVGL, like buttons, sliders, checkboxes and single line text entry, we provide wrapper classes like UICheckbox, UIButton, UISlider, UIText, and UILabel. See our fully Python implementation of these in ui.py for hints on building your own UIs. Also see our buttons.py example, or more complete examples like drums, juno6, wordpad etc in /sys/ex.
Tulip apps that support multitasking are called UIScreens and they wrap functionality for adding UI elements and switching between apps. A Tulip package tries to run(screen) in your main Python file, and if it exists, will expect the run(screen) function to exit quickly and hand over control to various callbacks. This allows multiple apps to work at the same time. It's especially useful for music apps that share the sequencer and MIDI callbacks.
By default a UIScreen is created for you when you run(app), presuming app.py in the package has a def run(screen): function. The UIScreen object is passed into screen. You can treat that object as your app's global state, and also set and get various parameters of the app:
def run(screen):
# These are all the defaults:
screen.bg_color = 0 # tulip color of the screen BG
screen.keep_tfb = False # whether to hide the TFB while running the app
screen.offset_y = 100 # by default, screens "start" at 0,100 to leave room for the task bar
screen.activate_callback = None # called when the app starts and when it is switched to
screen.deactivate_callback = None # called when you switch away from the app
screen.quit_callback = None # called when the quit button is pressed. Note: deactivate_callback is called first on quit
screen.handle_keyboard = False # if you set up UI components that accept keyboard input
screen.group.set_style_text_font(lv.font_tulip_11,0) # Set the default font for the entire app if you want
# Set up your UI with screen.add(), adding UIElement objects
screen.add(tulip.UILabel("hello there"), x=500,y=100)
# You can use LVGL alignment to add objects in relation to the last object added
# See https://docs.lvgl.io/master/widgets/obj.html for a listing of aligns
screen.add(tulip.UILabel("under that one"), direction=lv.ALIGN.BOTTOM_MID)
# When you're ready, do
screen.present()
def quit(screen):
# your quit callback gets a screen object, use it to shut down
def activate(screen):
# use this to re-draw anything explicitly. LVGL components added with add() will automatically appearTulip UIScreen apps should never wait in a loop or call sleep. They should rely on callbacks to do all their work. For example, our drum machine waits for the sequencer callback to play the next note. Our editor app relies on the keyboard callback for the next keypress. This allows Tulip to run multiple programs at once.
See some examples of more complex UIs using UIScreen:
If you want to edit these programs on Tulip, find editable versions in /sys/ex/my_X.py, like /sys/ex/my_drums.py.
You can see running multitasking apps with tulip.running_apps, which is a dict by app name. This lets you set or inspect parameters of running apps. tulip.repl_screen always returns the REPL UIScreen. You can programtically switch apps with e.g. tulip.app('drums'). The current running UIScreen is tulip.current_uiscreen().
>>> tulip.running_apps['voices'].piano_y
320
>>> tulip.repl_screen.bg_color
9You can summon a touch keyboard with tulip.keyboard(). Tapping the keyboard icon dismisses it, or you can use tulip.keyboard() again to remove it.
We boot a launcher for common operations. It's available via the small grey icon on the bottom right.
For LVGL fonts, you can use default LVGL montserrat fonts, e.g. font=lv.font_montserrat_12, or the built in Tulip BG fonts, e.g. font=lv.tulip_font_13.
tulip.keyboard() # open or close the soft keyboard
tulip.launcher() # open or close our launcher
# You're free to use any direct LVGL calls. It's a powerful library with a lot of functionality and customization, all accessible through Python.
import lvgl as lv
# our tulip.lv_scr is the base screen on bootup, to use as a base screen in LVGL.
calendar = lv.calendar(lv.current_screen())
calendar.set_pos(500,100)
# use our tulip.UIX classes to add simple UI elements to your app.
# UISlider: draw a slider
# bar_color - the color of the whole bar, or just the set part if using two colors
# unset_bar_color - the color of the unset side of the bar, if None will just be all one color
# handle_v_pad, h_pad -- how many px above/below / left/right of the bar it extends
# handle_radius - 0 for square
screen.add(tulip.UISlider(val=0, w=None, h=None, bar_color=None, unset_bar_color=None,
handle_color=None, handle_radius=None, handle_v_pad=None, handle_h_pad=None, callback=None))
# UIButton: push button with text
screen.add(tulip.UIbutton(text=None, w=None, h=None, bg_color=None, fg_color=None,
font=None, radius=None, callback=None))
# UILabel: text
screen.add(tulip.UILabel(text="", fg_color=None, w=None, font=None))
# UIText: text entry
screen.add(tulip.UIText(text=None, placeholder=None, w=None, h=None,
bg_color=None, fg_color=None, font=None, one_line=True, callback=None))
# UICheckbox
# Optionally draw a label next to the checkbox
screen.add(tulip.UICheckbox(text=None, val=False, bg_color=None, fg_color=None, callback=None))See our buttons.py example for UIX class use.
You can set up a tabbed UI in a UIScreen with our TabView class. It's set up to act like a mini UIScreen, where you can add elements.
def run(screen):
# This will create a TabView in the UIScreen, on the left, with three tabs
tabview = ui.TabView(screen, ["tab1", "tab2", "tab3"], size=80, position = lv.DIR.LEFT)
# Create any UIElement
bpm_slider = tulip.UISlider(tulip.seq_bpm()/2.4, w=300, h=25,
callback=bpm_change, bar_color=123, handle_color=23)
# Add it to the tab you want, same API as UIScreen.add()
tabview.add("tab2", bpm_slider, x=300,y=200)
screen.present()Tulip supports USB keyboard input, USB mouse input, and touch input. It also supports a software on-screen keyboard, and any I2C connected keyboard or joystick on Tulip CC. On Tulip Desktop and Tulip Web, mouse clicks act as touch points, and your computers' keyboard works.
If you have a USB mouse connected to Tulip (presumably through a hub) it will, by default, show a mouse pointer and treat clicks as touch downs.
# Returns a mask of joystick-like presses from the keyboard, from arrow keys, Z, X, A, S, Q, W, enter and '
tulip.joyk()
# test for joy presses. Try UP, DOWN, LEFT, RIGHT, X, Y, A, B, SELECT, START, R1, L1
if(tulip.joyk() & tulip.Joy.UP):
print("up")
# Returns the current held keyboard scan codes, up to 6 and the modifier mask (ctrl, shift etc)
(modifiers, scan0, scan1... scan5) = tulip.keys()
# Gets a key ascii code
(char, scan, modifier) = tulip.key_wait() # waits for a key press, returns scan code and modifier too
ch = tulip.key() # returns immediately, returns -1 if nothing held
# If scanning key codes in a program, you may want to turn on "key scan" mode so that
# keys are not sent to the underlying python process
tulip.key_scan(1)
tulip.key_scan(0) # remember to turn it back off or you won't be able to type into the REPL
# If you need to remap keys on your keyboard (we default to US)
tulip.remap() # interactive, can write to your boot.py for you
tulip.key_remap(scan_code, modifier, target_cp437_code)
# You can also register a keyboard callback. Useful for full screen apps that share with others
# there can only be one keyboard callback running.
tulip.keyboard_callback(key)
def key(k):
print("got key: %d" % (key))
tulip.keyboard_callback() # removes callbacks.
# Return the last touch panel coordinates, up to 3 fingers at once
(x0, y0, x1, y1, x2, y2) = tulip.touch()
# Modify the touch screen calibration if needed (on Tulip CC only)
# Run ex/calibrate.py to determine this for your panel
tulip.touch_delta(-20, 0, 0.8) # -20 x, 0 y, 0.8 y scale
tulip.touch_delta() # returns current delta
# Set up a callback to receive raw touch events. up == 1 when finger / mouse lifted up
def touch_callback(up):
t = tulip.touch()
print("up %d points x1 %d y1 %d" % (up, t[0], t[1]))
tulip.touch_callback(cb)Tulip hardware has a I2C port on the side for connecting a variety of input or output devices. We currently support the following:
- Mabee DAC (up to 10V) - use
import mabeedac; mabeedac.set(volts, channel)- see the CV control section in the sound documentation as well - ADC (up to 12V) - use
import m5adc; m5adc.get() - DAC (single channel, up to 3.3V) - use
import m5dac; m5dac.set(volts) - DAC2 (dual channel, up to 10V) - use
import m5dac2; m5dac.set2(volts, channel) - CardKB keyboard - use
import m5cardkb, which will automatically let your cardKB be a keyboard in Tulip. Put this in yourboot.pyfor using it at startup. - 8-encoder knobs - use
import m5_8encoder, see the m5_8encoder.py file for more - 8-angle knobs - use
import m58angle; m58angle.get(ch) - Digiclock 7-segment clock - use
import m5digiclock; m5digiclock.set('ABCD') - Joystick - use
import m5joy; m5joy.get() - Extend GPIO - use
import m5extend; m5extend.write_pin(pin, val); m5extend.read_pin(pin)
Tulip CC has the capability to connect to a Wi-Fi network, and Python's native requests library will work to access TCP and UDP. We ship a few convenience functions to grab data from URLs as well.
# Join a wifi network (not needed on Tulip Desktop or Web)
tulip.wifi("ssid", "password")
# Get IP address or check if connected
ip_address = tulip.ip() # returns None if not connected
# Save the contents of a URL to disk (needs wifi)
bytes_read = tulip.url_save("https://url", "filename.ext")
# Get the contents of a URL to memory (needs wifi, and be careful of RAM use)
content = tulip.url_get("https://url")
# Upload a URL to a PUT API. Used in our file_server.py
tulip.url_put(url, "filename.ext")
# Set the time from an NTP server (needs wifi)
tulip.set_time() We ship asyncio and also provide a simpler tulip.defer() callback to schedule code in the future.
import asyncio
async def sleep(sec):
await asnycio.sleep(sec)
print("done")
asyncio.run(sleep(5))
def hello(t):
print("hello called with arg %d" % (t))
tulip.defer(hello, 123, 1500) # will be called 1500ms laterTulip comes with the AMY synthesizer, a very full featured 120-oscillator synth that supports FM, PCM, subtractive and additive synthesis, partial synthesis, filters, and much more. See the AMY documentation for more information, Tulip's version of AMY comes with stereo sound, chorus and reverb. It includes a "small" version of the PCM patch set (29 patches) alongside all the Juno-6 and DX7 patches. It also has support for loading WAVE files in Tulip as samples.
Once connected to Wi-Fi, Tulip can also control an Alles mesh. Alles is a wrapper around AMY that lets you control the synthesizer over Wi-Fi to remote speakers, or other computers or Tulips. Connect any number of Alles speakers to the wifi to have instant surround sound! See the Alles getting started tutorial for more information and for more music examples.
Tulip can also route AMY signals to CV outputs connected over Tulip CC's I2C port. You will need one or two Mabee DACs or similar GP8413 setup. This lets you send accurate LFOs over CV to modular or other older analog synthesizers.
See the music tutorial for a LOT more information on music in Tulip.
We provide a wrapper on AMY that manages synthesizers you can allocate. These handle voice stealing and finding oscillators for the underlying synth patches. They're recommended to use for most use cases. If you need more direct control, you can use AMY.
You can use synth.PatchSynth to create a synthesizer based on our built-in patches. 0-127 are Juno-6 patches, 128-255 are DX-7 patches, 256 is a piano. You can create your own patches as well.
syn = synth.PatchSynth(num_voices=2, patch=143) # two note polyphony, patch 143 is DX7 BASS 2If you want to play multimbral tones, like a Juno-6 bass alongside a DX7 pad:
synth1 = synth.PatchSynth(num_voices=1, patch=0) # Juno
synth2 = synth.PatchSynth(num_voices=1, patch=128) # DX7
synth1.note_on(50, 1)
synth2.note_on(50, 0.5)
synth1.note_off(50)The OscSynth synth lets yo directly control parameters of an AMY oscillator as a managed synth:
syn = synth.OscSynth(wave=amy.PCM, patch=10) # PCM wave type, patch=10 (808 Cowbell)You can use OscSynth and amy.load_sample to load samples from WAV files on Tulip storage:
amy.load_sample('sample.wav', patch=50)
s = synth.OscSynth(wave=amy.PCM, patch=50)
s.note_on(60, 1.0)Use syn.release() to free up the resources for a synth.
You can use amy.py to control the AMY synthesizer directly.
amy.drums() # plays a test song
amy.volume(4) # change volume
amy.reset() # stops all music / sounds playing
amy.send(voices='0', load_patch=129, note=45, vel=1) # plays a tone
amy.send(voices='0', pan=0) # set to the right channel
amy.send(voices='0', pan=1) # set to the left channel
# start mesh mode (control multiple speakers over wifi)
# once mesh mode is set, you can't go back to local mode until you restart Tulip.
alles.mesh() # after turning on wifi. tulip itself will stop playing AMY messages.
alles.mesh(local_ip='192.168.50.4') # useful for setting a network on Tulip Desktop
alles.map() # returns booted Alles synths on the mesh
amy.send(voices='0', load_patch=101, note=50, vel=1) # all Alles speakers in a mesh will respond
amy.send(voices='0', load_patch=101, note=50, vel=1, client=2) # just a certain clientTo load your own WAVE files as samples you can play like an instrument, use amy.load_sample:
# To save space / RAM, you may want to downsample your WAVE files to 11025 or 22050Hz. We detect SR automatically.
amy.load_sample("flutea4.wav", patch=50) # samples are converted to mono if they are stereo. patch # can be anything
# You can optionally tell us the loop start and end point (in samples), and base MIDI note of the sample.
# We can detect this in WAVE file metadata if it exists! (Many sample packs include this.)
amy.load_sample("flutea4.wav", midinote=81, loopstart=1020, loopend=1500, patch=50)
# The patch number can now be used in AMY's PCM sample player.
amy.send(osc=20, wave=amy.PCM, patch=50, vel=1, note=50)
# You can unload already allocated patches:
amy.unload_sample(patch) # frees the RAM and the patch slot
amy.reset() # frees all allocated PCM patchesOn Tulip Desktop or Web, or with an AMYboard / AMYchip connected to a hardware Tulip over I2C, you can use audio input as well. This is brand new and we're still working out a good API for it. For now, you can set any oscillator to be fed by the L or R channel of an audio input.
amy.send(osc=0, wave=amy.AUDIO_IN0, vel=1)
amy.echo(1, 250, 500, 0.8) # echo effect on the audio inputTo send signals over CV on Tulip CC (hardware only):
amy.send(osc=100, wave=amy.SAW_DOWN, freq=2.5, vel=1)
tulip.amy_set_external_channel(100, 1) # osc, channel
# external_channel = 0 - no CV output, will route to audio (default)
# external_channel = 1 - 1st channel of the first connected GP8413 / dac
# external_channel = 2 - 2nd channel of the first connected GP8413
# external_channel = 3 - 1st channel of the second connected GP8413
# external_channel = 4 - 2st channel of the second connected GP8413
# Or just send CV signals directly using the mabeedac library:
import mabeedac
mabeedac.send(volts, channel=0)Tulip also ships with our own music.py, which lets you create chords, progressions and scales through code:
import music
chord = music.Chord("F:min7")
for i,note in enumerate(chord.midinotes()):
amy.send(wave=amy.SINE,osc=i*9,note=note,vel=0.25)PLEASE NOTE -- LOW LEVEL SAMPLE ACCESS DOES NOT CURRENTLY WORK, BUT WE ARE WORKING ON IT
You can access the audio input (AUDIO_IN0/1) sample buffer per frame (on Tulip Desktop, Web, and future devices with audio input support), and you can also set two external audio channels (AUDIO_EXT0/1) from Python. This lets you synthesize audio in Python, or do things like stream WAV files from disk to the audio output.
To do so, you need to register an AMY frame callback in Tulip. In this example, we open a WAV file and read it 256 frames per block, and set those frames to the EXT0/1 oscillators, which we initialize as AMY oscillators 0 and 1, with their pan set to left and right.
# Play a wav file through AMY, streaming from disk
import amy_wave
f = amy_wave.open(wav_filename,'rb')
def cb(x):
frames = f.readframes(256)
if(len(frames)!=1024): # file done. stop the AMY frame callback.
frames = bytes(1024)
tulip.amy_block_done_callback()
# Sets the stereo channel buffer EXT0/EXT1 from the frames bytes
tulip.amy_set_external_input_buffer(frames)
amy.reset()
amy.send(osc=0,wave=amy.AUDIO_EXT0, pan=0, vel=1)
amy.send(osc=1,wave=amy.AUDIO_EXT1, pan=1, vel=1)
tulip.amy_block_done_callback(cb)To sample incoming audio (on devices that support it), use tulip.amy_get_input_buffer:
buf = bytes()
tick_start = 0
ms = 2000
def sample(x):
global buf
buf = buf + tulip.amy_get_input_buffer()
# stop "recording" to buf after ms
if(tulip.ticks_ms() > tick_start + ms):
tulip.amy_block_done_callback()
play()
tick_start = tulip.ticks_ms()
print("Recording for 2s. Make sure audio input is on!")
tulip.amy_block_done_callback(sample)
# Then buf will have stereo frames of audio to do whatever you want with.Please note: on Tulip CC hardware, you do not have much compute time left per block to do much. Reading files, saving to memory or doing simple synthesis works, but most anything more complicated you should write your effects / synthesis code in C, as part of AMY.
Tulip is always running AMY's live sequencer, which allows you to have multiple music programs running sharing a common clock. You can use seq = sequence.AMYSequence(length, divider) and then seq.add(offset, function, args) to control an AMY sequence.
A sequence in AMY is defined as a length and divider. The divider is set as the musical note length's denominator. If you want this sequence to be a pattern of events, you can specify that in length, which indicates how many of those events happen in a loop. For an example of a 16 position 1/8th note drum machine, length is 16 and divider is 8. For a 8 note long quarter note pattern, length is 8 and divider is 4.
If you want repeating events but don't care about a pattern, you can set length to 1. The sequence will just repeat at the given divider note length. For example, if you want a thing to happen every 32nd note, you'd choose a length of 1 and a divider of 32.
You can also set length to 0, which lets you address ticks in absolute time. This is useful for non-repeating sequencing, like a MIDI event recorder: just set the divider to whatever note length you want, and set length to 0: AMYSequence(0,8). Then you can add events to the sequence in absolute note lengths from the start.
You can set divider from 1 up to 192 and length can be any number you want. You can have multiple sequences running at once, each with different dividers and lengths.
You can only sequence AMY music events (MIDI, note ons, synth, amy.send, parameter changes) with the AMY sequencer.
To use the music sequencer, use seq = sequencer.AMYSequence(length, divider). Then add new events using seq.add(position, function, [args]). position is the position within the pattern (or any future position, if length is 0) to schedule function in. In the drum machine example, you set up a pattern of 16 1/8th notes, so index 0 would be the first hit, and 15 the last). You lastly pass whatever arguments you want to give to that function. synth.note_on takes 2 - a note number and a velocity. You can optionally pass other parameters like pan=0.1 as keyword arguments. seq.add() returns the event that was added. You can keep this event around to later update or remove an individual event. e = seq.add(0, func) can then be used to update the sequence with a new function: e.update(0, new_func) or remove it with: e.remove().
To schedule any Python function in time with the music sequencer, for example, if you want to update the display to show a LED animation as a drum pattern plays, you can use sequence.TulipSequence(divider). You can only have up to 8 TulipSequences overall in Tulip, so your app should only use one -- if your app wants to sequence arbitrary Python, set up a single sequence_callback at the divider you want. The clock is shared between TulipSequence and AMYSequence. For example, if your drum machine is AMYSequence(16, 8), use TulipSequence(8) for your graphical update code -- it will be called every 1/8th note, in time with the drum pattern.
See how we do this in the drums app.
To use the Tulip sequencer, use seq = sequence.TulipSequence(divider, func). func will be called every divider, in time with the AMY sequencer. You can stop it with seq.clear().
Here's an example of using both sequencers:
import sequencer
syn = synth.PatchSynth(1, 0) # make a synthesizer to control
arp_notes = [48,50,52,49,56,58,60,57]
def print_every_other_note(x):
print("hit! %d" %(x))
music_seq= sequencer.AMYSequence(16, 8) # 1/8th notes, 16 of them
# Every 1/8th note print the current tick
print_seq= sequencer.TulipSequence(8, print_every_other_note) # every 1/8th note
for i in range(16):
# At index i, schedule a note on for the synth, with parameters (arp_notes[i%8], 1)
music_seq.add(i, syn.note_on, [arp_notes[i%8], 1])
def stop():
music_seq.clear() # Removes all scheduled notes from this sequence
print_seq.clear() # Removes all scheduled events from this sequence
syn.release() # Stops the synthYou can set or see the system-wide BPM (beats, or quarters per minute) with AMY's sequencer.tempo(120)
See the music tutorial for a LOT more information on music in Tulip.
Via AMY, Tulip supports MIDI in and out to connect to external music hardware. You can set up a python callback to respond immediately to any incoming MIDI message. You can also send messages out to MIDI out.
You can use MIDI over serial (the 3.5mm connectors on Tulip CC) or USB as well, using the USB-KB connector. Note this USB is meant as a host connector: you can connect USB MIDI keyboards or USB MIDI interfaces to Tulip. You cannot connect Tulip directly to a computer as a "USB MIDI gadget". If you want your Tulip to control your computer, use a MIDI interface on your computer and wire Tulip's MIDI out to it.
If you have a USB MIDI adapter connected, MIDI out from Tulip will go to both USB and TRS MIDI connectors. MIDI in can come into either TRS or USB.
By default, Tulip boots into AMY's live MIDI synthesizer mode. Any note-ons, note-offs, program changes or pitch bend messages will be processed automatically with polyphony and voice stealing, and Tulip will play the tones with no other user intervention needed.
By default, MIDI notes on channel 1 will map to Juno-6 patch 0. And MIDI notes on channel 10 will play the PCM samples (like a drum machine).
You can adjust which voices are sent with midi.config.add_synth(channel, patch_number, num_voices). For example, you can have Tulip play DX7 patch 129 on channel 2 with midi.config.add_synth(channel=2, patch_number=129, num_voices=1). channel=2 is a MIDI channel (we use 1-16 indexing), patch_number=129 is an AMY patch number, num_voices=1 is the number of voices (polyphony) you want to support for that channel and patch.
(A good rule of thumb is Tulip CC can support about 6 simultaneous total voices for Juno-6, 8-10 for DX7, and 20-30 total voices for PCM and more for other simpler oscillator patches.)
These mappings will get reset to default on boot. If you want to save them, put add_synth commands in your boot.py.
You can set up your own MIDI callbacks in your own programs. You can call midi.add_callback(function), which will call your function with a list of a (2 or 3-byte) MIDI message. These callbacks will get called alongside the default MIDI callback (that plays synth notes on MIDI in).
On Tulip Desktop, MIDI works on macOS 11.0 (Big Sur, released 2020) and later ports using the "IAC" MIDI bus. (It does not yet work at all on Linux or Windows.) This lets you send and receive MIDI with Tulip to any program running on the same computer. If you don't see "IAC" in your MIDI programs' list of MIDI ports, enable it by opening Audio MIDI Setup, then showing MIDI Studio, double click on the "IAC Driver" icon, and ensure it is set to "Device is online."
Tulip Desktop macOS's SYSEX handling only works on macOS 14.0 (Sonoma, released 2023) and later.
On Tulip Web, MIDI (including SYSEX) "just works" in many browsers, but not Safari.
You can also send MIDI messages "locally", e.g. to a running Tulip program that is expecting hardware MIDI input, via tulip.midi_local()
def callback(m):
if(m[0]==144):
print("Note on, note # %d velocity # %d" % (m[1], m[2]))
midi.add_callback(callback)
midi.remove_callback(callback) # turns off callback
def callback(message):
print(message[0]) # first byte of MIDI in message
tulip.midi_out((144,60,127)) # sends a note on message
# tulip.midi_out(bytes) # Can send bytes or list
tulip.midi_local((144, 60, 127)) # send note on to local busExternal MIDI realtime sync to Tulip's sequencer is off by default.
Use tulip.external_midi_sync(x) to control this:
tulip.external_midi_sync(False) # default: ignore external MIDI realtime clock/start/stop
tulip.external_midi_sync(True) # enable external MIDI realtime syncWhen enabled:
- MIDI
F8(Timing Clock) drives external tempo sync for the sequencer. - MIDI
FA(Start) starts the sequencer. - MIDI
FC(Stop) stops the sequencer.
Tulip has special handling for MIDI sysex messages. Because of Tulip's memory constraints, we do not return SYSEX messages in tulip.midi_in(). We do always parse SYSEX messages for AMY-over-SYSEX, this allows you to send AMY wire messages over MIDI.
If you want to receive and parse MIDI sysex messages in Tulip, set a midi.sysex_callback. Like so:
def scb(message):
print("Received sysex message of %d bytes" % (len(message)))
midi.sysex_callback = scbThen, any MIDI SYSEX message will call this function with the message as the parameter. We limit SYSEX messages to 16KB at a time.
If you do not set a sysex_callback, we will not parse any SYSEX messages other than AMY-over-SYSEX. Your midi_in function will never receive SYSEX messages.
To send SYSEX messages, just use midi_out like normal: tulip.midi_out([0xf0, 0x01, 0x02, 0x03, 0xf7]).
See the music tutorial for a LOT more information on music in Tulip.
You can send an AMY message over MIDI on Tulip. This allows you to control another AMY device over a MIDI connection (USB or UART). You can easily route any AMY messages over MIDI SYSEX using midi.sysex_amy:
amy.override_send = midi.sysex_amy
amy.reset()
amy.send(osc=0, vel=1, freq=440) # will send this message over SYSEXAny connected AMY device (AMYboard, Tulip, Python on a computer) will respond to this message.
The Tulip GPU consists of 3 subsystems, in drawing order:
- A bitmap graphics plane (BG) (default size: 1024+128 x 600+100), with scrolling x- and y- speed registers. Drawing shape primitives and UI elements draw to the BG.
- A text frame buffer (TFB) that draws 8x12 fixed width text on top of the BG, with 256 colors
- A sprite layer on top of the TFB (which is on top of the BG). The sprite layer is fast, doesn't need to have a clear screen, is drawn per scanline, can draw bitmap color sprites.
The Tulip GPU runs at a fixed FPS depending on the resolution and display clock. You can change the display clock but will limit the amount of room for sprites and text tiles per line. The default for Tulip CC is 28Mhz, which is 34FPS. This is a great balance of speed and stability for text -- the editor and REPL.
You can set a python callback for the frame done interrupt, for use in games or animations.
# returns current GPU usage computed on the last 100 frames, as a percentage of max
usage = tulip.gpu()
# returns current FPS, based on the display clock
fps = tulip.fps()
# resets all 3 GPU systems back to their starting state, clears all BG and sprite ram and clears the TFB.
tulip.gpu_reset()
# Get or set the display clock in MHz. Current default is 18.
# Higher clocks mean smoother animations, but less time for the CPU to prepare things to draw
clock = tulip.display_clock()
tulip.display_clock(mhz)
# Convenience function for getting the screen width and height,
# which are just the first two values returned by tulip.timing()
(WIDTH, HEIGHT) = tulip.screen_size()
# if the display clock gets in a strange state, you can restart it by just
tulip.display_restart() # does not clear any data like gpu_reset()
# You can also manually stop and start the display. This is useful if you want to do something intensive
# that requires the resources of the GPU as well as the CPU, or want to do faster disk access
tulip.display_stop() # Tulip will still run
tulip.display_start()
# Sets a frame callback python function to run every frame
# See the game mode in UIScreen for an easier way to make games
game_data = {"frame_count": 0, "score": 0}
def game_loop(data):
update_inputs(data)
check_collisions(data)
do_animation(data)
update_score(data) # etc
data["frame_count"] += 1
tulip.frame_callback(game_loop, game_data) # starts calling the callback every frame
tulip.frame_callback() # disables the callback
# Sets the screen brightness, from 1-9 (9 max brightness.) 5 is default.
tulip.brightness(5)
# Show the GPU usage (frames per second, time spent in GPU) at the next GPU epoch (100 frames) in stderr
tulip.gpu_log()The default background plane (BG) is 1024 + 128 x 600 + 100, with the visible portion 1024x600. (You can change this with tulip.timing().) Use the extra for double buffering, hardware scrolling or for storing bitmap data "offscreen" for later blitting (you can treat it as fixed bitmap RAM.) The BG is drawn first, with the TFB and sprite layers drawn on top.
The UI operations (LVGL or anything in tulip.UI) also draw to the BG. Be careful if you're using both BG drawing operations and LVGL as they may draw on top of one another.
Tulip uses RGB332, with 256 colors. Here's the palette:
# Set or get a pixel on the BG
pal_idx = tulip.bg_pixel(x,y)
tulip.bg_pixel(x,y,pal_idx) # pal_idx is 0-255 for 8-bit RGB332 mode
# Convert between packed palette color and r,g,b
pal_idx = tulip.color(r,g,b)
(r,g,b) = tulip.rgb(pal_idx)
# Set the contents of a PNG file on the background.
# To save RAM and disk space, I recommend converting your PNG to 255 colors before moving to Tulip
# Imagemagick does this with: convert input.png -colors 255 output.png
# Or with dithering: convert input.png -dither FloydSteinberg -colors 255 output.png
png_file_contents = open("file.png", "rb").read()
tulip.bg_png(png_file_contents, x, y)
# Or use the png filename directly
tulip.bg_png(png_filename, x, y)
# Copy bitmap area from x,y of width,height to x1, y1
tulip.bg_blit(x,y,w,h,x1, y1)
# If you give blit an extra parameter it will not copy over alpha color (0x55), good for blending BG images
tulip.bg_blit(x,y,w,h,x1, y1, 1)
# Sets or gets a rect of the BG with bitmap data (RGB332 pal_idxes)
tulip.bg_bitmap(x, y, w, h, bitmap)
bitmap = tulip.bg_bitmap(x, y, w, h)
# Clear the BG with a color or default
tulip.bg_clear(pal_idx)
tulip.bg_clear() # uses default
# Drawing primitives. These all write to the BG.
# If you want to use them for sprites, you can use bg_bitmap after drawing offscreen.
# set filled to 1 if you want the shape filled, 0 or omit otherwise
tulip.bg_line(x0,y0, x1,y1, pal_idx, [width])
tulip.bg_bezier(x0,y0, x1,y1, x2,y2, pal_idx)
tulip.bg_circle(x,y,r, pal_idx, filled) # x and y are the center
tulip.bg_roundrect(x,y, w,h, r, pal_idx, filled)
tulip.bg_rect(x,y, w,h, pal_idx, filled)
tulip.bg_triangle(x0,y0, x1,y1, x2,y2, pal_idx, filled)
tulip.bg_fill(x,y,pal_idx) # Flood fill starting at x,y
tulip.bg_str(string, x, y, pal_idx, font) # same as char, but with a string. x and y are the bottom left. font is a number, 0-18
tulip.bg_str(string, x, y, pal_idx, font, w, h) # Will center the text inside w,h
"""
Set scrolling registers for the BG.
line is visible line number (0-599).
x_offset sets x position pixels to offset for that line (default is 0)
y_offset sets y position pixels to offset for that line (default is the line number)
x_speed is how many pixels a frame to add to x_offset (default is 0)
y_speed is how many pixels a frame to add to y_offset (default is 0)
For example, to scroll the BG up two pixels a frame
for i in range(600):
tulip.bg_scroll(i, 0, i, 0, -2)
"""
tulip.bg_scroll(line, x_offset, y_offset, x_speed, y_speed)
tulip.bg_scroll() # resets to default
# Change individual registers
tulip.bg_scroll_x_speed(line, x_speed)
tulip.bg_scroll_y_speed(line, y_speed)
tulip.bg_scroll_x_offset(line, x_offset)
tulip.bg_scroll_y_offset(line, y_offset)
# "Swap" the visible BG with the one to its right, using the scrolling registers
# This would make 1024,0 the top left BG pixel after the first call to swap, and 0,0 after the second call to swap
tulip.bg_swap()There are three types of fonts built into Tulip.
- TFB fonts: we ship 3 fixed-size fonts for the TFB (see below). You can switch them at runtime with
tulip.tfb_font(). - LVGL fonts: LVGL ships a few fonts like
lv.font_montserrat_12. These fonts have more glyphs, can handle some unicode characters, and also ship with symbols (like the ones we show in the Tulip launcher). - Tulip fonts: we ship 19 fonts to use with
bg_stretc, and they can also be used in LVGL widgets by referencing them likelv.tulip_font_13.
The TFB supports 3 built-in fixed-width fonts, switchable at runtime with tulip.tfb_font(x):
0: default 8x12 font1: small 6x8 font2: big 12x16 font
The TFB is a character plane for fast text drawing. The visible row/column count depends on the selected font size. It supports 256 ANSI colors for foreground and background, and supports formatting. TFB is used by the text editor and Python REPL.
# Sets a string / gets a character and/or format to the text frame buffer (TFB)
# (default geometry is 128x50 with font 0; changes with other TFB font sizes)
# Format has ANSI codes for reverse (0x80), underline (0x40), flash (0x20), bold (0x10)
# fg color is palette index, 0-255, same for bg color
# Note that the REPL and editor use the TFB
tulip.tfb_str(x,y, "string", [format], [fg], [bg])
(char, format, fg, bg) = tulip.tfb_str(x,y)
# ANSI color and formatting codes have convenience functions
print(tulip.Colors.LIGHT_RED + "this is red " + tulip.Colors.GREEN + tulip.Colors.INVERSE + " and then green inverse")
# To reset ANSI formatting
print(tulip.Colors.DEFAULT)
# Tulip REPL supports ANSI 256 color modes as well
print(tulip.ansi_fg(56))
# You can also stop or start the TFB. It will maintain what is on screen in memory, and you can still read/write it
tulip.tfb_stop()
tulip.tfb_start()
# If you want to keep the existing TFB around, you can save it to a temporary buffer and recall it
tulip.tfb_save()
tulip.tfb_restore()
# Set/get TFB font number (0=8x12, 1=6x8, 2=12x16)
tulip.tfb_font(x)
font_num = tulip.tfb_font()You can have up to 32 bitmap sprites on screen at once, and have 32KB of bitmap data to store them in. Sprites have collision detection built in. Sprites are drawn in order of sprite index, so sprite index 5 will draw on top of sprite index 3 if they share pixel space.
# Load the data from a PNG file into sprite RAM at the memory position (0-32767).
# Returns w, h, and number of bytes used
# Alpha is used if given
(w, h, bytes) = tulip.sprite_png(png_data, mem_pos)
(w, h, bytes) = tulip.sprite_png("filename.png", mem_pos)
# Or load sprites in from a bitmap in memory (packed pallete indexes for RGB332)
# The bitmap can be made from code you wrote, or from bg_bitmap to sample the background
# Use pal idx 0x55 to denote alpha when generating your own sprites
bytes = tulip.sprite_bitmap(bitmap, mem_pos)
# Read bitmap data from sprite ram if you need to modify sprites or copy them to BG
bitmap = tulip.sprite_bitmap(mem_pos, length)
# "Register" a piece of sprite RAM into a sprite handle index for later use.
# Creates sprite handle #12 referencing sprite data starting at mem_pos and w,h pixels
tulip.sprite_register(12, mem_pos, w, h)
# Turn on a sprite to draw on screen
tulip.sprite_on(12)
# And off
tulip.sprite_off(12)
# Set a sprite x and y position
tulip.sprite_move(12, x, y)
# Every frame, we update a collision list of things that collided that frame
# Collisions are evaluated every scanline (left to right and top to bottom),
# and only on pixels that are written to the screen (not ALPHA, and must be visible)
# See world.download("collide") for an example
# Calling collisions() clears the memory of collisions we've kept up to that point.
for c in tulip.collisions():
(a,b) = c # a and b are sprite #s that collided. a will always < b.
# Check if a touch or mouse click hit a sprite by looking for sprite #31
if(b==31):
print("Touch/click on sprite %d" % (a))
# Clear all sprite RAM, reset all sprite handles
tulip.sprite_clear()You can access sprites using the tulip_sprite_X commands, or use our convenience Sprite class to manage memory and IDs for you:
class Bullet(tulip.Sprite):
def __init__(self, copy_from=None):
super().__init__(copy_from=copy_from)
self.load("bullet.png", 32, 32)
b.on()
b.move_to(20,20)
b = Bullet()
b.off() # turn off
b.on() # turns on
b.x = 1025
b.clamp() # ensure it is within screen range
b.move() # move to its most recent position
b.move_to(x,y) # sets x and y and moves
b2 = Bullet(copy_from=b) # will use the image data from b but make a new sprite handle
b2.move_to(25,25) # Can have many sprites of the same image data on screen this wayA Player class comes with a quick way to move a sprite from the keyboard:
p = tulip.Player(speed=5) # 5px per movement
p.load("me.png", 32, 32)
p.joy_move() # will update the position based on the joystickSee planet_boing in /sys/ex/ for a fleshed out example of using the Game and Sprite classes.
Things we've thought of we'd love your help on:
- Sprite editor in Tulip
- Tile / Map editor in Tulip




Chat about Tulip on our Discord!