Hermetic Modular

03/Control Surfaces

The Capability Reference

Every capability in the framework. Use this page as a focused reference.

VirtualKnob

alchemy/surface/virtual_knob.h

A VirtualKnob is one knob/parameter, gathered into one declaration: name, curve and parameter type, LED animation, CV binding, and the metadata the web editor shows. It exists because a knob on this hardware is not always just a potentiometer reading: the value may come from a stored page, be modulated by a param lock or CV, or sit behind pot catch.

Example:

static VirtualKnob cutoff = VirtualKnob(kPotTopLeft, "Cutoff")
    .Exp(20.f, 18000.f)
    .Ring(Level(kAmber, FillAnim::Pulse))
    .Cv(0, 0.6f)
    .Ident("flt.cutoff")
    .Unit("Hz");

const float hz = cutoff.Value();

The essentials are the constructor (a pot position and a name), one transform, a Ring, and Value(). Everything else is optional for additional features.

Constructing and reading

VirtualKnob(pot, "Name")

Binds the knob to physical pot position pot and names it. The name is diagnostic and surfaces in the web editor.

Value() -> float

Get the "finished" summed value: the full mix (catch, lock, CV), clamped, then passed through the declared transform. Cheap and ISR-safe.
Use it forfundamental accessor, e.g. your DSP consumes a parameter, for the audio callback, animations, etc.

Norm() -> float

The same mix as the Value() accessor, but before the transform to the defined unit. In other words, always 0..1.
Use it forcustom tapers, lookup tables, or other times you just want to know summed pot position.

Transforms (choose one; default is Linear(0, 1))

.Linear(min, max)

Even sweep from one end to the other.
Use it forgains in dB, mix amounts, times, anything perceived linearly.

.Exp(min, max)

Geometric sweep: min × (max/min)ⁿ. Requires min > 0 (falls back to linear otherwise).
Use it forfrequencies and anything the ear hears logarithmically.

.Selector(n)

Divides travel into n zones; Value() returns a whole number 0 to n-1 as a float.
Use it formode and engine pickers. Pair with a SelectorRing so the zones are visible and .Labels() so hosts name them.

Ring, pip, and overdraw

The looks themselves are cataloged in LED Animations; these are the attachment points.

.Ring(style)

Sets the declarative ring. Styles: Level (filled arc), Bipolar (two-color fan around a pivot), SelectorRing (discrete zones), Gradient (color morph across snap points), GradientFill (fill tinted by a sibling pot), or Custom(fn, ctx) to take the ring over entirely. Omit for a dark ring.

.Pip(spec)

Paints the off-arc bottom LED, independent of the ring style. SolidPip is a fixed marker; ThresholdSnapPip(c, lo, hi, over) changes color inside a value window; GradientSnapPip lights in the morph color when a Gradient ring sits on a snap.
Use it forunity-gain markers, detent confirmation, "you are on a snap" feedback.

.Overdraw(fn, ctx)

Paints fn on top of the declarative ring each frame, same signature as Custom. The ring keeps showing the value; your layer rides above it.
Use it formeters and envelopes over a value fill, like the kick example's volume ring.

CV binding

.Cv(index, attenuation = 1.0, bipolar = true)

Static binding: CV jack index modulates this knob. attenuation scales the contribution; bipolar treats raw 0..1 CV as ±0.5 around center (the eurorack convention), false forwards it unipolar.
Use it forfixed wiring without a matrix object. When a CvMatrix is attached to the loop it owns CV entirely and these bindings are ignored; pick one scheme per firmware.

Host metadata (all optional)

Without any of these the knob still appears in the web editor, named after its constructor name with a display hint derived from its transform.

.Ident("flt.cutoff")

A stable field id. Defaults to the positional p<page>.<pot>.
Use it forkeeping host presets addressable: set one early and saved presets survive the knob later moving to a different pot or page.

.Unit("Hz")

Engineering unit shown next to the value in the editor.

.Labels(names, n)

Zone names for a Selector knob (array of string literals, static lifetime). Hosts show e.g. "Series / Parallel / Spectral" instead of 0 / 1 / 2.

.Disp(json)

Raw display-hint JSON (protocol §5), overriding the derived hint entirely.
Use it forexplaining things in the web browser hostlink.

Note

A knob used without a ControlLoop attachment reads the physical pot directly, no catch, no pages. That is why examples/kick can skip the Pager entirely and still read pitch.Norm().

VirtualButton and ButtonBank

alchemy/surface/virtual_button.h · alchemy/surface/button_bank.h · docs/buttons.md

VirtualButton is the twin of VirtualKnob: one declaration names a physical button, its gestures, and, when the button carries state, that state's shape (zone count, labels, colors, default). The object itself holds no state. A ButtonBank is its storage backend the way a Pager is a knob's: it persists one byte per stateful button in presets, recognizes tap and hold gestures, paints the button LEDs, and shows up in the web editor as an editable field per button.

static const char* kModes[3] = {"LP", "BP", "HP"};

static VirtualButton flt = VirtualButton(kButtonB3, "Filter")
    .Ident("flt.mode")             /* stable host id               */
    .Selector(kModes)              /* 3 zones, labeled, tap-cycles */
    .Colors(kModeColors)           /* per-zone LED feedback        */
    .Bind(SetFilterMode);          /* fires on gesture AND preset load */

static Page       page_a = Page(0).Knobs(k1, k2).Buttons(flt);
static ButtonBank buttons;

