04/Firmware Anatomy
How a Module Runs
Every Alchemy firmware runs on similar principles: an audio interrupt, a control frame, and a binder function. This page opens that machine up.
The deep dive
The two loops
The processor runs two loops with different owners and different rules.
The audio loop is an interrupt, fired for every block of samples. hw.StartAudio(callback) installs a thin SDK shim: it runs J1/J2 trigger detection over the input block (read-only), calls your function, then overwrites the output channel of any codec jack claimed by EnableCvOutput() with its staged SetVolts() target.
The control loop is the main thread. ControlLoop owns it: polling hardware approximately every millisecond, updating surfaces and rendering LEDs every 16 ms by default. Your main() ends in for (;;) loop.Tick(); and hands the thread over.
The bridge between them is deliberately narrow. The audio side reads control values through ISR-safe accessors like knob.Value(); the control side reads whatever state your DSP publishes into plain volatile floats.
Starting the audio path without application DSP
On Alchemy Lab V2, hw.StartAudio() can run without an application callback:
hw.Init();
hw.StartAudio();
The SDK still processes J1/J2 trigger detection and fills J9/J10 channels claimed for CV output. Unclaimed audio outputs are cleared.
Use hw.StartAudio(AudioCallback) when the application processes audio. A claimed codec CV output is filled by the SDK after that callback returns, replacing the samples written to that channel. DisableCvOutput() returns the channel to the application callback.
The audio callback, control polling, and LED frame are separate schedules. At 48 kHz with 24-sample blocks, the audio callback runs 2,000 times per second. The default control loop polls approximately every millisecond and renders approximately every 16 ms.
Keep formatted diagnostics and blocking peripheral operations on the main/control thread. A diagnostics gauge can publish a scalar from the audio callback without formatting or transmitting USB data there.
The surface stack
Above the hardware, an Alchemy firmware is a stack of surfaces: plain C++ objects, declared at file scope, each owning one interaction concern. A Pager owns page state. A ParamLock owns looping automation. A Presets owns flash slots. None is aware of your DSP; all are optional.
The stack assembles in one chain of Use() calls. Each overload slots its argument into a fixed role inside the loop, so attach order is irrelevant: the frame always runs surfaces in the same canonical sequence.
loop.Use(pager)
.Use(locks)
.Use(settings)
.Use(cv_matrix)
.Use(left_page)
.Use(right_page)
.OnFrame(UpdateCoeffs);
for (;;) loop.Tick();
One frame, step by step
Tick() runs five stages. Buttons and CV edges are polled every millisecond inside the frame, so gestures and triggers never feel quantized to 60 Hz. You can also hang your own control frame code off of this fast cadence.
The value pipeline
A VirtualKnob is not a pot reading. It is the sum of everything that wants to move one parameter: the caught pot position, the param-lock playhead, and the CV input, mixed in normalized space, clamped, then shaped into engineering units.
You read the end of the pipeline. Value() returns the transformed result; Norm() returns the raw 0..1 mix for when you want to shape it yourself. Both are cheap and ISR-safe: call them from the audio callback freely.
Note
Norm() and apply your own shaping. That is its purpose: the escape hatch is part of the API.The binder function, at both rates
The binder function has one job: push those values from the control frame to your DSP. Fundamentals introduces it; here is the mechanical detail. It can run at two rates, and most modules use both.
Control rate, on OnFrame, for derived state that is expensive to compute. Filter coefficients don't need recomputing 48,000 times a second: 60 Hz for the math, sample rate for the signal.
static void UpdateCoeffs()
{
eq_dsp::SetChannel(0, {
l_lo_freq.Value(), l_lo_level.Value(),
l_mid_freq.Value(), l_mid_level.Value(), kMidQ,
l_hi_freq.Value(), l_hi_level.Value(),
});
}
/* in main(): */
loop.Use(left_page).OnFrame(UpdateCoeffs);
Block rate, at the top of the audio callback, for parameters that should be fresh every block:
static kick_dsp::Params CurrentParams()
{
return {
pitch.Norm(),
sweep.Norm(),
static_cast<int>(transient.Value()), /* selector zone */
drive.Norm(),
decay.Norm(),
volume.Norm(),
};
}
static void AudioCallback(daisy::AudioHandle::InputBuffer in,
daisy::AudioHandle::OutputBuffer out, size_t n)
{
const bool trig = hw.buttons[kTriggerBtn].RisingEdge();
env_meter = kick_dsp::Process(CurrentParams(), trig, out, n, hw.SampleRate());
}
Of course there is sample rate as well, but this happens within your DSP as it computes the samples it will return.
Publishing state back
Meters, envelopes, and gate lights flow the other way: from DSP to control code. As an example, in the below case, the callback writes a volatile float, and a ring reads it at render time through .Overdraw(). The kick example's volume ring is the canonical case: a dim base fill, with a live envelope meter painted over it.
static volatile float env_meter = 0.f;
static void DrawVolumeMeter(LedPanel& panel, uint8_t pot,
const ArcGeometry& geo, float norm,
uint32_t t_ms, void* ctx)
{
const float env = *static_cast<volatile float*>(ctx);
DrawLevelArc(panel, pot, geo, env, {0.5f, 0.85f},
{0x00, 0xC0, 0x00}, /* green: body */
{0xC0, 0xC0, 0x00}, /* yellow: hot */
{0xFF, 0x00, 0x00}); /* red: clipping */
}
static VirtualKnob volume = VirtualKnob(5, "Volume")
.Ring(Level({0x40, 0x40, 0x40}))
.Overdraw(DrawVolumeMeter, const_cast<float*>(&env_meter));
Temporal slicing
The machine, summarized as the four slots your code can occupy:
| Slot | Cadence | Use it for |
|---|---|---|
AudioCallback | every sample block | the signal, and block-rate binder reads |
OnFrame(fn) | every frame, 16 ms | the control-rate binder function |
OnPoll(fn) | every 1 ms | tap tempo, hold timing, anything gesture-fast |
OnRender(fn) | every frame, after rings | custom LED painting on top of the SDK's render |
Keep the DSP pure (if you want)
The template's file split is just generally a good idea. *_dsp.* files take floats and buffers and know nothing about knobs, pages, or hardware; the main file owns every SDK type and the binder function is the only door between them. Firmware organized this way ports between projects and keeps its process function readable. That discipline is yours to keep; the SDK just makes it natural.