Hermetic Modular

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

Nothing here is required to build a module: Fundamentals and Control Surfaces cover everyday use.

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.

Audio Loopinterrupt · every sample blockcodec in · J1 J2AudioCallback(in, out, n)your DSPthe SDK shim wraps this callcodec out · J9 J10Control Loopmain thread · 16 ms framepots · buttons · CV · USBPoll1 ms × 16Updatesurfaces + OnFrameRenderrings → Show()Tick()LED ringsValue()ISR-safepublished state

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.

Your FirmwareVirtualKnobs · Pages · DSP · main()Surfaces · Opt-InPagerParamLockSettingsCvMatrixPresetsHostSdCardControlLoopUse() slots each surface by role · Tick() runs the canonical frameAlchemyLab · BSPpots · buttons · jacks · LED panel · codec · QSPI flash

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.

the whole wiring
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.

1Inner poll loop× frame length, every 1 msbuttons debounced · CV edges timestampedhost commands executed · OnPoll hook2Snapshotphys[]: pot positions for this frame3Always-on updatesSettings enter gesture · lock playback · CV deltas4Performance updatelock gestures · page advance · pot catchOnPageChange · OnFrame hook5Renderclear → rings → pips · overdraw → page indicator · button colors→ OnRender hook → lock overlay → Show()Tick()

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.

potcatchno jumpsstored+param lock ΔCV Δclamp 0..1Norm()transformLinear · Exp · SelectorValue()your DSPLED ring

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

Need a curve the built-ins don't express? Read 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.

stereo_eq.cpp
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:

kick.cpp
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.

kick.cpp
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:

SlotCadenceUse it for
AudioCallbackevery sample blockthe signal, and block-rate binder reads
OnFrame(fn)every frame, 16 msthe control-rate binder function
OnPoll(fn)every 1 mstap tempo, hold timing, anything gesture-fast
OnRender(fn)every frame, after ringscustom 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.