presets.Manage(buttons);
loop.Use(page_a).Use(buttons);

Zones, labels, the tap-cycle gesture, its browser label, the preset byte, and the LEDs all derive from that one declaration. Pages are pure composition: Page::Buttons says which buttons are active on which page, so the same physical button can do different things on different pages by declaring one VirtualButton per page.

Reading the physical button

hw.buttons[i].Pressed() -> bool

Debounced held state, right now.

RisingEdge() / FallingEdge() -> bool

True exactly once per press or release. Latched, so polling from the audio callback (like the kick trigger) or the 1 ms poll both work.
Use it fortaps, triggers, and gesture starts and ends.

TimeHeldMs() -> float

How long the current press has been held.
Use it forhold gestures and long-press menus of your own design.

Declaring the button and its state

VirtualButton(kButtonB3, "Filter")

Physical button index and display name, mirroring VirtualKnob(pot, name). Pass the board constant, not a bare 0: a literal 0 is ambiguous with the other constructor and fails to compile.

VirtualButton("ident", "Name")

The no-hardware form: host-only state that persists and is editable in the browser, with no panel gesture. Also the form for legacy metadata-only tables.

.Ident("flt.mode")

Stable field id, defaulting to the positional b<hw>. Set one to keep host presets addressable if the button later moves, and always when two buttons share a hardware index.

.Selector(n) / .Selector(labels) / .Toggle()

Persisted N-zone state, one byte in the preset blob. The labeled form deduces the zone count from one array. Toggle() is two zones.

.Default(zone) / .Labels(...) / .Colors(per_zone)

Factory-default zone, zone labels, and per-zone LED colors the bank paints while the button's page is active. Counts are checked against the zone count when the bank freezes.

Gestures and effects

A stateful button with no declared gesture tap-cycles its zones, and the browser label is derived from the zone labels. Declare gestures only to change that.

.Tap(action | fn) / .TapSet(zone)

What a tap does: Cycle, Toggle, Set, jump to a specific zone, or call a function (a momentary button: zero preset bytes).

.Hold(ms, action | fn) / .HoldSet(ms, zone)

The same choices on a hold, with the threshold in milliseconds.
Use it fora short and long press action on one button.

.Bind(&target) / .Bind(setter)

Write the zone into a variable, or call a setter with it, on every change: gesture, SetZone, or preset load.

.OnChange(fn, ctx)

General change notification, fired after any Bind targets.

flt.Zone() -> uint8_t

The current zone, read through the bank. The normal read path.

The bank

ButtonBank buttons; loop.Use(buttons); presets.Manage(buttons);

Declare one. Use wires the board's buttons and the loop's pages into it, polls gestures at 1 ms (gated by Settings like every button surface), and renders the per-zone colors. Manage makes every stateful button one byte in each preset.

buttons.Global(b) / buttons.Pages(p1, ...)

Global registers a button active on every page, outside any page's list. Pages hands the bank an explicit page roster, for firmware running without a ControlLoop page source. The bank holds up to 16 stateful buttons and 4 globals.
Use it fora shift key or a global mode toggle that should work everywhere.

buttons.Ok() -> bool

False if a declaration error latched (a bad default zone, a label count mismatch, a stateful button first seen after freeze). A bad roster also fails the descriptor build, with the reason in DescriptorBuilder::LastError().

buttons.SetZone(b, zone) / buttons.ZoneOf(b)

Programmatic state access. Zone() on the button is the normal read; these cover host commands and code that drives a button's state directly.

Host metadata

.Anchor("flt.cutoff")

Attach this button to a named field, like its companion knob: hosts render it with that field wherever the field appears. The id must name a field the descriptor emits; a miss fails the descriptor build.

.Role(...) / .Action(...) / .Controls(...)

The older metadata-only form, still valid including as constexpr tables: labels gestures and points at fields for buttons a surface like the Pager already owns. Bank-managed buttons need none of it.

Page and Pager

alchemy/surface/page.h · alchemy/surface/pager.h

A Page is a page index plus references to knob objects. A Pager is what controls the state: it owns the current page index, stores every (page, pot) value, consumes one button for the advance gesture, and runs pot catch. They are separate because pages are composition (which knobs appear together) and the pager is state (which page is live, what every stored value is).

static VirtualKnob cutoff  = VirtualKnob(kPotTopLeft,     "Cutoff").Exp(80.f, 12000.f);
static VirtualKnob reso    = VirtualKnob(kPotTopRight,    "Reso");
static VirtualKnob drive   = VirtualKnob(kPotMiddleLeft,  "Drive");
static VirtualKnob env_amt = VirtualKnob(kPotMiddleRight, "Env Amt");
static VirtualKnob attack  = VirtualKnob(kPotBottomLeft,  "Attack");
static VirtualKnob release = VirtualKnob(kPotBottomRight, "Release");

static Page filter = Page(0).Knobs(cutoff, reso, drive,
                                   env_amt, attack, release).Name("Filter");
static Page mod    = Page(1).Knobs(rate, depth, shape,
                                   offset, rise, fall).Name("Mod");
static Pager pager(hw.buttons[kButtonB1], 2, kNumPots);

loop.Use(pager).Use(filter).Use(mod);

Page: composition

Page(idx).Knobs(k1, k2, ...)

Construct a page bound to index idx and append knobs, fluently.

.Add(knob) / .Remove(knob) / .Clear()

