From d043616f5cb3e29d2a86c2799e121a9d983d2182 Mon Sep 17 00:00:00 2001 From: moon2 <moon2protonmail@protonmail.com> Date: Sat, 14 Dec 2024 17:54:48 +0100 Subject: [PATCH 1/3] bl00mbox: background callback fix, mixer behavior change --- components/bl00mbox/micropython/mp_sys_bl00mbox.c | 6 +++--- components/bl00mbox/radspa/standard_plugin_lib/mixer.c | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/components/bl00mbox/micropython/mp_sys_bl00mbox.c b/components/bl00mbox/micropython/mp_sys_bl00mbox.c index f0cf05c3ee..e1b8a5450f 100644 --- a/components/bl00mbox/micropython/mp_sys_bl00mbox.c +++ b/components/bl00mbox/micropython/mp_sys_bl00mbox.c @@ -292,7 +292,7 @@ STATIC mp_obj_t mp_channel_core_make_fake(mp_obj_t self_in) { fake->fake = true; fake->channel_plugin = self->channel_plugin; #ifdef BL00MBOX_CALLBACKS_FUNSAFE - fake->callback = self->callback; + fake->callback = MP_OBJ_NULL; #endif return MP_OBJ_FROM_PTR(fake); } @@ -372,7 +372,7 @@ STATIC void channel_core_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) { bl00mbox_channel_set_foreground(chan, mp_obj_is_true(dest[1])); #ifdef BL00MBOX_CALLBACKS_FUNSAFE } else if (attr == MP_QSTR_callback) { - self->callback = dest[1]; + ((channel_core_obj_t *)chan->parent)->callback = dest[1]; #endif } else { attr_exists = false; @@ -395,7 +395,7 @@ STATIC void channel_core_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) { dest[0] = self->channel_plugin; } else if (attr == MP_QSTR_callback) { #ifdef BL00MBOX_CALLBACKS_FUNSAFE - dest[0] = self->callback; + dest[0] = ((channel_core_obj_t *)chan->parent)->callback; #else dest[0] = mp_const_none; #endif diff --git a/components/bl00mbox/radspa/standard_plugin_lib/mixer.c b/components/bl00mbox/radspa/standard_plugin_lib/mixer.c index 0eebdcf2e1..91cb1e6910 100644 --- a/components/bl00mbox/radspa/standard_plugin_lib/mixer.c +++ b/components/bl00mbox/radspa/standard_plugin_lib/mixer.c @@ -25,10 +25,8 @@ void mixer_run(radspa_t * mixer, uint16_t num_samples, uint32_t render_pass_id){ for(uint8_t j = 0; j < data->num_inputs; j++){ int32_t input_gain_const = radspa_signal_get_const_value(input_gain_sigs[j], render_pass_id); - // load bearing bug, figure out how to migrate to this behavior gracefully before uncommenting: - // if(!input_gain_const) continue; + if(!input_gain_const) continue; int32_t input_const = radspa_signal_get_const_value(input_sigs[j], render_pass_id); - if(!input_gain_const) continue; // remove when uncommenting two lines above if(!input_const) continue; if((input_const != RADSPA_SIGNAL_NONCONST) && (input_gain_const != RADSPA_SIGNAL_NONCONST)){ if(ret_init){ -- GitLab From 9f764ab17ed8ef4ba5d6eb12b544ac7c4cb74570 Mon Sep 17 00:00:00 2001 From: moon2 <moon2protonmail@protonmail.com> Date: Sat, 14 Dec 2024 03:11:44 +0100 Subject: [PATCH 2/3] docs: bl00mbox --- docs/badge/bl00mbox.rst | 503 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 501 insertions(+), 2 deletions(-) diff --git a/docs/badge/bl00mbox.rst b/docs/badge/bl00mbox.rst index c3bb239667..6700ea426b 100644 --- a/docs/badge/bl00mbox.rst +++ b/docs/badge/bl00mbox.rst @@ -1,6 +1,505 @@ .. _bl00mbox: bl00mbox -========== +======== -This is an external module, `click here for the primary documentation <https://gitlab.com/moon2embeddedaudio/bl00mbox>`_. +bl00mbox is an audio synthesis and processing engine. Its primary documentation is hosted +`here <https://gitlab.com/moon2embeddedaudio/bl00mbox>`_ and covers general use cases. This page does not duplicate that +documentation, but rather focuses on bl00mbox in the context of flow3r. Feel free to read these documents in any order, +we will introduce basic bl00mbox concepts here briefly as needed. + +Environment +----------- + +flow3r is intended as a music toy where multiple applications can make sound together at the same time. Since only one +user interface can be active at a given time, there is a distinction between applications making sound in the foreground +and in the background. These multiple sources are managed by the **system mixer**. To get a feeling for it, open a music +application, hold the OS button down for a second and select the mixer page: You will see that a corresponding *named* +channel has appeared in the mixer and that you can control its volume seperately (from ``media``, for example). If you exit +the application, the channel probably has disappeared. + +That is, unless you have picked one of the apps that may continue playing in the background, such as *gay drums*. Try +opening it, create a beat and exit while it continues to play - you can now enter another music application and see the +two coexisting channels in the system mixer. This is not only useful for relative volume control; if you mute one of these +channels in the mixer it stops rendering and thereby using CPU entirely. If you mute a *gay drums* beat for a tiny bit, +you will notice that its sequence has not progressed when you unmute it. + +These channels map directly to bl00mbox ``Channel`` objects, which are the central interfaces to create, connect and manage +plugins. In almost all cases each music application uses a single channel whose name is identical to the application name. +If you find a good reason to create multiple channels in a single application, their names should make it clear which +application they belong to (and of course also fit in the system mixer channel box). + +If you have been around since the early days of flow3r, you may remember that music applications had a tendency to +sometimes produce sound unwanted or to idly consume CPU/RAM indefinitely after having been closed. For a user that wishes +to use background channels as a session for example without rebooting flow3r all the time this is inconvenient, so we +have tried our best to clean up things automatically, but it doesn't go all the way (RAM, for example). We could enforce +this from the OS side by cleaning up resources more aggressively with the tradeoff that misbehaving apps crash more, +but we'd prefer to trust application developers to carefully to manage resources. + +**Please follow the resource management guidelines presented here so that flow3r can grow into a flexible and reliable +multitrack music toy!** + +Make sound +---------- + +Let's make a simple application that uses bl00mbox in its most basic form. The block we have seen in the mixer earlier +is a ``Channel`` object. It is initialized with a descriptive name (typically the application name) so that the user +knows what is what in the mixer. A channel can create plugins which create or modify sound. It also provides an audio +signal from the line input, as well as another that line output that routes to the mixer. + +.. code-block:: python + + import bl00mbox + + class Beeper(Application): + def __init__(self, app_ctx): + super().__init__(app_ctx) + # empty slot for the channel + self.blm = None + + def on_enter(self, vm): + super().on_enter(vm) + # this method creates a synthesizer and stores it in self.blm + self.build_synth() + + + def on_exit(self): + super().on_exit() + # important resource management: delete the channel when exiting. + # this also deletes all plugins. + self.blm.delete() + # good practice: also allow the now-"hollow" channel object to be + # garbage collected to save memory. + self.blm = None + + + def build_synth(self): + # create an empty channel + self.blm = bl00mbox.Channel("Beeper") + # create an internal mixer with 10 inputs. note: this is not the system mixer. + self.mixer = self.blm.new(bl00mbox.plugins.mixer, 10) + # connect the output of the mixer plugin to the line output of the channel + self.blm.signals.line_out << self.mixer.signals.output + for x in range(10): + # mute the x-th mixer input + self.mixer.signals.input_gain[x].mult = 0 + # create an oscillator + beep = self.blm.new(bl00mbox.plugins.osc) + # give it a unique pitch + beep.signals.pitch.tone = x + # connect it to the x-th mixer input + beep.signals.output >> self.mixer.signals.input[x] + # note: the oscillator may go out of scope here without being garbage + # collected. any plugin that is connected (directly or via other plugins) + # to the line out is considered reachable by bl00mbox. + + + def think(self, ins, delta_ms): + super().think(ins, delta_ms) + for x in range(10): + # unmute the x-th mixer input if corresponding petal + # is pressed to play a sound. + volume = 1 if ins.captouch.petals[x].pressed else 0 + self.mixer.signals.input_gain[x].mult = volume + # note: if it wasn't for deleting the channel in on_exit() this would just + # continue playing sound if exited while a petal is held. + +This app frees all resources that it doesn't need anymore, simply by calling ``.delete()`` on the channel. +Further attempts to interact with that channel and its plugins will result in a ``ReferenceError``, so a new one +must be created when re-entering. This is okay; the OS recognizes the name of the channel and applies all the +previous mixer settings again. A name should therefore not only be **descriptive**, but also **unique**. But not to +worry, you don't need to check every app ever, if your application name is unique in the app store and you use it for +the channel you have done due diligence. + +This application is almost well behaved and ready to ship, but there's one more thing we should do first to make +users happy: + +Normalize volume +---------------- + +It is desirable that all music applications default to a similar volume level. You might say, why not just the +maximum volume without clipping?, but there is this nasty little thing called crest ratio: The maximum peak of +an audio signal is very poorly correlated to its volume. The square wave we generated above is very very loud +compared to its maximum peak, but a more delicate sound such as an acoustic instrument sample may hopelessly +disappear next to it even if it fills all the range. A good default should be allow for a fair amount of wiggle +room for all these cases, so we've made an arbitrary decision: + +**flow3r instruments should aim for a typical volume of -15dB rms**. + +This volume adjustment must be done manually, but worry not, we provide utilities that make this fast and easy. +The most universal approach is to tell a channel to keep track of its line out volume and print it to the REPL. +This should of course only be **temporary during development**; measuring volume takes away CPU from the audio +engine which could otherwise use to render other channels for example, printing it reduces your think rate. +It's just 2 lines, it's not a big deal to comment them out. Let's modify our application: + +.. code-block:: python + + import bl00mbox + + class Beeper(Application): + + def build_synth(): + # (same body as before) + + # activate volume measurement + self.blm.compute_rms = True + + def think(self, ins, delta_ms): + # (same body as before) + + # print current volume in decibels + print(self.blm.rms_dB) + +The print rate may be very high, you can always add a temporary sleep or counter or close the connection on the +host side. We can see that the volume changes with the amount of petals we're pressing. This begs the next question: +What amount of petals do we normalize to? The answer is very unsatisfying: Whatever is typical for that application. +In this case you probably would play 1 or 2 notes at the same time normally; if it's more, it's a special case +and allowed to be louder. That's just personal intuition and other answers may be justifiable too, but it's a fairly +reasonable guess. If users don't like it they may fine tune in the mixer after all, you're just providing some +general purpose default setting. + +Let's measure then! With 1 petal pressed we're getting -34dB, with 2 petals it's about -31dB, so to reach our +target of -15dB we need to increase volume by 17.5dB. Conveniently the channel has a volume control just for that +(seperately from the mixer volume control, which this object has no access to). Unforunately it defaults to -12dB, +and its maximum level is 0dB, so we can't increase it enough. Why is our volume so low in the first place? Mixer +plugins are initialized so that all inputs can be processed without clipping, which means the output gain of the +mixer is set to a multiplier of 0.1, or -20dB. We can get the missing 5.5dB from the mixer plugin (this comes at +the cost of clipping when more than 5 voices are playing. We'll discuss that issue in the Performance section): + +.. code-block:: python + + import bl00mbox + + class Beeper(Application): + + def build_synth(): + # (same body as before) + + # done with this, remove + # self.blm.compute_rms = True + + # apply as much of the volume difference as we can here + self.blm.gain_dB += 12 + + # put the rest in the mixer plugin + self.mixer.signals.gain.dB += 5.5 + + def think(self, ins, delta_ms): + # (same body as before) + + # done with this, remove + # print(self.blm.rms_dB) + +This isn't all that hard, but there is an even easier way! You might have noticed a peculiar quality: When we go into +the system mixer, we actually do not exit the application, it is just suspended! This means if you enter the mixer +*while holding a petal* the sound continues to play indefinitely - is that bad behavior? Should we squash it? Nay, au +contraire, it is desirable! Say the user wants to readjust volume, it would be awfully useful to hear your adjustment +while in the mixer, right? Let's keep it! But we can also use it for development: Try it, and you will notice that +the mixer activates volume measurement and displays it in the channel. If you look closely, there is a little notch next +to it too: This is our normalization notch that you should aim for. + +While this method doesn't give you an absolute value, it is much better at displaying the dynamic behavior; our little +Beeper here is fairly static, but if there's more movement in the volume it might be hard to follow by just reading the +printout. In such a dynamic case, you should normalize so that the loud bits linger mostly around the notch. Going above +a little bit for a quick peak is okay. It's somewhat hard to make a static set of rules for this; when in doubt, compare +to similar stock applications, and don't start a loudness war :D! + +Run in background +----------------- + +The above example is designed to free all resources when exited, but didn't we say earlier these could run in the background? +Guess what - more rules and best practices first :P! It's actually pretty simple: + +Firstly, you should give users the option to **not** have your channel run in the background after exiting. Ideally this +option should be obvious and the default. An example: gay drums destroys its channel if the sequencer is not running, i.e. +the drumbeat is not playing (or the track is empty, so that it is kind of playing but actually not). We can directly adapt +this approach to our Beeper; if we hold a petal while exiting, we may continue playing. Didn't we say earlier that this +was bad? + +Well, only if it is unintentional - and it only is unintentional if it's not in the **help text**! This one can be accessed +right next to the mixer and should ideally contain all there is to know about your application (it needn't all be in the +same string, remember that ``.get_help()`` may change its output depending on application state). Let's add this to our next +iteration, and we're golden! + +Secondly, what if a user just wants to be done with that background channel without navigating to and through your app, or +if some application has a bug and cannot *not* play in the background by accident? Remember that ``blm.delete()`` method +from earlier - the mixer can call it too. Not on the currently active foreground channel, so if your app doesn't do +backgrounding you don't have to worry about it, but if it does it need to check after re-entry if the channel still +exists, else it might crash with a ``ReferenceError``. + +One last thing before we write some code: What's that currently active foreground channel? Well, simply put, only one +channel is in the foreground at any given time. Most interactions with a channel or its plugins set it as the foreground +channel automatically. Exiting an application clears the foreground channel too. If we want to have a channel rendered +that is not currently foregrounded, we must explicitely set the ``.background_mute_override`` attribute. As a general rule +of thumb, if a channel does not have this attribute set it should be deleted when exiting the application in order to not +waste RAM. The OS is not automatically doing it. **For now** :P. + +.. code-block:: python + + import bl00mbox + + class Beeper(Application): + def __init__(self, app_ctx): + super().__init__(app_ctx) + self.blm = None + self.any_playing = False + + def on_enter(self, vm): + super().on_enter(vm) + if self.blm is not None: + try: + self.blm.foreground = True + except ReferenceError: + self.blm = None + + if self.blm is None: + self.build_synth() + + + def on_exit(self): + super().on_exit() + if self.any_playing: + self.blm.background_mute_override = True + else: + self.blm.delete() + self.blm = None + + def think(self, ins, delta_ms): + super().think(ins, delta_ms) + self.any_playing = False + for x in range(10): + if ins.captouch.petals[x].pressed: + self.mixer.signals.input_gain[x].mult = 1 + self.any_playing = True + else: + self.mixer.signals.input_gain[x].mult = 0 + + def get_help(self): + ret = ( "Simple synthesizer, each petal plays a different note. " + "If you exit while holding a petal that note continues " + "playing in the background to allow for drones." ) + return ret + + # (build_synth same as before) + +But wait, there's more! The above approach allows us to do anything in the background that the standalone audio engine +can do; we could modulate volume with a low frequency sine wave easily if we wanted to for example, but that's not really +convenient or appropriate for many things. To make things more flexible, we can also attach a micropython callable to it +which gets called by the OS regularily as long as the channel is rendered (at the end of each main loop to be exact, see +``st3m.application``). If the channel is muted it is not called. bl00mbox itself doesn't specify the arguments, but for +flow3r purposes we call it think-like with ``ins`` and ``delta_ms`` as positional arguments. + +This callback can do anything think can do and obviously can be used very irresponsibly. Avoid using this method +irresponsibly please. Here's a couple of rules: + +Wouldn't it be cool if one app set the LEDs in the background and another did something in the foreground with captouch +and the display and all? Yes, but you cannot make sure at this point that the foreground app isn't accessing the LEDs as +well, resulting in some middle ground that is unsatisfying in the best case and epilepsy inducing in the worst. Let's not +do this. + +We are actively planning to add more background callback options in a future release, which would allow for proper resource +locks and an adequate user interface to control these background tasks. Before that, please be patient, restrain yourself +and **do not use bl00mbox callbacks to change anything except for the corresponding channel**. Attempts to hijack these +callbacks for any other purpose is considered malicious. + +Well, that means we can still use ``ins`` to read out captouch and just make our thing playable along with other instruments, +right? Nope, normally no. If you do subtle indirect changes to a modulation, yes, that can make sense, so we're still passing +the parameter and don't just downright block it - but consider: If menuing or navigating sound unrelated apps just plays +like a keyboard, that would be pretty annoying. **Don't make your application annoying**. The infrastructure is not quite +ready yet (just like the mixer actually can't call ``.delete()`` yet, we were lying), but at some point users will be able +to permanently block channels from running in the background. Avoid getting on that list ideally. Avoid being the person +who motivates us to release this feature sooner than later. + +Now that we've set the ground rules let's do something cool: Let's add a filter hooked up to the accelerometer that +updates when you tilt the badge. This interacts with some tilt-based applications but it's not as obnoxious as retaining +captouch behavior, we'd expect it to be cool with many users. And yes: The stock *wobbler* application is derived from +this example. + +.. code-block:: python + + import bl00mbox + from st3m.ui import widgets + + class Beeper(Application): + def __init__(self, app_ctx): + super().__init__(app_ctx) + self.blm = None + self.any_playing = False + + def on_enter(self, vm): + super().on_enter(vm) + if self.blm is not None: + try: + self.blm.foreground = True + except ReferenceError: + self.blm = None + + if self.blm is None: + self.build_synth() + + def on_exit(self): + super().on_exit() + if self.any_playing: + self.blm.background_mute_override = True + else: + self.blm.delete() + self.blm = None + + def build_synth(self): + self.blm = bl00mbox.Channel("Beeper") + self.blm.gain_dB += 18.5 + self.mixer = self.blm.new(bl00mbox.plugins.mixer, 10) + # let's add a global filter + self.filter = self.blm.new(bl00mbox.plugins.filter) + self.filter.signals.input << self.mixer.signals.output + self.filter.signals.reso.value = 25000 + + self.blm.signals.line_out << self.filter.signals.output + for x in range(10): + self.mixer.signals.input_gain[x].mult = 0 + beep = self.blm.new(bl00mbox.plugins.osc) + # and make it a bit lower this time + beep.signals.pitch.tone = x - 36 + beep.signals.output >> self.mixer.signals.input[x] + + self.tilt = widgets.Inclinometer(buffer_len = 8) + self.tilt.on_enter() + + def synth_callback(ins, delta_ms): + # note: in python an inner function like this inherit the outer + # scope so that we can still access "self" in here + self.tilt.think(ins, delta_ms) + + # note: tilt.pitch describes the aviation angle here, not frequency + # it's a bit silly ^w^. + pitch = self.tilt.pitch + + # note: we're not accessing self.tilt.pitch again because it isn't + # cached internally and we should pay attention to making background + # callbacks as fast as possible. + if pitch is not None: + self.filter.signals.cutoff.tone = -pitch * 10 + 10 + + self.blm.callback = synth_callback(ins, delta_ms) + + def think(self, ins, delta_ms): + super().think(ins, delta_ms) + self.any_playing = False + for x in range(10): + if ins.captouch.petals[x].pressed: + self.mixer.signals.input_gain[x].mult = 1 + self.any_playing = True + else: + self.mixer.signals.input_gain[x].mult = 0 + + def get_help(self): + ret = ( "Simple synthesizer, each petal plays a different note. " + "Tilt to change filter cutoff frequency. " + "If you exit while holding a petal that note continues " + "playing in the background to allow for drones." ) + return ret + +Performance +----------- + +The CPU can only do so much, and this is a hard real time environment: If an audio frame is rendered too slowly, +there will be audible glitches. While different channels can be rendered in parallel on different cores (they're +not right now but hopefully soon, optionally - this could starve other tasks though and is not a magic cure-all), +there are no plans to make any single channel render its plugins in parallel. This gives us a hard upper limit +of using 100% of one core. + +We can easily find out how much our application is using in a given state: Go into the System->Settings menu and +activate ftop. This prints a CPU load report on the serial console every 5 seconds (while blocking all micropython +execution for a noticable time, which is why it's ideally turned off outside of debugging). If your app is running +with no other channel playing in the background, the audio task CPU load directly corresponds to your channel's +performance. Note that this is averaged over the entire 5 second interval, so depending on how much is going on a +CPU load of say 70% may already start "crackling" by producing a few dropouts here and there. + +Optimizing CPU load of a given sound includes a lot of trial and error: Different plugins of course cause different +CPU load, but also the same type in a different configuration will perform differently. If you have a single audio +chain and it's too heavy, there's often little choice but to simplify it. + +One common issue is excessive polyphony, or how many notes are playable at the same time: Above, we have naively +just tied an oscillator to each petal. For the sake of a simple example this was good enough, but we should ask +ourselves: Do we really want to play all of them at the same time? Isn't it more valuable to have more CPU available +for each note you play, without choking the entire engine including background channels if a user changes hand +position to hold a shoulder button for example? We can easily implement more intelligent system in python that +limits the amount of voices, or, alternatively, use the ``poly_squeeze`` plugin, or a mix of both - the Violin +app for example reduces its output to a single voice, which also helps with switching between notes. Furthermore, +playing a lot of notes at the same time demands a high headroom and may result in clipping. It really is for the +best to consider a hard limit there. + +Another technique we can apply is paraphony, or sharing common elements between voices. Our wobbler above is +actually paraphonic: It has 10 oscillators, but they all go through a shared filter. Say our filter cutoff was +controlled by each captouch position instead, so that it would make sense to have 10 filters - the CPU load +of our humble application might very well double (guesstimate)! In that case we could ask ourselves, maybe +a shared filter with the maximum of all cutoffs can fake the job well enough? Or two in parallel, one with +max, one with min? + +One extra parameter is render tree topology. See - whenever we set the input_gain of a mixer plugin to 0, +the connected oscillator is no longer being rendered to not waste CPU cycles (this can be overridden with +the ``.always_render`` attribute). Plugins that are not reachable from the line output are not rendered at +all. However, this system is not fully automatic: For example, the filter above is always rendered, even +if all inputs of the mixer before it are muted. If this chain was very long we could run into high idle loads, +which creates problems if we have multiple heavy plugin chains and want to only render one at a time: We must +be careful that rendering is properly suppressed for everything we don't need. bl00mbox will probably at some +point provide better automation there, but for the time being it is a good idea to carefully observe if idle +voices are maybe not rendered to a large degree. + +Common issues +------------- + +**Bugs in bl00mbox** + +bl00mbox has a good amount known of bugs. If something doesn't work as you'd expect it doesn't need to be your +fault. It might be worthwhile to check out the latest documentation of that feature. + +**Writing to flash causes audio glitches** + +This sadly cannot be avoided due to the bus architecture of the ESP32S3 for external RAM, in which bl00mbox +plugins generally live. Music applications should only save data on the SD card. + +**Channel won't get loud enough or distorts** + +bl00mbox audio buffers use 16bit data. This is generally a good amount of resolution for a normalized signal, +but we're not playing a CD back here: Synthesis can be sometimes a bit unpredictable in terms of headroom and +volume levels. If you only change the volume at the channel output, you might end up distorting earlier in the +chain, either by clipping or by being too silent. It is important to keep in mind the intermediate gain levels +as well; you can optimize them by plugging an intermediate output directly in the line out for testing purposes. +Excessive polyphony can make this harder: 10 full scale voices playing without clipping at the same time means +that each may only use 1/10th of the headroom, resulting in a -20dB gain reduction compared to a single voice. +This means 10 voice polyphony normalized to our -14dB target requires compression to avoid clipping. + +**How do I just get a plain piano sound?** + +bl00mbox is not a soundfont player. It can sort of kind of be squeezed into that role, but it is not its primary +focus for the time being. At this stage it is best to look up (analog) synthesis techniques for the timbre you're +looking for and find out what translates well by trial and error. A wavetable synthesizer as used by the *Violin* +app may help to cut that process a bit shorter for very "clean" sounding instruments, but many have noisy or +disharmonic textures which are best emulated by experimentally determined types of modulation, it is often wise +to look up other people's work. + +**All that background synthesis is nice and well, but what if I just wanna record and loop?** + +Well, technically there is the sampler plugin, which currently only supports a fixed buffer size, so it's a +tradeoff between max sample length and RAM hogging, which isn't great. That doesn't mean we don't consider this +feature important, but rather that we're taking our time getting it right: There's little point in having each +music application implement its own version of this, but rather we can override the volume up/down buttons to +implement a global looper that all applications may use. This is also why we recommend against using +``st3m.application.override_os_button_volume`` for music applications. We hope to finish this feature soon, +but there's a lot of details to get right so it will take a little longer to be ready for public! + +**In the example above, what if I want sound to stop when entering the mixer?** + +Thing is, right now you can't really do that. If you are familiar with ``st3m.ui.View`` you might ask, why not +just call ``.on_exit()`` when opening the system menu, but unfortunately these methods are typically used for +opening/closing applications that do not use views. It's a regrettable situation, and we will rectify it soon +when we have a clear path on how to resolve the general lack of seperation between applications and views; it's +probably just gonna be some extra methods, but it is gonna take some careful planning to unravel this cleanly. + +**I don't like this, can't I just use <other micropython audio engine> instead?** + +The backend allows for easily adding extra engines and we're happy to take a look if you have a concrete +proposition. It's best to start with opening an issue in the firmware repository so that we can have a look +before anybody sinks any potentially futile work into hooking it up. If you just wanna do it for yourself +regardless, the backend is simple enough to hook extra engines into. -- GitLab From 862bc3870454b2d49321f4f5921a0d6b32426e3d Mon Sep 17 00:00:00 2001 From: moon2 <moon2protonmail@protonmail.com> Date: Sat, 14 Dec 2024 21:07:17 +0100 Subject: [PATCH 3/3] new app: wobbler --- python_payload/apps/wobbler/__init__.py | 187 ++++++++++++++++++++++++ python_payload/apps/wobbler/flow3r.toml | 11 ++ 2 files changed, 198 insertions(+) create mode 100644 python_payload/apps/wobbler/__init__.py create mode 100644 python_payload/apps/wobbler/flow3r.toml diff --git a/python_payload/apps/wobbler/__init__.py b/python_payload/apps/wobbler/__init__.py new file mode 100644 index 0000000000..f8b404203b --- /dev/null +++ b/python_payload/apps/wobbler/__init__.py @@ -0,0 +1,187 @@ +from st3m.application import Application + +import math, cmath +import bl00mbox +import captouch +import leds +from st3m.ui import widgets, colours + + +class Wobbler(Application): + def __init__(self, app_ctx): + super().__init__(app_ctx) + self.blm = None + + self.any_playing = False + self.tilt_ref = None + self.tilt = None + self.pitch = -36 + + def on_enter(self, vm): + super().on_enter(vm) + if self.blm is not None: + try: + self.blm.foreground = True + except ReferenceError: + self.blm = None + + if self.blm is None: + self.build_synth() + + leds.set_slew_rate(min(160, leds.get_slew_rate())) + + def on_exit(self): + super().on_exit() + if self.any_playing: + self.blm.background_mute_override = True + else: + self.blm.delete() + self.blm = None + + def build_synth(self): + self.blm = bl00mbox.Channel("wobbler") + self.blm.gain_dB = 0 + self.mixer = self.blm.new(bl00mbox.plugins.mixer, 2) + self.mixer.signals.gain.mult = 8 + self.filter = self.blm.new(bl00mbox.plugins.filter) + self.filter.signals.gain.mult = 0.2 + self.filter.signals.reso.value = 22000 + self.env = self.blm.new(bl00mbox.plugins.env_adsr) + self.filter.signals.input << self.mixer.signals.output + self.env.signals.input << self.filter.signals.output + self.blm.signals.line_out << self.env.signals.output + self.oscs = [self.blm.new(bl00mbox.plugins.osc) for x in range(2)] + + for x, osc in enumerate(self.oscs): + osc.signals.output >> self.mixer.signals.input[x] + osc.signals.waveform.switch.SAW = True + + # background widget, doesn't do normal think/on_enter/on_exit + self.tilt_widget = widgets.Inclinometer(buffer_len=2) + self.tilt_widget.on_enter() + + def synth_callback(ins, delta_ms): + self.tilt_widget.think(ins, delta_ms) + roll = self.tilt_widget.roll + if roll is not None: + tilt = complex(roll, self.tilt_widget.pitch) + if self.tilt_ref is None: + self.tilt_ref = tilt + else: + tilt = tilt - self.tilt_ref + tilt *= 1.6 + abs_tilt = abs(tilt) + if abs_tilt > 1: + tilt /= abs_tilt + self.tilt = tilt + self.filter.signals.cutoff.tone = self.pitch + (2 - tilt.imag) * 20 + self.oscs[0].signals.pitch.tone = self.pitch + tilt.real * 2 + self.oscs[1].signals.pitch.tone = self.pitch - tilt.real * 2 + + self.blm.callback = synth_callback + + def think(self, ins, delta_ms): + super().think(ins, delta_ms) + + if self.input.buttons.app.middle.pressed: + roll = self.tilt_widget.roll + if roll is not None: + tilt = complex(roll, self.tilt_widget.pitch) + self.tilt_ref = tilt + + any_playing = False + pos = 0 + pos_div = 0 + for x in range(0, 10, 2): + if pressed := ins.captouch.petals[x].pressed: + any_playing = True + pos += ins.captouch.petals[x].pos.real + pos_div += 1 + """ + if x == 2: + for x, osc in enumerate(self.oscs): + if pressed: + osc.signals.waveform.switch.SAW = True + else: + osc.signals.waveform.switch.TRI = True + elif x == 8: + self.mixer.signals.gain.mult = 8 if pressed else 2 + """ + + if pos_div: + self.pitch = -44 + (pos / pos_div + 1) * 6 + + if self.any_playing != any_playing: + if any_playing: + self.env.signals.trigger.start() + else: + self.env.signals.trigger.stop() + self.any_playing = any_playing + + def get_help(self): + ret = ( + "Press any top petal to play the note. How far away from " + "the center you press controls pitch.\n\n" + "Tilt forward/backward to change filter cutoff and left/right " + "to detune the oscillators. Press app button down to zero the " + "tilt reference.\n\n" + "If you exit while playing the note it will continue playing " + "in the background while still responding to tilt." + ) + return ret + + def draw(self, ctx): + ctx.gray(0).rectangle(-120, -120, 240, 240).fill() + + col = (1, 1, 1) + eye_pos = complex(0, 0) + hue = 0 + + tilt = self.tilt_widget.pitch + if self.tilt is not None: + eye_pos = self.tilt * 10 + hue = (eye_pos.real + eye_pos.imag) % math.tau + eye_pos *= 5 + + ctx.radial_gradient( + eye_pos.real, eye_pos.imag, 0, eye_pos.real, eye_pos.imag, 100 + ) + for x in range(5): + rel = x / 4 + nval = rel + if self.any_playing: + nval /= 3 + col = colours.hsv_to_rgb(hue + math.tau * rel, 1 - rel / 2, 1 - nval) + ctx.add_stop(rel, col, 1) + for y in range(8): + leds.set_rgb(y * 5 + x, *col) + leds.update() + + length = 100 + openness = 60 + ctx.line_width = 1 + ctx.move_to(length, 0).quad_to(0, openness, -length, 0).stroke() + ctx.move_to(length, 0).quad_to(0, -openness, -length, 0).stroke() + for x in range(3): + lash_length = 0.3 if x == 1 else 0.2 + t = (x + 1) / 4 + nt = 1 - t + # nt^2 * p0 + 2 * nt * t * p1 + t^2 * P2 + lash_start_x = (nt * nt - t * t) * length + lash_start_y = t * nt * openness * 2 + # 2 * (nt - t) * p1 - 2 * nt * p0 + 2 * t * p2 + lash_angle_x = 2 * length + lash_angle_y = 2 * (nt - t) * openness + ctx.move_to(lash_start_x, lash_start_y) + ctx.rel_line_to( + lash_angle_y * lash_length, lash_angle_x * lash_length + ).stroke() + + ctx.arc(eye_pos.real, eye_pos.imag, 10, 0, math.tau, 0).stroke() + ctx.arc(eye_pos.real, eye_pos.imag, 20, 0, math.tau, 0).stroke() + + +if __name__ == "__main__": + import st3m.run + + st3m.run.run_app(Wobbler) diff --git a/python_payload/apps/wobbler/flow3r.toml b/python_payload/apps/wobbler/flow3r.toml new file mode 100644 index 0000000000..926c9988ce --- /dev/null +++ b/python_payload/apps/wobbler/flow3r.toml @@ -0,0 +1,11 @@ +[app] +name = "wobbler" +category = "Music" + +[entry] +class = "Wobbler" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware" -- GitLab