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, updating surfaces, 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.
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.