Runtime mutation. The loop re-reads each page's knob list every frame, so a change takes effect on the next Tick().
Use it forreconfigurable layouts: a settings option that swaps which parameters a page exposes, or a mode that hides controls entirely. See: Echoa's alternate layout.

.Buttons(b1, b2, ...) / .AddButton(b) / .RemoveButton(b)

The buttons active while on this page, mirroring Knobs(): duplicates are no-ops, overflow is dropped, and runtime add or remove takes effect next frame. A ButtonBank attached to the same loop persists, dispatches, and describes them per page.

.Name("Filter") / .Color("#c9a84c")

Tab identity for the web editor: label and tint for this page's tab. String literals only.

Index() / Count() / At(i) / NumButtons() / ButtonAt(i)

Read accessors: the page index, then the knob and button lists by count and position.

Pager: state and gestures

Pager(button, num_pages, num_pots)

The advance button (canonically B1), page count (1..8), and pots per page (1..8). A clean press-and-release advances to the next page, wrapping.

Page() / ActivePage()

The current page index.

Value(pot) -> float

Catch-aware stored value for a pot on the current page: the number a knob on this page resolves against.
Use it forcomposing values by hand when you bypass VirtualKnob; with knobs attached you rarely call it.

Stored(page, pot) -> float

Stored value for any (page, pot), including inactive pages.

Caught(pot) -> bool

True once the physical pot has caught the stored value on the current page.
Use it forcustom UI that hints "this pot is live" vs "still waiting to catch".

SetStored(page, pot, value, phys)

Programmatically set a stored value and re-arm catch: the user must move through the new value before the pot responds.
Use it forapplying host edits, morphs, or randomizers without a value jump.

LockPage(page, phys)

Re-arm catch on every pot of a page at once.
Use it foranything that rewrites values wholesale, like a preset load. The framework already does this on preset apply and on Settings exit.

GoToPage(page, phys)

Jump straight to a known page, re-arming catch exactly as the advance does — a jump can never make a parameter leap. Out-of-range is ignored; a same-page jump still re-arms.
Use it for"home" gestures, presets that pin a page, host commands. Edge-trigger a held gesture — re-issuing it every frame keeps the pots permanently uncaught — and pair with ConsumeButton() when the gesture involves the pager's own button.

SetPageColor(page, rgb)

Per-page indicator color; the loop paints the advance button with the active page's color each frame.
Use it formaking "which page am I on" legible at a glance.

ConsumeButton()

