Skip to content

Commit

Permalink
Merge pull request #416 from shorepine/amyseq2
Browse files Browse the repository at this point in the history
AMY sequencer in Tulip
  • Loading branch information
bwhitman authored Nov 8, 2024
2 parents 4cd2497 + 67c97da commit ef7747d
Show file tree
Hide file tree
Showing 22 changed files with 205 additions and 335 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ You can [buy a Tulip for $59 US](https://tulip.computer) or [build your own Tuli
Tulip CC supports:
- 8.5MB of RAM - 2MB is available to MicroPython, and 1.5MB is available for OS memory. The rest is used for the graphics framebuffers (which you can use as storage) and the firmware cache.
- 32MB flash storage, as a filesystem accesible in Python (24MB left over after OS in ROM)
- An [AMY](https://github.com/shorepine/amy) stereo 120-voice synthesizer engine running locally, or as a wireless controller for an [Alles](https://github.com/shorepine/alles) mesh. Tulip's synth supports additive and subtractive oscillators, an excellent FM synthesis engine, samplers, karplus-strong, high quality analog style filters, and much more. We ship Tulip with a drum machine, voices / patch app, and Juno-6 editor.
- An [AMY](https://github.com/shorepine/amy) stereo 120-voice synthesizer engine running locally, or as a wireless controller for an [Alles](https://github.com/shorepine/alles) mesh. Tulip's synth supports additive and subtractive oscillators, an excellent FM synthesis engine, samplers, karplus-strong, high quality analog style filters, a sequencer, and much more. We ship Tulip with a drum machine, voices / patch app, and Juno-6 editor.
- Text frame buffer layer, 128 x 50, with ANSI support for 256 colors, inverse, bold, underline, background color
- Up to 32 sprites on screen, drawn per scanline, with collision detection, from a total of 32KB of bitmap memory (1 byte per pixel)
- A 1024 (+128 overscan) by 600 (+100 overscan) background frame buffer to draw arbitrary bitmaps to, or use as RAM, and which can scroll horizontally / vertically
Expand Down
2 changes: 1 addition & 1 deletion amy
Submodule amy updated 9 files
+2 −2 Makefile
+42 −4 README.md
+5 −4 amy.py
+97 −101 src/amy.c
+5 −2 src/amy.h
+7 −4 src/patches.c
+174 −0 src/sequencer.c
+21 −0 src/sequencer.h
+1 −1 src/setup.py
20 changes: 19 additions & 1 deletion docs/music.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ You can also easily change the BPM of the sequencer -- this will impact everythi
tulip.seq_bpm(120)
```

(Make sure to read below about the higher-accuracy sequencer API, `amy.send(sequence)`. The Tulip `seq_X` commands are simple and easy to use, but if you're making a music app that requires rock-solid timing, you'll want to use the AMY sequencer directly.)

## Making new Synths

We're using `midi.config.get_synth(channel=1)` to "borrow" the synth booted with Tulip. But if you're going to want to share your ideas with others, you should make your own `Synth` that doesn't conflict with anything already running on Tulip. That's easy, you can just run:
Expand All @@ -130,7 +132,7 @@ synth1.note_on(50, 1)
synth2.note_on(50, 0.5)
```

You can also "schedule" notes in the near future (up to 20 seconds ahead). This is useful for sequencing fast parameter changes or keeping in time with the sequencer. `Synth`s accept a `time` parameter, and it's in milliseconds. For example:
You can also "schedule" notes. This is useful for sequencing fast parameter changes. `Synth`s accept a `time` parameter, and it's in milliseconds. For example:

```python
# play a chord all at once
Expand Down Expand Up @@ -477,6 +479,22 @@ s.note_on(55, 1)
Try saving these setup commands (including the `store_patch`, which gets cleared on power / boot) to a python file, like `woodpiano.py`, and `execfile("woodpiano.py")` on reboot to set it up for you!


## Direct AMY sequencer

Tulip can use the AMY sequencer directly. The `tulip.seq_X` commands are written in Python, and may end up being delayed some small amount of milliseconds if Python is busy doing other things (like drawing a screen.) For this reason, we recommend using the AMY sequencer directly for music, and using the Tulip sequencer for graphical updates. The AMY sequencer runs in a separate "thread" on Tulip and cannot be interrupted. It will maintain rock-solid timing using the audio clock on your device.

A great longer example of how to do this is in our [`drums` app](https://github.com/shorepine/tulipcc/blob/main/tulip/shared/py/drums.py). You can see that the drum pattern itself is updated in AMY any time a parameter is changed, and that we use `tulip.seq_X` callbacks only to update the "time LED" ticker across the top.

You can schedule events to happen in a sequence in AMY using `amy.send(sequence=` commands. For the drum machine example, you set the `period` of the sequence and then update events using AMY commands at the right `tick` offset to that length. For example, a drum machine that has 16 steps, each an eighth note, would have a `period` of 24 * 16 = 384. (24 is half of the sequencer's PPQ. If you wanted 16 quarter notes, you would use 48 * 16. Sixteenth notes would be 12 * 16.) And then, each event you expect to play in that pattern is sequenced with an "offset" `tick` into that pattern. The first event in the pattern is at `tick` 0, and the 9th event would be at `tick` 24 * 9 = 216.

```python
amy.send(reset=amy.RESET_SEQUENCER) # clears the sequence

amy.send(osc=0, vel=1, wave=amy.PCM, patch=0, sequence="0,384,1") # first slot of a 16 1/8th note drum machine
amy.send(osc=1, vel=1, wave=amy.PCM, patch=1, sequence="216,384,2") # ninth slot of a 16 1/8th note drum machine
```

The three parameters in `sequence` are `tick`, `period` and then `tag`. `tag` is used to keep track of which events are scheduled, so you can overwrite their parameters or delete them later.



Expand Down
14 changes: 8 additions & 6 deletions docs/tulip_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,19 +490,21 @@ for i,note in enumerate(chord.midinotes()):

## Music sequencer

Tulip is always running a live sequencer, meant for music programs you write to share a common clock. This allows you to have multiple music programs running that respond to a callback to play notes.
Tulip is always running AMY's live sequencer, meant for music programs you write to share a common clock. This allows you to have multiple music programs running that respond to a callback to play notes.

To use the clock in your code, you should first register on the music callback with `slot = tulip.seq_add_callback(my_callback)`. You can remove your callback with `tulip.seq_remove_callback(slot)`. You can remove all callbacks with `tulip.seq_remove_callbacks()`. We support up to 8 callbacks running at once.
**There are two types of sequencer callbacks in Tulip**. One is the AMY sequencer, where you set up an AMY synthesizer event to run at a certain time (or periodically.) This is done using the `amy.send(sequence=)` command. See [AMY's documentation](https://github.com/shorepine/amy/blob/main/README.md#the-sequencer) for more details.

When adding a callback, there's an optional second parameter to denote a divider on the system level parts-per-quarter timer (currently at 48). If you run `slot = tulip.seq_add_callback(my_callback, 6)`, it would call your function `my_callback` every 6th "tick", so 8 times a quarter note at a PPQ of 48. The default divider is 48, so if you don't set a divider, your callback will activate once a quarter note.
Tulip also receives these same sequencer messages, for use in updating the screen or doing other periodic events. Due the way Tulip works, depending on the activity, there can sometimes be a noticeable delay between the sequencer firing and Tulip finishing drawing (some 10s-100 milliseconds.) The audio synthesizer will run far more accurately using the AMY native sequencer. So make sure you use AMY's event sequencing to schedule audio events, and use these Tulip callbacks for less important events like updating the screen. For exanple, a drum machine should use AMY's `sequence` command to schedule the notes to play, but using the `tulip.seq_add_callback` API to update the "beat ticker" display in Tulip. See how we do this in the [`drums`](https://github.com/shorepine/tulipcc/blob/main/tulip/shared/py/drums.py) app.

To use the lower-precision Python Tulip sequencer callback in your code, you should first register with `slot = tulip.seq_add_callback(my_callback)`. You can remove your callback with `tulip.seq_remove_callback(slot)`. You can remove all callbacks with `tulip.seq_remove_callbacks()`. We support up to 8 callbacks running at once.

By default, your callback will receive a message 50 milliseconds ahead of the time of the intended tick, with the parameters `my_callback(intended_time_ms)`. This is so that you can take extra CPU time to prepare to send messages at the precise time, using AMY scheduling commands, to keep in perfect sync. You can set this "lookahead" globally for all callbacks if you want more or less latency with `tulip.seq_latency(X)` or get it with `tulip.seq_latency()`.
When adding a callback, there's an optional second parameter to denote a divider on the system level parts-per-quarter timer (currently at 48). If you run `slot = tulip.seq_add_callback(my_callback, 6)`, it would call your function `my_callback` every 6th "tick", so 8 times a quarter note at a PPQ of 48. The default divider is 48, so if you don't set a divider, your callback will activate once a quarter note.

You can set the system-wide BPM (beats, or quarters per minute) with `tulip.seq_bpm(120)` or retrieve it with `tulip.seq_bpm()`.
You can set the system-wide BPM (beats, or quarters per minute) with AMY's `amy.send(tempo=120)` or using wrapper `tulip.seq_bpm(bpm)`. You can retrieve the BPM with `tulip.seq_bpm()`.

You can see what tick you are on with `tulip.seq_ticks()`.

See the example `seq.py` on Tulip World for an example of using the music clock, or the [`drums`](https://github.com/shorepine/tulipcc/blob/main/tulip/shared/py/drums.py) included app.
See the example `world.download('seq.py','bwhitman')` on Tulip World for an example of using the music clock, or the [`drums`](https://github.com/shorepine/tulipcc/blob/main/tulip/shared/py/drums.py) included app.

**See the [music tutorial](music.md) for a LOT more information on music in Tulip.**

Expand Down
1 change: 1 addition & 0 deletions tulip/esp32s3/boards/TDECK/sdkconfig.board
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=4608
4 changes: 3 additions & 1 deletion tulip/esp32s3/boards/sdkconfig.tulip
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ CONFIG_SPIRAM_RODATA=y
CONFIG_LCD_RGB_ISR_IRAM_SAFE=n
CONFIG_LCD_RGB_RESTART_IN_VSYNC=y

CONFIG_LWIP_PPP_SUPPORT=n
CONFIG_LWIP_PPP_SUPPORT=n

CONFIG_ESP_TIMER_TASK_STACK_SIZE=8192
3 changes: 2 additions & 1 deletion tulip/esp32s3/esp32_common.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ list(APPEND MICROPY_SOURCE_EXTMOD
${TULIP_SHARED_DIR}/ui.c
${TULIP_SHARED_DIR}/midi.c
${TULIP_SHARED_DIR}/sounds.c
${TULIP_SHARED_DIR}/sequencer.c
${TULIP_SHARED_DIR}/tsequencer.c
${TULIP_SHARED_DIR}/lodepng.c
${TULIP_SHARED_DIR}/lvgl_u8g2.c
${TULIP_SHARED_DIR}/u8fontdata.c
Expand All @@ -194,6 +194,7 @@ list(APPEND MICROPY_SOURCE_EXTMOD
${AMY_DIR}/src/filters.c
${AMY_DIR}/src/oscillators.c
${AMY_DIR}/src/transfer.c
${AMY_DIR}/src/sequencer.c
${AMY_DIR}/src/partials.c
${AMY_DIR}/src/pcm.c
${AMY_DIR}/src/log2_exp2.c
Expand Down
8 changes: 3 additions & 5 deletions tulip/esp32s3/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@


#include "usb.h"
#include "sequencer.h"
#include "usb_serial_jtag.h"
#include "modmachine.h"
#include "modnetwork.h"
Expand All @@ -63,6 +62,8 @@
#include "tdeck_keyboard.h"
#endif

#include "tsequencer.h"


#if MICROPY_BLUETOOTH_NIMBLE
#include "extmod/modbluetooth.h"
Expand Down Expand Up @@ -440,10 +441,6 @@ void app_main(void) {
fflush(stderr);
delay_ms(100);

fprintf(stderr,"Starting Sequencer (timer)\n");
sequencer_init();
run_sequencer();

#ifdef TDECK
delay_ms(3000); // wait for touchscreen
fprintf(stderr,"Starting T-Deck keyboard on core %d\n", USB_TASK_COREID);
Expand All @@ -452,6 +449,7 @@ void app_main(void) {
delay_ms(10);
#endif

tsequencer_init();


}
Expand Down
21 changes: 19 additions & 2 deletions tulip/esp32s3/tdeck_keyboard.c
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,29 @@ void run_tdeck_keyboard() {
} else {
char_to_send[0] = rx_data[0];
}

uint8_t c = char_to_send[0];
if(keycode_to_ctrl_key(c) != '\0') {
const size_t len = strlen(lvgl_kb_buf);
if (len < KEYBOARD_BUFFER_SIZE - 1) {
lvgl_kb_buf[len] = keycode_to_ctrl_key(c);
lvgl_kb_buf[len + 1] = '\0';
}
} else {
// put it in lvgl_kb_buf for lvgl
const size_t len = strlen(lvgl_kb_buf);
if (len < KEYBOARD_BUFFER_SIZE - 1) {
lvgl_kb_buf[len] = c;
lvgl_kb_buf[len+1] = '\0';
}
}

// Send as is, combining with ctrl if toggled
if (ctrl_toggle) {
send_key_to_micropython(get_alternative_char(ctrlMappings, ctrlMappingsSize, char_to_send[0]));
send_key_to_micropython(get_alternative_char(ctrlMappings, ctrlMappingsSize, c));
ctrl_toggle = false; // Reset toggle after sending
} else {
send_key_to_micropython(char_to_send[0]);
send_key_to_micropython(c);
}
}
}
Expand Down
7 changes: 2 additions & 5 deletions tulip/linux/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
#include "display.h"
#include "alles.h"
#include "midi.h"
#include "sequencer.h"
#include "shared/runtime/pyexec.h"


Expand Down Expand Up @@ -863,6 +862,7 @@ MP_NOINLINE void * main_(void *vargs) { //int argc, char **argv) {
extern int8_t unix_display_flag;

#include "lvgl.h"
#include "tsequencer.h"

int main(int argc, char **argv) {
// Get the resources folder loc
Expand Down Expand Up @@ -912,10 +912,7 @@ int main(int argc, char **argv) {
pthread_t mp_thread_id;
pthread_create(&mp_thread_id, NULL, main_, NULL);

sequencer_init();
pthread_t sequencer_thread_id;
pthread_create(&sequencer_thread_id, NULL, run_sequencer, NULL);

tsequencer_init();
delay_ms(100);
// Schedule a "turning on" sound

Expand Down
6 changes: 2 additions & 4 deletions tulip/macos/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
#include "display.h"
#include "alles.h"
#include "midi.h"
#include "sequencer.h"
#include "shared/runtime/pyexec.h"

#include "tsequencer.h"


// Command line options, with their defaults
Expand Down Expand Up @@ -945,9 +945,7 @@ int main(int argc, char **argv) {
pthread_t mp_thread_id;
pthread_create(&mp_thread_id, NULL, main_, NULL);

sequencer_init();
pthread_t sequencer_thread_id;
pthread_create(&sequencer_thread_id, NULL, run_sequencer, NULL);
tsequencer_init();

delay_ms(100);
// Schedule a "turning on" sound
Expand Down
43 changes: 2 additions & 41 deletions tulip/shared/modtulip.c
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
#include "alles.h"
#include "midi.h"
#include "ui.h"
#include "sequencer.h"
#include "tsequencer.h"
#include "keyscan.h"
#include "genhdr/mpversion.h"

Expand Down Expand Up @@ -483,7 +483,7 @@ STATIC mp_obj_t tulip_seq_add_callback(size_t n_args, const mp_obj_t *args) {
if(n_args == 2) {
sequencer_dividers[index] = mp_obj_get_int(args[1]);
} else {
sequencer_dividers[index] = sequencer_ppq;
sequencer_dividers[index] = AMY_SEQUENCER_PPQ;
}
} else {
index = -1;
Expand Down Expand Up @@ -513,42 +513,6 @@ STATIC mp_obj_t tulip_seq_remove_callbacks(size_t n_args, const mp_obj_t *args)
STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_seq_remove_callbacks_obj, 0, 0, tulip_seq_remove_callbacks);


STATIC mp_obj_t tulip_seq_bpm(size_t n_args, const mp_obj_t *args) {
if(n_args == 1) {
sequencer_bpm = mp_obj_get_float(args[0]);
sequencer_recompute();
} else {
return mp_obj_new_float(sequencer_bpm);
}
return mp_const_none;
}

STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_seq_bpm_obj, 0, 1, tulip_seq_bpm);


STATIC mp_obj_t tulip_seq_ppq(size_t n_args, const mp_obj_t *args) {
if(n_args == 1) {
sequencer_ppq = mp_obj_get_int(args[0]);
sequencer_recompute();
} else {
return mp_obj_new_int(sequencer_ppq);
}
return mp_const_none;
}

STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_seq_ppq_obj, 0, 1, tulip_seq_ppq);

STATIC mp_obj_t tulip_seq_latency(size_t n_args, const mp_obj_t *args) {
if(n_args == 1) {
sequencer_latency_ms = mp_obj_get_int(args[0]);
} else {
return mp_obj_new_int(sequencer_latency_ms);
}
return mp_const_none;
}

STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(tulip_seq_latency_obj, 0, 1, tulip_seq_latency);


STATIC mp_obj_t tulip_seq_ticks(size_t n_args, const mp_obj_t *args) {
return mp_obj_new_int(sequencer_tick_count);
Expand Down Expand Up @@ -1320,9 +1284,6 @@ STATIC const mp_rom_map_elem_t tulip_module_globals_table[] = {
{ MP_ROM_QSTR(MP_QSTR_seq_remove_callback), MP_ROM_PTR(&tulip_seq_remove_callback_obj) },
{ MP_ROM_QSTR(MP_QSTR_seq_remove_callbacks), MP_ROM_PTR(&tulip_seq_remove_callbacks_obj) },
{ MP_ROM_QSTR(MP_QSTR_midi_callback), MP_ROM_PTR(&tulip_midi_callback_obj) },
{ MP_ROM_QSTR(MP_QSTR_seq_bpm), MP_ROM_PTR(&tulip_seq_bpm_obj) },
{ MP_ROM_QSTR(MP_QSTR_seq_ppq), MP_ROM_PTR(&tulip_seq_ppq_obj) },
{ MP_ROM_QSTR(MP_QSTR_seq_latency), MP_ROM_PTR(&tulip_seq_latency_obj) },
{ MP_ROM_QSTR(MP_QSTR_seq_ticks), MP_ROM_PTR(&tulip_seq_ticks_obj) },
{ MP_ROM_QSTR(MP_QSTR_midi_in), MP_ROM_PTR(&tulip_midi_in_obj) },
{ MP_ROM_QSTR(MP_QSTR_midi_out), MP_ROM_PTR(&tulip_midi_out_obj) },
Expand Down
6 changes: 2 additions & 4 deletions tulip/shared/py/arpegg.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
import time
import random
import tulip


#tulip.seq_add_callback(midi_step, int(tulip.seq_ppq()/2))
import amy


class ArpeggiatorSynth:
Expand Down Expand Up @@ -69,7 +67,7 @@ def run(self):
self.current_step = -1
# Semaphore to the run loop to start going.
self.running = True
self.slot = tulip.seq_add_callback(self.step_callback, int(tulip.seq_ppq()/2))
self.slot = tulip.seq_add_callback(self.step_callback, int(amy.SEQUENCER_PPQ/2))

def stop(self):
self.running = False
Expand Down
Loading

0 comments on commit ef7747d

Please sign in to comment.