Wow, that’s a lot of stuff to cover! Let’s start from the beginning…
bypass()
The bypass()
conditional has nothing to do with threading.
modulator_
is the class in which all the DSP code (for example computing the ring modulation between two signals) is contained, along with the state of all the DSP related things (for example: the active internal oscillator waveform).
What we’re checking here is if the DSP engine is in a special “bypass” mode. In this hidden mode, the two audio inputs are passed as is to the outputs, for purposes of factory testing. Another “testy” thing happening in this mode: the big knob glows in red, green, blue successively (for checking that the 3 components of the LED are all working).
milliseconds()
milliseconds()
returns the number of milliseconds elapsed since the module started. It is manually incremented by a call to system_clock.Tick()
(thus, something should be responsible for calling system_clock.Tick();
1000 times a second, we’ll soon see what it is). milliseconds() & 4095
is a value that will ramp from 0 to 4095, at the rate of 1 increment per millisecond.
So the first 3 lines are generating ramps counting between 0 and 4095, with a 120° phase difference between them (well, 117°… sloppy me knew the value of 4000 / 3, not of 4096 / 3, so I just typed 1333 and 2667
).
These ramps are then folded into triangles, counting from 0 to 2047 then down to 0. Divide by 8 (shift right by 3) to get values between 0 and 255. Then you get the R, G, B components to write to the main tricolor LED. We also set the bicolor LED to full brightness on both the R and G component.
UI::Poll()
vs UI::DoEvents()
Ui::Poll()
contains all the code that is called on a regular basis to scan the state of buttons, encoders (and sometimes pots) and transmit information to the LEDs and displays.
Who calls Ui::Poll()
? Either the interrupt handler for a 1kHz timer, called SysTick_Handler()
, and defined in the main module file (for example, check rings.cc
: https://github.com/pichenettes/modules/blob/master/rings/rings.cc#L68). This low level timer is a facility offered by all ARM CPUs - and is often used by RTOS for context switching.
Or the caller of Ui::Poll()
is the interrupt handler that is invoked whenever a buffer of samples is ready to written to/read from the codec. That’s the case in warps.cc
, check the FillBuffer
routine: https://github.com/pichenettes/modules/blob/master/warps/warps.cc#L63
Anecdote time: Warps’ buffer size used to be 96 samples, with a sample rate of 96kHz, so FillBuffer()
was called once per millisecond, my preferred rate for UI polling. I later reduced the buffer size to 60 samples to improve latency a bit, which means that Ui::Poll
is now invoked at a rate of 1.6kHz, so everything that relies on milliseconds()
is actually running at 1.6x the expected speed
Doesn’t really matter, but it would have been more correct to move Ui::Poll()
to a SysTick_Handler()
to get the right rate.
Back to Ui::Poll()
… Since it is called 1000 times a second, it shouldn’t attempt to do anything that takes more than 1ms. Otherwise… stack overflow (if it always takes more than its allotted time) or audio glitches (if it occasionally takes more than expected)!
So whenever we detect a button press in Ui::Poll()
, we just shove an event in a message queue (the Event
object stores everything we need to know: what kind of control? which control? for how long has it been pressed?) and we just get out. We don’t attempt to do the actual thing the button press should do (like changing the state of the module, saving things, sending a SysEx over MIDI…), because it might actually take more than 1ms to do this thing!
When is it going to be handled?
Whenever the processor is done with FillBuffer()
(which contains the bulk of the audio processing code), and more generally whenever there is no interrupt to service, it returns back in the main loop. You can think of this loop as what the CPU is left to do whenever all the higher priority stuff is done (LED blinking? buttons checked? codec fed with audio? now let’s check for UI stuff to do!). This is in this loop that we call ui.DoEvents()
. It checks for unread messages in the event loop, and call the required methods to handle button presses or other types of events. With this organization of the execution time, the code handling the button presses is free to take as long as it needs: if necessary, it might be interrupted multiple times whenever something more useful needs to be done (like feeding audio to the codec).
Debate: polling vs interrupts for UI stuff
This code organization is not the only possible approach. For example, it is possible to ask the MCU to trigger an interrupt whenever there’s a change on the voltage at the pin hooked to the button. So we don’t have to check the state of the button continuously - we only get a notification when its state changes. Sounds cool, why am I not using that?
- Easy debouncing. My preferred debouncing technique (shove the read bits in a shift register, wait for a full string of 00000000 or 11111111 to decide that the button has reached a stable state) works better when the button is read continuously. Debouncing in ISRs… you need to start measuring intervals between calls to the ISR…
- Habit from desktop synth projects with many buttons, in which the buttons are all read by shift registers. In this case case the state of all buttons is streamed bit by bit to a single CPU pin, and a voltage change at this pin doesn’t mean anything about the state of a specific button.
- There are sometimes limitations on the number of interrupts one can subscribe to, or on the MCU pins that can receive interrupts. Not relying on interrupts gives me more freedom for deciding which signal is connected to what. Since buttons are not critical traces (low speed signals, no special function required), they tend to be assigned to whatever pins are left after all the important peripherals are hooked to the processor.
- When stuff happens in too many interrupt handlers, it gets hard to troubleshoot audio glitches/unregistered button presses. On all my modules, I can assume that
Ui::Poll()
runs in constant time and uses about 0.2% of the CPU. Then the only thing I have to check for is that FillBuffer()
runs in less than 99% of its allotted time, and we’re left with a tiny bit of CPU for Ui::DoEvents()
. You can often find something called PROFILE_INTERRUPT
in my projects. When enabled, this toggles a processor pin at the beginning of FillBuffer()
and at its end. I can monitor this pin with my scope, the period of the waveform is the latency (buffer duration), and the pulse width gives me the CPU time consumed by the audio processing code. I can zoom on the falling edge and see if there are combinations of settings that increase the CPU use. I’ve shown this to a couple of people coding plug-ins or audio software, they were all insanely jealous