Suppress the next release-triggered page advance. Surfaces that claim a B1 gesture in the same frame (ParamLock's hold-and-nudge, a chord handler) call this so releasing the hold doesn't also flip the page. ControlLoop already runs consumers before the pager; only hand-rolled loops need to care about the order.

Note

Persistence: the pager serializes only the stored values. Page index and catch state are rebuilt on load, and every page comes back locked, so a preset load never causes a jump either.

Jacks and CvMatrix

alchemy/hw/cv_jack.h · alchemy/surface/cv_matrix.h

The jack API (hw.j3 .. hw.j10) controls which direction a jack points and what voltage is on it. This is an electrical change to the circuit of the Alchemy Lab. The CvMatrix maps what an incoming CV signal means, for incoming destinations per jack.

/* Electrical: J8 becomes a CV output. A physical switch is flipped in the analog front end. */
hw.j8.EnableCvOutput();
hw.j8.SetVolts(2.5f);

/* Semantic: what arriving CV does. */
static CvMatrix cv_matrix(kNumCvInputs);
cv_matrix.Jack(0).To(cutoff).Atten(0.6f);
cv_matrix.Jack(1).Custom(OnTap, &tap_state);
loop.Use(cv_matrix);

Compare this with the CV conveninence in the VirtualKnob class and only use one.

The jack: direction and volts

EnableCvOutput() / DisableCvOutput()

Direction control. J3..J8 default to CV input; enabling closes the analog switch that routes the backing DAC to the jack. On J9/J10 enabling claims that codec channel from your audio callback and fills it with the SetVolts target. Disable returns the default.
Use it fora fixed panel set once at boot, or live flips (from a settings option, a gesture, the patch itself) when firmware reconfigures its own jack field.

SetVolts(v) -> bool

Drive the jack toward v (±5 V), calibration applied. Until EnableCvOutput() the code is staged but the jack stays disconnected.

Volts() -> float

Calibrated jack voltage. On J3..J8 it is the ADC reading. J9/J10 have no readback (they are the DC coupled codec out only) and return the last target.

Value() -> float

The raw normalized 0..1 reading, before voltage mapping.
Use it forcode that wants pot-like normalized CV rather than volts.

IsCvOutput() -> bool

Current direction.

Driving CV from the DSP is one line in the binder function. Publish a value from the audio callback, write it to a jack each frame:

static volatile float env_follow = 0.f;   /* published by the audio callback */

/* at boot: hw.j8.EnableCvOutput(); */

static void Bind()
{
    filt.SetCutoff(cutoff.Value());
    hw.j8.SetVolts(5.f * env_follow);
}
loop.OnFrame(Bind);

CvMatrix

CvMatrix(num_jacks)

Dispatches that many jacks, 1:1 with the physical CV jack indices. All jacks start Off.

.Jack(n).To(knob)

CV on jack n modulates a VirtualKnob: its contribution joins the knob's normalized mix. Setting a destination and changing it are the same call, any time, including from OnFrame or a settings option.
Use it forthe normal case: CV modulating parameters, statically or re-routable.

.Jack(n).Custom(fn, ctx)

CV on jack n fires your callback from the 1 ms poll, with a microsecond timestamp. The value never touches a knob.
Use it fortap tempo, gates, triggers, clocks, or CV feeding the DSP directly according to your needs.

.Jack(n).Off()

CV is consumed with no effect (the default).

.Atten(v)

Attenuation multiplier before the knob accumulates: 1.0 identity, -1.0 inverts, 0.0 mutes. Ignored for Off and Custom.

.Bipolar(b)

Default true: raw 0..1 CV reads as ±0.5 around center, the eurorack convention. False forwards unipolar 0..1. Ignored for Off and Custom.
Use it forunipolar envelopes and offsets that should only push a value one direction, for whatever reason.

Note

Attaching a CvMatrix to the loop makes it the sole owner of CV: any per-knob .Cv() bindings are ignored while it is attached. One scheme per firmware. CvMatrix is canonically the more powerful tool, per know .Cv mapping is a convenience if you simply want to map a jack to a knob.

Clocks and Timing

alchemy/control/musical_clock.h · clock_follower.h · cv_edge.h · clock_div.h

The timing toolkit turns an external clock, a tap, or an internal tempo into something leverageable.MusicalClock is a free-running oscillator that integrates a rate and emits bar, beat, continuous phase, and a subdivision event mask. ClockFollower steers that timeline from outside pulses. CvEdge (and its gate twin) turns raw CV into clean, timestamped edges. The ClockDiv tables map a locked tempo onto musical delay divisions.

Pulses never move the phase, only the rate. The timeline is monotonic, so a playhead or gate derived from BeatPhase() never jumps backward and never clicks, even while the follower is pulling tempo. Everything runs in the 1 ms poll; nothing here touches the audio interrupt.

The Clock
static MusicalClock  clock_; 
static ClockFollower follower(clock_);
static CvEdge        clk_edge;

static void OnPoll(uint32_t t_ms)
{
    const uint32_t now_us = daisy::System::GetUs();

    clk_edge.Tick(loop.Cv(), loop.NumCv(), now_us);
    if (clk_edge.JustRose(kClockCvChan))
        follower.OnPulse(clk_edge.LastRiseUs(kClockCvChan));

    follower.Update(now_us);   /* PI loop + loss detection */
    clock_.Tick(now_us);       /* integrate rate -> events */
}

/* One-time setup, before the loop starts. */
clk_edge.Init(kClockCvChan + 1);        /* defaults to 0 channels    */
follower.SetAutoStart(true);            /* run the timeline on lock  */
follower.Enable(kClockExtPpqnDefault);  /* 24 PPQN; off until enabled */

loop.OnPoll(OnPoll);

That is the whole routing: patch the clock into a CV jack, do the one-time setup, and the three calls in OnPoll carry it from jack to timeline. CvMatrix doesn't impact this: whether you run one or not, and whatever it routes, clock intake never passes through it. Additionally, you can use the audio codec inputs for clock edges.

MusicalClock

Internally the clock runs at 96 PPQN, chosen so every straight, triplet, and dotted subdivision lands on an exact tick. The 20..300 BPM clamp is on SetBpm only; a follower writes the rate directly, so a slow external clock can hold it below 20 BPM. It does not know whether its rate comes from SetBpm or a follower, and everything downstream reads it as the single source of musical truth.

MusicalClock(ppqn = 96, beats_per_bar = 4)

The resolution (must be a multiple of 24; the default is right for nearly everyone) and the time-signature numerator, which only the bar and multi-bar events depend on. You do not need to lower this if you want to drive it at a lower PPQN input from a jack!

Tick(now_us)

Advance the timeline. Call once per poll with the current microsecond timestamp (daisy::System::GetUs()). The clock measures the real time elapsed since the last call, moves position forward by rate × elapsed, and records every musical boundary crossed on the way; Events() reports those crossings. Because it advances on measured time, not counted calls, jittery polling does not warp the tempo. And if the loop stalls outright, catch-up is capped at 50 ms, so a stall cannot fire a burst of missed beats when polling resumes.

Start() / Stop() / Reset()

Transport. Start begins advancing; Stop halts in place with position held; Reset snaps to the top of the bar. Each takes effect at the next Tick and announces itself in Events() (START, STOP, RESET), so downstream code reacts to transport the same way it reacts to beats. If a follower is steering, pair Reset with the follower's OnReset() so the PLL re-anchors instead of reading the jump as error and dragging the tempo.

SetBpm(bpm) / SetTimeSignature(beats)

Set the internal tempo and the bar length. While a follower steers, SetBpm is silently ignored, so a tempo knob or settings control can keep calling it without checking who owns the clock.
Use it forinternal-clock modules, tap tempo, or a tempo settings control.

BeatPhase() / BarPhase() -> float

Where you are in the current beat (or bar), as a continuous 0..1 ramp. This is the accessor to derive motion from: it only ever moves forward, smoothly, even while the follower is pulling the tempo, so anything driven by it stays click-free.
Use it forLFOs, tremolos, playheads, and ring animations that should clock sync.

Events() -> uint32_t

The CLK_EV bitmask of musical boundaries the most recent Tick crossed. A crossing appears for exactly one Tick. See list of events below.
Use it fortriggering envelopes, steps, and flashes exactly on musical boundaries.

Bar() / Beat() / TickInBeat()

The position as integers: which bar since the last Reset, which beat inside that bar, which internal tick inside that beat. Use these when something counts or displays position (a step readout, a bar counter); when something moves, use the phase accessors above.

Running() -> bool

True between Start and Stop. While false the timeline stops advancing, but events still fire: the STOP itself, a Reset (which snaps position to zero and fires every downbeat flag), and follower flags like EXT_LOCK / EXT_LOST.

Bpm() -> float

The tempo the clock is actually running at, whether it came from SetBpm or a follower. The number to display, and the source for the ClockDiv math below: 60.f / clock_.Bpm() is the quarter-note period in seconds.

SetTicksPerUs(tpu) / SetSteered(on)

SetTicksPerUs writes the clock's rate directly, in internal ticks per microsecond. SetSteered(true) tells the clock an external source now controls that rate; from then on, SetBpm calls are ignored so they cannot overwrite it. If you use ClockFollower, it manages all of this itself and you never call these.
Use it forwriting your own sync source: MIDI clock intake, a custom PLL.

OrPendingEvent(flags)

Add flags to the next Tick's event mask. This is how the follower's EXT_LOCK / EXT_LOST arrive in the same Events() read as the musical flags, and it is the hook for announcing events from a custom source the same way.

The full CLK_EV set, by family:

FamilyFlags
TransportSTART · STOP · RESET
StraightSIXTYFOURTH · THIRTYSECOND · SIXTEENTH · EIGHTH · QUARTER · HALF · WHOLE · BAR
TripletTHIRTYSECOND_T · SIXTEENTH_T · EIGHTH_T · QUARTER_T · HALF_T · WHOLE_T
DottedDOTTED_16TH · DOTTED_8TH · DOTTED_QUARTER · DOTTED_HALF · DOTTED_WHOLE
Multi-barTWO_BAR · FOUR_BAR · EIGHT_BAR
Clock gridMIDI_CLOCK: the 24 PPQN pulse grid, for emitting MIDI clock
External clockEXT_LOCK · EXT_LOST: set by the follower on lock and loss
const uint32_t ev = clock_.Events();
if (ev & CLK_EV::QUARTER)  StepSequencer();
if (ev & CLK_EV::EIGHTH_T) TripletAccent();
if (ev & CLK_EV::EXT_LOST) ShowUnlockedRing();

ClockFollower: locking to an external clock

A PI-controlled PLL. It receives timestamped pulses from an edge detector and continuously adjusts the clock's rate to match them. It reports lock after three consecutive valid pulses. It ignores pulses that would imply a tempo outside 20..300 BPM. If no pulse arrives for three expected periods in a row, it reports the clock lost; what happens then depends on chosen policy: an effect usually wants Freewheel (keep the last tempo through the dropout), a sequencer wants Freeze (hold position until re-lock) or Stop.

ClockFollower(clock)

Construct with the MusicalClock it will control. One follower per clock.

Enable(ext_ppqn = 24) / Disable()

Start or stop following. Enable stops the clock and zeroes its rate, so it does not keep running at the old tempo while waiting for the first lock. Disable leaves the last locked rate in place; call SetBpm after it to go back to an internal tempo. ext_ppqn is how many pulses arrive per quarter note: 24 is the MIDI standard, and any value that divides the internal 96 works, so a 1 or 4 PPQN clock is fine too.

OnPulse(stamp_us)

Give the follower one rising-edge timestamp, straight from CvEdge::LastRiseUs. Cheap and safe to call from anywhere, including an interrupt. The processing happens later, in Update.

Update(now_us)

Does the follower's work, once per poll: updates the tempo estimate from any new pulse, adjusts the clock's rate toward the measured timing, and checks whether the external clock has stopped. OnPulse only stores the timestamp; this is where it takes effect.

SetLossPolicy(Freewheel | Freeze | Stop)

What the timeline does when the external clock disappears.
Use it forFreewheel for effects, Freeze or Stop for sequencers. Default Freewheel.

SetAutoStart(on)

Automatically start the transport on first lock.

Locked() / Bpm() / Enabled()

Locked() is false until three consecutive valid pulses have arrived; use it to gate synced behavior, like switching a delay into synced mode. Bpm() is the estimated tempo of the external clock, for display. Enabled() reports whether following is currently active.
Use it forgating synced behavior and driving lock indicators on the panel.

OnReset()

Call alongside MusicalClock::Reset(). It re-anchors the follower's phase reference to the new downbeat. Without it, the PLL treats the position jump as phase error and pulls the tempo off while it corrects.

SetExtPpqn(n)

Change the assumed input resolution at runtime; the follower re-locks smoothly. This is what a clock-resolution settings option should call.

SetGains(kp, ki, alpha)

Override the PLL tuning constants, for advanced use cases.

CvEdge and CvGate: clean edges from CV

CV inputs are continuous readings; clocks and gates need edges. CvEdge is Schmitt detection with debounce: a high threshold to trigger, a lower one to release, so a wobbling signal fires once per pulse instead of chattering. JustRose / JustFell / Level map those edges onto poll ticks (rose this tick, fell this tick, currently high), and LastRiseUs timestamps them for clock math. A channel is the index of the CV input being watched: channel n reads entry n of the buffer you pass to Tick.

There are two flavors because of the thresholds: 0 V reads as 0.5 on bipolar-conditioned inputs, and CvEdge sits symmetric around that (0.30 / 0.70), right for pulses. A gate returns to 0 V and never below it, so CvGate puts both thresholds above rest (0.55 / 0.65, no debounce) so gates actually release.

Init(num_channels) / Init(num_channels, cfg)

Up to eight channels per instance, one threshold set per instance. Config is three fields: lo_threshold, hi_threshold, and debounce_us (CvEdge defaults 0.30 / 0.70 / 500 µs).

Tick(cv, num_cv, now_us) -> rising mask

Process one poll of readings (feed it loop.Cv()). Returns the bitmask of channels that just rose; falling edges via FallingMask().

JustRose(ch) / JustFell(ch) / Level(ch)

Did this channel rise or fall during the most recent Tick, and is it currently high. The edge flags last exactly one tick; Level is continuous.
Use it forLevel for envelope gates: it reports whether the gate is currently held.

LastRiseUs(ch) / LastFallUs(ch)

Microsecond timestamps of the most recent edges.
Use it forfeeding ClockFollower::OnPulse, tap-tempo math, measuring gate lengths.

ClockDiv: musical divisions of a locked tempo

Free functions for clock-synced time controls. While a clock is locked, the time pot selects among thirteen musical divisions of the quarter note (64th to double whole, triplets and dotted values included) instead of sweeping seconds. The availability mask excludes divisions your engine cannot produce (too short or too long) so they can never be selected.

Think of these as helper functions to make it easier to work with common clock use cases.

ClockDivAvailableMask(quarter_s, min_s, max_s) -> uint16_t

Bit i set when division i lands inside your engine's real time range. Compute the quarter period as 60.f / clock_.Bpm().

ClockDivZoneFromNorm(norm, avail_mask) -> zone

Map a pot's 0..1 value to the nearest available division, so the sweep skips unavailable divisions instead of landing on them.

ClockDivSeconds(zone, quarter_s) -> float

The division's period in seconds: the value to pass to your delay line.

ClockDivNearestZone(target_s, quarter_s, avail_mask)

The available division closest to a free-running time. Call it at the moment of lock to snap the pot's current delay to the nearest musical value, so the delay time does not jump when sync engages.

ClockDivNormFromZone(zone) -> float

The pot position at a zone's center. Use it to re-seed a stored knob value after snapping, so the pot position and the selection agree.

ClockDivIsTriplet(i) / ClockDivIsDotted(i) / ClockDivIsQuarter(i)

Predicates on a zone index, for rendering: tint triplet and dotted zones differently, or mark the 1:1 quarter zone.
clock-synced delay time
const float quarter_s = 60.f / clock_.Bpm();
const uint16_t avail  = ClockDivAvailableMask(quarter_s, kMinDelayS, kMaxDelayS);

if (follower.Locked() && avail)
{
    const uint8_t zone = ClockDivZoneFromNorm(time_knob.Norm(), avail);
    delay.SetTime(ClockDivSeconds(zone, quarter_s));
}
else
{
    delay.SetTime(time_knob.Value());     /* free mode: seconds */
}

ParamLock

alchemy/surface/param_lock.h · alchemy/control/lock_length.h

Looping automation, per pot, recorded by gesture: hold the trigger button and nudge a pot, and the motion records into a circular buffer that replays as a loop on top of the stored value. Repeat the gesture (nudge) on a playing pot to remove the automation.

static ParamLock<6>  locks(hw.buttons[kButtonB1]);          /* unpaged, 16 s   */
static ParamLock<12> locks(hw.buttons[kButtonB1], pager);   /* 2 pages x 6     */

static ParamLock<6, LockLength<30>> long_locks(hw.buttons[kButtonB1]);   /* 30 s */

loop.Use(locks);

The first template parameter is the total slot count. Unpaged, it equals the pot count and Delta(p) reads slot p. Paged, it must equal pages × pots, and every (page, pot) pair gets its own independent loop. Playback on inactive pages keeps cycling.

The second is a capacity policy: LockLength<seconds, rate_hz = 30, store = Preset>, defaulting to 16 seconds. Seconds mean seconds: the buffer is clocked in milliseconds, so changing the loop's frame rate never rescales a recording. For locks longer than presets can hold, LockStore::RamOnly drops them from the preset blob entirely.

Reading playback

Delta(pot) -> float

The modulation amount for a pot on the visible page, without advancing playback. Knobs attached to the loop already include this in Norm().
Use it forhand-rolled composition when you bypass VirtualKnob.

IsActive(pot) / IsRecording(pot)

Whether the visible page's slot is playing back, or currently recording.
Use it fordriving custom ring feedback beyond the built-in pips.

DeltaAtPage(page, pot) / IsActiveAtPage / IsRecordingAtPage

The same reads for any (page, pot), ignoring the current page.

AnyRecording() / AnyActive()

True if any slot on the visible page is recording, or playing.

IsButtonHeld() -> bool

True while the trigger is held, between rising and falling edge.
Use it forcoordinating your own B1 chord gestures with the lock gesture.

Control and persistence

Clear()

Reset every slot to inactive.
Use it fora panic gesture, or initializing a fresh preset.

Save(out[]) / Restore(in[])

Manual persistence of all slots as raw bytes. With presets.Manage(locks) you never call these; the Serializable path round-trips the same bytes. They are also the only route for LockStore::RamOnly locks, e.g. writing them to the SD card yourself.

Render(panel, t_ms)

The built-in overlay: a red pip at 6 o'clock while recording, green while playing. The loop calls it automatically when the surface is attached.

Presets

alchemy/surface/presets.h · alchemy/surface/serializable.h

Sixteen flash slots, each holding the whole surface state. Serialization is handled by every framework surface (Pager, ParamLock, Settings); they already implement the Serializable contract, and Manage() registers each one into the slot payload. Registration order is the on-flash byte layout. Writes are wear-levelled across ping-pong sectors, and every slot is stamped with a schema hash of the managed set, so a firmware whose layout changed reads old slots as empty instead of corrupting state.

static Presets presets(hw.seed.qspi);

presets.Manage(pager);      /* order matters: it IS the layout */
presets.Manage(locks);
presets.Manage(settings);
presets.Init();             /* once, after hw.Init()           */
presets.BootLoad();         /* restore slot 0 at power-on      */

The core calls

Presets(hw.seed.qspi)

Bind to the board's QSPI flash. Nothing touches flash until Init().

Manage(serializable)

Register a component for save/load, before Init(). Up to eight; order defines the byte layout and feeds the schema hash, so keep it stable across firmware versions.

Init()

Scan slot headers and prepare the store. Exactly once, after hw.Init() and every Manage() call, before StartAudio() is safest.

Save(slot) / Load(slot) -> bool

Capture every managed component into a slot, or restore one. False on invalid slot, schema mismatch, or flash failure.
Use it foryour own gestures or host commands. The stock panel gesture comes from Settings, below.

BootLoad() -> bool

Restore slot 0 if it holds a valid record at boot-time; a convenience.

HasValid(slot) -> bool

True if the slot holds a CRC-verified record matching the current schema.
Use it forbuilding slot pickers that show which slots are occupied.

EraseSlot(slot) -> bool

Invalidate a slot, both ping-pong sides.

UseNames() -> PresetName&

Store display names inside the blob itself, so names travel with the hardware and with exports. Pinned last in the layout regardless of when you call it; hosts read and edit the names through the descriptor.

Bringing your own state

Anything outside the standard surfaces rides along by deriving from Serializable: report a size, write bytes, read bytes, and return a schema hash that captures the layout's shape. Then Manage() it like the rest.

struct ModState : alchemy::Serializable
{
    float   depth = 0.f;
    uint8_t shape = 0;

    size_t SerializedSize() const override { return 5; }

    void Serialize(uint8_t* out) const override
    {
        std::memcpy(out, &depth, 4);
        out[4] = shape;
    }

    bool Deserialize(const uint8_t* in) override
    {
        std::memcpy(&depth, in, 4);
        shape = in[4];
        return true;
    }

    uint32_t SchemaHash() const override { return 0x4D4F4431; } /* 'MOD1' */
};

static ModState mod_state;
presets.Manage(mod_state);   /* now it saves and loads with everything else */

Bump the schema hash deliberately when a layout change should invalidate existing slots; two builds with the same shape read each other's data.

Note

Capacity is generous but finite: a preset slot holds 20,448 bytes for all managed components combined, and the ceiling is hard: the preset region ends at the last byte of the QSPI chip, so it cannot grow. Long param locks are the usual budget eater; see the cost model in the SDK's docs/param-locks.md. A single oversized ParamLock fails at compile time; the combined managed set is checked at run time, where Save() returns false and the preset never appears. PayloadBytes(), Capacity() and FitsInSlot()report that budget before the first save.

Settings

alchemy/surface/settings.h · alchemy/surface/settings_control.h

Helpful default settings behavior, with the enter gesture, up to four pages of declarative controls, their rendering, and their persistence. Hold B2+B3 for two seconds to enter; tap B2 or B3 to leave; B1 cycles settings pages while inside. On exit it re-locks the perf pager so every pot re-catches. Stock options come with, and additional controls can be added.

static Settings settings(hw, &pager);

settings.UseBrightness();               /* page 0, pot 0            */
settings.UsePresets(presets);           /* pots 2 + 3: save / load  */

settings.Page(1).Name("Mod")
        .Pot(0).Selector(3);            /* a custom 3-zone option   */

loop.Use(settings);
presets.Manage(settings);               /* use the serialization    */

Stock options

UseBrightness() -> BrightnessHandle

Global LED brightness on (page 0, pot 0): range 0.05..1.00, default 0.30. The handle refines it: .Range(lo, hi), .Default(v), .Color(rgb), and .Value() reads the current setting. The header's own warning stands: these LEDs get insanely bright, and hot. Don't push it.

UsePresets(presets) -> PresetGestureUi&

The stock save/load UI, wired to your Presets store. Reserves (page 0, pot 2) as the slot selector and (page 0, pot 3) as the action pot. See the gesture below.

The preset gesture, exactly

Inside settings, the slot pot sweeps the sixteen slots; the ring shows the selection as four color groups of four arcs. The action pot commits: park it hard counter-clockwise and hold for three seconds to save into the selected slot, hard clockwise and hold to load. A progress arc fills during the hold, a white flash confirms the commit, and the commit calls presets.Save() / Load() directly.

Two protections are built in. Entering settings disarms the action pot: if it was already parked at an edge, nothing fires until you bring it through the neutral middle zone first, so there are no accidental saves on entry. And because loading rewrites the perf pager's values, the load path re-locks every page: pots wait to re-catch, and leaving settings does the same.

Note

The gesture is a separable piece: PresetGestureUi can be instantiated directly, bound to any two pot values, if you want save/load outside settings mode. And if you want neither, skip both and call presets.Save(slot) from your own gesture; the store does not care who calls it.

Custom controls

Declare controls per (page, pot) through the builder; each returns a typed handle whose setters refine it and whose .Value() reads it back at runtime. Keep the handle if you need the read.

Page(i) / Page(i).Name("Mod")

Access settings page i (0..3), auto-creating intermediate pages. Name labels the page's tab in the web editor.

Page(i).Pot(p).Knob() -> KnobHandle

A plain stored 0..1 value with a level ring: .Default(v), .Color(rgb), .Anim(fill_anim).

Page(i).Pot(p).Bipolar() -> BipolarHandle

A signed -1..+1 value rendered as a two-arm fan: .Default(v), .Color, .AltColor, .CenterColor.

Page(i).Pot(p).Selector(n) -> SelectorHandle

A discrete option with n zones: .Default(idx), .Colors(palette), .InactiveColor, .InactiveDim, .Geometry; .Value() returns the zone index.

Page(i).Pot(p).Brightness() -> BrightnessHandle

A brightness-style mapped control at a custom position.

Page(i).Pot(p).Custom() -> CustomHandle

Full control: .Tick(fn) runs your update against the physical pot, .Render(fn) draws the ring, .Ctx(p) passes context, .Default(v) seeds the value at every power-on.
Use it foranything the typed controls don't express. The value is not persisted; for a persisted value use a typed kind and paint in an overlay.

State and appearance

IsActive() -> bool

True while settings mode is open. The loop reads it to gate perf surfaces.

CurrentPage() / NumPages()

Which settings page is visible, and how many exist.

SetIndicatorColor(rgb)

The color painted on the B1 pair while settings is active (default orange).

Settings(hw, &pager, hold_ms)

The constructor: board, optional perf pager (enables the exit re-lock), and the enter-hold duration (default 2000 ms).

Note

Settings is itself a Serializable: presets.Manage(settings) makes every declared control's value part of each preset (Custom() slots excepted), and it is the only persistence settings has. Leave it unmanaged and every option resets to its default at power-on.

ControlLoop and the Binder

alchemy/surface/control_loop.h

ControlLoop is the conductor: it owns the main thread, polls hardware every millisecond, runs each attached surface in a fixed canonical order every 16 ms frame, renders the rings, and calls your hooks. You hand it surfaces with Use(); because each overload slots its argument into a fixed role, attach order never matters, because the point of teh ControlLoop is to sequentialy execute in a safe way.

Many developer use cases involve creating your own control loop to do something advanced. Take the control loop, copy it in your main function, and modify as desired.

static ControlLoop loop(hw);

loop.Use(pager).Use(locks).Use(settings).Use(cv_matrix)
    .Use(filter).Use(mod)
    .OnFrame(Bind);

for (;;) loop.Tick();

Construction and cadence

ControlLoop(hw, frame_ms = 16, poll_ms = 1)

The board, the frame interval (~60 Hz default), and the inner poll cadence (button debounce, CV edges, host commands).

.FrameMs(ms) / .PollMs(ms)

Fluent cadence overrides after construction.
Use it forslower frames to give a heavy OnFrame more headroom; rarely needed otherwise.

Audio rate and block size

hw.Init() takes the sample rate and the audio block size, defaulting to 48 kHz and the board's standard block. Smaller blocks tighten trigger-to-sound latency and cost more interrupt overhead; higher rates buy bandwidth and spend CPU.

hw.Init(daisy::SaiHandle::Config::SampleRate::SAI_96KHZ, 16);
my_dsp::Init(hw.SampleRate());     /* seed the DSP with the real rate */
hw.StartAudio(AudioCallback);      /* n per block == hw.BlockSize()   */

Attaching surfaces

Use(...) acceptsRole it fillsCanonical example
KnobStorage&stored values + catchPager
LockSource&looping automationParamLock<N>
CvSource&CV dispatchCvMatrix
Settings&the settings modeSettings
Page&a knob page (up to 8)Page
ButtonBank&button state + gesturesButtonBank
HostService&USB host linkhostlink::Host

Hooks and reads

.OnFrame(fn)

Runs once per frame, after surfaces update and before render.

.OnPoll(fn(t_ms))

Runs inside the 1 ms inner poll, right after the SDK's own button polls.
Use it fortap tempo, hold timing, anything that would feel quantized at 60 Hz.

.OnRender(fn(t_ms))

Runs during render, after the SDK's rings and before the lock overlay.
Use it forpainting panel-wide custom visuals on top of the standard render.

.OnPageChange(fn)

Runs on the frame a page advance lands.
Use it forside effects of paging: swapping CV routes, re-seeding state.

Tick()

One frame: poll, update, render, show.

Phys() / Cv() / NumCv()

This frame's raw pot and CV buffers, for composition sites that bypass the knob layer.

The binder function

You need a function that reads each knob and hands finished values to your DSP. Hang it on OnFrame for control-rate updates, or read Value() straight from the audio callback for per-block freshness; both are correct, and most modules use both. Choose the appropriate rate for your DSP balanced against compute budget.

static void Bind()
{
    synth.SetCutoff(cutoff.Value());
    synth.SetRes   (reso.Value());
    synth.SetMode  (static_cast<int>(mode.Value()));
}

loop.OnFrame(Bind);

What runs when, exactly, and what one frame looks like from the inside: Firmware Anatomy is next, and it opens the machine this page has been enumerating.

Also in the framework: the LED ring vocabulary lives in LED Animations, and the SD card plus the browser link live in HostLink.