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")
pot and names it. The name is diagnostic and surfaces in the web editor.Value() -> float
Norm() -> float
Transforms (choose one; default is Linear(0, 1))
.Linear(min, max)
.Exp(min, max)
min × (max/min)ⁿ. Requires min > 0 (falls back to linear otherwise)..Selector(n)
.Selector(N) divides the control’s normalized range into N equal-width bins. Value() returns the selected index as a float, from 0 through N−1. The index is floor(norm × N), clamped to the available choices, so the fully clockwise endpoint selects the last option.SelectorRing so the zones are visible and .Labels() so hosts name them.For four choices, the boundaries are 0.25, 0.5, and 0.75. Values below 0.25 select option 0; values from 0.25 up to 0.5 select option 1; values from 0.5 up to 0.75 select option 2; and values from 0.75 through 1 select option 3.
Use a named label array with one label per choice. The labels describe the choices in the editor and manual; they do not change the selection boundaries.
Ring, pip, and overdraw
The looks themselves are cataloged in LED Animations; these are the attachment points.
.Ring(style)
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)
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..Overdraw(fn, ctx)
fn on top of the declarative ring each frame, same signature as Custom. The ring keeps showing the value; your layer rides above it.CV binding
.Cv(index, attenuation = 1.0, bipolar = true)
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.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")
p<page>.<pot>..Unit("Hz")
.Labels(names, n)
Selector knob (array of string literals, static lifetime). Hosts show e.g. "Series / Parallel / Spectral" instead of 0 / 1 / 2..Disp(json)
Note
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
RisingEdge() / FallingEdge() -> bool
TimeHeldMs() -> float
Declaring the button and its state
VirtualButton(kButtonB3, "Filter")
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")
.Ident("flt.mode")
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()
Toggle() is two zones..Default(zone) / .Labels(...) / .Colors(per_zone)
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)
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)
.Bind(&target) / .Bind(setter)
SetZone, or preset load..OnChange(fn, ctx)
flt.Zone() -> uint8_t
Sharing a button with navigation
Pager::Shift button fires only when the hold was clean: press and release, and it taps; hold and turn, and the bank stays quiet. A gesture declared on a Cycle or Latch button consumes that release, so a button declared on B1 shadows page-advance on the pages that declare it, and only there. Don't stack a hold gesture on the ParamLock trigger: lock recording is hold+knob, and two hold gestures on one button cannot be arbitrated.The bank
ButtonBank buttons; loop.Use(buttons); presets.Manage(buttons);
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.Pager::Shift binding, not a bank button.buttons.Ok() -> bool
DescriptorBuilder::LastError().buttons.SetZone(b, zone) / buttons.ZoneOf(b)
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")
.Role(...) / .Action(...) / .Controls(...)
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 · docs/pages-and-layers.md
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, runs pot catch, and decides which page is showing. 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).
Cycling is one way to reach a page. A module often wants others: a page shown only while a button is held, for secondary parameters like output levels, or a pair of sub-pages that one button toggles between. Built as its own surface, such a layer would have to redo everything a page already gets: pot catch, presets, param-lock banks, CV routing, ring rendering, and the web editor tab. So the SDK has no layer concept. A layer is a page selected by holding a button. The pager owns the page pool, and how each button navigates it is declared: Cycle advances on a release, Shift shows a page while held, Latch toggles a pair.
enum : uint8_t { kPageFilter, kPageMod, kPageLevels, kNumAppPages };
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(kPageFilter).Knobs(cutoff, reso, drive,
env_amt, attack, release).Name("Filter");
static Page mod = Page(kPageMod).Knobs(rate, depth, shape,
offset, rise, fall).Name("Mod");
static Page levels = Page(kPageLevels).Knobs(out_level, mix, tone).Name("Levels");
static Pager pager = Pager(kNumAppPages, kNumPots)
.Cycle(hw.buttons[kButtonB1], kPageFilter, kPageMod) /* release advances */
.Shift(hw.buttons[kButtonB2], kPageLevels); /* page while held */
loop.Use(pager).Use(filter).Use(mod).Use(levels);
Knobs on the layer are ordinary VirtualKnobs on an ordinary Page. presets.Manage(pager) persists its values with everything else, a ParamLock banks it, a CvMatrix routes to it, the rings draw it while it is held, and the web editor shows it as a tab. None of that is layer-specific code. The classic constructor Pager(hw.buttons[kButtonB1], 2, kNumPots) still works and means Pager(2, kNumPots).Cycle(hw.buttons[kButtonB1]): an empty cycle list is every page in order.
Page: composition
Page(idx).Knobs(k1, k2, ...)
idx and append knobs, fluently..Add(knob) / .Remove(knob) / .Clear()
Tick()..Buttons(b1, b2, ...) / .AddButton(b) / .RemoveButton(b)
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") / .Color(rgb)
Index() / Count() / At(i) / NumButtons() / ButtonAt(i)
RGB page colors
Page::Color accepts a CSS string or LedPanel::Rgb. The RGB overload stores the color as #rrggbb in the page, including when the page is copied or assigned.
static constexpr LedPanel::Rgb kFilter = {0xc9, 0xa8, 0x4c};
static Page filter_page = Page(0).Name("Filter").Color(kFilter);
static Page delay_page = Page(1).Name("Delay").Color({0x70, 0x90, 0xc0});
Page::Color sets the browser tab and manual color. Pager::SetPageColor sets the physical page-button tint. CSS strings passed to Color(const char*) must remain alive while the page is used.
Hiding a control from the editor and manual
Use Page::HidePot() when a physical pot position should not appear as an editable control on that page.
page.HidePot(kPotBottomRight); // hide P6
page.HidePot(kPotBottomRight, false); // show P6 again
const bool hidden = page.PotHidden(kPotBottomRight);
Pot indices are zero-based. Hiding a position preserves its place on the physical panel and does not move the remaining controls.
Visibility is presentation metadata. The stored value remains in presets and factory defaults. The control can still participate in firmware processing and LED rendering; hiding it does not disable its hardware binding or DSP behavior.
An unassigned position is not automatically an explicitly hidden position. Declare visibility when you want the editor and manual to omit that control.
The control loop picks up runtime page composition changes on its next tick. Published browser metadata changes only after the firmware requests a descriptor refresh:
host.RequestDescriptorRefresh();
Runtime refresh requires stable field identities and storage layout. Use it for presentation changes, not for changing the shape of an existing preset.
Pager: navigation
Pager(num_pages, num_pots)
Pager(button, num_pages, num_pots)
Pager(num_pages, num_pots).Cycle(button)..Cycle(button, pages...)
.Shift(button, page)
.Latch(button, a, b)
ButtonBank gestures there. A page belongs to at most one pair..From(pages...)
HoldUsed(button) -> bool
ButtonBank: a tap handler of your own that should stay quiet after a hold-and-turn.GoToPage(page, phys)
ConsumeButton() when the gesture involves a bound button.SetPageColor(page, rgb)
ButtonBank zone colors paint afterwards and win on a shared button.ConsumeButton()
ConsumesRelease(button) says whether a button carries such a binding. ControlLoop already runs consumers before the pager; only hand-rolled loops need to care about the order.RetainPage() / ReleasePage()
RetainPage() defers its exit until ReleasePage(). ParamLock brackets its trigger hold with the pair so the recording, the rings, and catch all stay on the page the hold began on.One binding per button, at most four bindings, and one Shift layer active at a time. A binding that names an out-of-range page is rejected silently, the same rule GoToPage follows.
Pager: state
Page() / ActivePage()
Value(pot) -> float
VirtualKnob; with knobs attached you rarely call it.Stored(page, pot) -> float
Caught(pot) -> bool
SetStored(page, pot, value, phys)
Set a stored value and re-arm pot catch. page and pot are zero-based; value is the desired normalized value (0–1).
phys is the array of current normalized physical pot readings. Pass loop.Phys(); only phys[pot] is used to initialize catch. The pot catches when it crosses the target or comes within tolerance, including immediately if it is already close enough.
// One-time change during normal operation: page 0, top-left pot, 75%.
pager.SetStored(0, kPotTopLeft, 0.75f, loop.Phys());
LockPage(page, phys)
Startup pickup
Call SetStored before the first control frame to set a pot's initial normalized value. Pickup initializes against the first live physical reading and preserves the stored value until the pot catches it.
// During setup, before the first loop.Tick():
pager.SetStored(0, kPotTopLeft, 0.75f, loop.Phys());
Set factory values before presets.Init() and presets.BootLoad(). The factory snapshot then includes those values, and a saved boot preset can replace them. Unseeded pots start from their physical positions. During normal operation, pass the current loop.Phys() readings to re-arm pickup.
Shift layers and the release contract
Two rules make a hold-to-show page work with no bookkeeping in the app.
Catch does the isolation. On the press edge the layer's page re-arms pot catch, so every pot is detached until it crosses its stored value on the layer. Motion during the press cannot move a base-page parameter. On release the pager returns to the page the press left and re-arms catch again, because the pot now sits wherever the layer edit parked it. Catch only ever runs on the active page, so the pages under a hold are untouched by construction: nothing is snapshotted and nothing is restored.
The release says what the hold was. A Shift button is usually also a tap button: tap to mute, hold and turn to edit the layer. Neither is known at press time, so the decision is made at release. A hold that caught no pot and moved none past the catch tolerance is clean and does not claim its release, and the tap fires. A hold that edited claims it, and a ButtonBank gesture declared on the same button stays quiet. App code outside the bank reads pager.HoldUsed(btn) instead. Releases process before that frame's catch, so a last-millisecond nudge cannot both edit the layer and pass for a clean tap.
That is the whole declaration for tap-to-mute plus hold-to-edit on one button:
static Pager pager = Pager(kNumAppPages, kNumPots)
.Cycle(hw.buttons[kButtonB1])
.Shift(hw.buttons[kButtonB2], kPageLevels);
static VirtualButton mute = VirtualButton(kButtonB2, "Mute")
.Ident("out.mute").Toggle().Bind(SetMute);
static Page filter = Page(kPageFilter).Knobs(cutoff, reso, drive).Buttons(mute);
Arbitration
Three buttons serve navigation, ButtonBank gestures, the ParamLock trigger, and the Settings chord at once. The pager settles the collisions in one place so firmware never has to.
| Situation | Rule |
|---|---|
| Settings gate | While Settings is active, bindings track button state but fire nothing. A press that begins under the gate, or spans it, is inert until its release. A gate rising mid-hold aborts an active layer, so Settings always opens over the base page. |
| Chord reach | Two navigation-bound buttons down at once reads as reaching for a chord: active layers abort and both presses stay inert for the rest of their holds. A layer on a Settings-chord button whose partner is not navigation-bound stays up during the reach; the gate abort clears it when Settings opens. |
| View latch | ParamLock retains the page across its trigger hold. A layer showing when the hold began stays until the trigger releases, so rings, catch, and the recording's bank agree. Layers engaged after the hold began are not retained. |
| Explicit jump | GoToPage cancels an active layer with no return leg. A Latch pair containing the target remembers it. |
Locks bank at the press edge. ParamLock latches its slot bank when the trigger goes down, so a recording targets the page visible at press time. A layer released mid-hold, or a host-driven GoToPage, cannot retarget a recording in flight.
Note
Left to the app
pager.ActivePage() or HoldUsed(). Not implemented: tap-to-latch on a Shift button, since detecting the split would add latency to every plain tap; nested shifts; and descriptor metadata for navigation gestures. The full contract is in the SDK's docs/pages-and-layers.md.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()
SetVolts target. Disable returns the default.SetVolts(v) -> bool
v (±5 V), calibration applied. Until EnableCvOutput() the code is staged but the jack stays disconnected.Volts() -> float
Value() -> float
IsCvOutput() -> bool
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);
From volts to a control value
CV modulation is added in normalized control space before the knob’s output transform is applied.
On Alchemy Lab V2, J3–J8 nominally map −10 V to 0, 0 V to 0.5, and +10 V to 1. A +5 V input therefore reads approximately 0.75. These are normalized readings; use Volts() when your application needs calibrated input voltage.
With bipolar modulation enabled, the SDK subtracts 0.5 from the normalized input, then applies the attenuation multiplier:
modulation = (normalized_input - 0.5) × attenuation
The result is added to the knob position and other modulation, then constrained to the control’s range.
For a centered Linear(-5, 5) control, +5 V at the input contributes 0.25 normalized units with unity attenuation. The resulting control value is +2.5. An attenuation multiplier of 2 makes that same input reach +5.
| Input voltage | Nominal normalized input | Bipolar contribution at attenuation 1 |
|---|---|---|
| −5 V | 0.25 | −0.25 |
| 0 V | 0.50 | 0 |
| +5 V | 0.75 | +0.25 |
Atten(1) preserves the normalized contribution. Atten(-1) inverts it, Atten(0) removes it, and values above 1 increase its depth. This multiplier does not mean that one input volt becomes one output volt.
Bipolar(false) forwards the normalized input without subtracting 0.5. It does not remap a 0–5 V source onto 0–1: nominally, that source still occupies 0.5–0.75. Use an explicit mapping when your application needs a different voltage range.
The per-knob .Cv(index, attenuation, bipolar) binding uses the same normalized-space model. When a CvMatrix is attached to the control loop, it owns CV routing and the per-knob .Cv() bindings are ignored.
CvMatrix
CvMatrix(num_jacks)
Off..Jack(n).To(knob)
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..Jack(n).Custom(fn, ctx)
.Jack(n).Off()
.Atten(v)
Off and Custom..Bipolar(b)
Off and Custom.Note
.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 · tick_timebase.h
One timestamp for the poll
MusicalClock advances a musical timeline from elapsed time. ClockFollower adjusts its rate from incoming pulses, and CvEdge timestamps transitions on the CV inputs.
All three must use the same microsecond timebase. Inside a ControlLoop poll hook, use loop.PollTimeUs(). Do not substitute daisy::System::GetUs(): dividing a wrapping hardware counter into microseconds gives it a different wrap period from the one these consumers expect.
This example receives a unipolar clock on J3:
static MusicalClock clock_;
static ClockFollower follower(clock_);
static CvEdge clk_edge;
static constexpr uint8_t kClockCvChan = 0u; // J3
static void OnPoll(uint32_t /*t_ms*/)
{
const uint32_t now_us = loop.PollTimeUs();
clk_edge.Tick(loop.Cv(), loop.NumCv(), now_us);
if (clk_edge.JustRose(kClockCvChan))
follower.OnPulse(clk_edge.LastRiseUs(kClockCvChan));
follower.Update(now_us);
clock_.Tick(now_us);
}
After hardware initialization and before starting the control loop:
CvEdge::Config cfg;
cfg.lo_threshold = 0.55f;
cfg.hi_threshold = 0.65f;
cfg.debounce_us = 500u;
clk_edge.Init(kNumCvInputs, cfg);
follower.SetAutoStart(true);
follower.Enable(kClockExtPpqnDefault);
loop.OnPoll(OnPoll);
The thresholds are above the nominal 0 V input value of 0.5 and suit a clock that rises from 0 V to +5 V. Choose thresholds for the actual source rather than treating one pair as suitable for every signal.
CvMatrix routing does not consume this clock input. The edge detector reads the sampled CV values independently.
Timing in a custom loop
A firmware with its own loop can use TickTimebase:
#include "alchemy/control/tick_timebase.h"
static alchemy::TickTimebase clock_time;
static void InitClockTime()
{
clock_time.Init(
daisy::System::GetTickFreq(),
daisy::System::GetTick());
}
static uint32_t ReadClockTimeUs()
{
return clock_time.Update(daisy::System::GetTick());
}
Call InitClockTime() after hardware initialization. At each poll, call ReadClockTimeUs() once and pass that value to the related edge detectors, follower, and musical clock.
Update the timebase from one context only. Calls must be closer together than one full raw-counter wrap, about 17.9 seconds at 240 MHz. If the timer restarts or changes frequency, reinitialize the timebase and its timing consumers together.
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)
Tick(now_us)
ControlLoop::PollTimeUs() or a compatible TickTimebase. Call once per poll. The clock integrates rate over elapsed time and records the musical boundaries crossed. Catch-up after a stalled loop is capped at 50 ms.Start() / Stop() / Reset()
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)
SetBpm is silently ignored, so a tempo knob or settings control can keep calling it without checking who owns the clock.BeatPhase() / BarPhase() -> float
Events() -> uint32_t
CLK_EV bitmask of musical boundaries the most recent Tick crossed. A crossing appears for exactly one Tick. See list of events below.Bar() / Beat() / TickInBeat()
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
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
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.OrPendingEvent(flags)
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:
| Family | Flags |
|---|---|
| Transport | START · STOP · RESET |
| Straight | SIXTYFOURTH · THIRTYSECOND · SIXTEENTH · EIGHTH · QUARTER · HALF · WHOLE · BAR |
| Triplet | THIRTYSECOND_T · SIXTEENTH_T · EIGHTH_T · QUARTER_T · HALF_T · WHOLE_T |
| Dotted | DOTTED_16TH · DOTTED_8TH · DOTTED_QUARTER · DOTTED_HALF · DOTTED_WHOLE |
| Multi-bar | TWO_BAR · FOUR_BAR · EIGHT_BAR |
| Clock grid | MIDI_CLOCK: the 24 PPQN pulse grid, for emitting MIDI clock |
| External clock | EXT_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)
Enable(ext_ppqn = 24) / Disable()
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)
CvEdge::LastRiseUs. Cheap and safe to call from anywhere, including an interrupt. The processing happens later, in Update.Update(now_us)
OnPulse only stores the timestamp; this is where it takes effect.SetLossPolicy(Freewheel | Freeze | Stop)
SetAutoStart(on)
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.OnReset()
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)
SetGains(kp, ki, alpha)
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.
Thresholds are expressed in normalized input values, not volts. On V2, an unpatched J3–J8 input rests near 0.5. A unipolar 0 V to +5 V pulse therefore moves approximately between 0.5 and 0.75.
For that source, thresholds such as 0.55 low and 0.65 high allow the detector to return to its low state between pulses. A low threshold of 0.30 would not be crossed by a signal that returns only to 0 V.
Use thresholds on opposite sides of the actual switching region, with enough separation to reject noise. CvGate is appropriate when the held level matters. J1/J2 use the separate codec-input RisingEdge() detector and cannot recover a sustained DC gate level.
Init(num_channels) / Init(num_channels, cfg)
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
loop.Cv()). Returns the bitmask of channels that just rose; falling edges via FallingMask().JustRose(ch) / JustFell(ch) / Level(ch)
Tick, and is it currently high. The edge flags last exactly one tick; Level is continuous.Level for envelope gates: it reports whether the gate is currently held.LastRiseUs(ch) / LastFallUs(ch)
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
60.f / clock_.Bpm().ClockDivZoneFromNorm(norm, avail_mask) -> zone
ClockDivSeconds(zone, quarter_s) -> float
ClockDivNearestZone(target_s, quarter_s, avail_mask)
ClockDivNormFromZone(zone) -> float
ClockDivIsTriplet(i) / ClockDivIsDotted(i) / ClockDivIsQuarter(i)
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 · alchemy/control/lock_types.h · docs/param-locks.md
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. Recording arms at 0.5% of travel. A 3% move on a pot that is already looping clears it. Two behaviors are optional: a Clocked mode that restarts each loop on the musical grid, and an exit policy that decides where the knob's value lands when the button is released. Capacity is a compile-time declaration.
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.
Behavior
Everything here is optional. The declaration alone gives: arm at 0.5%, clear at 3%, Latch exit, a solid red pip while recording, a blinking red pip during playback, free-running loops. Each call overrides one setting; the values shown are the defaults.
locks.UseClock(clock_) /* enables Clocked mode */
.SnapGrid(LockGrid::Straight | LockGrid::Triplet) /* | LockGrid::Dotted */
.ArmThreshold(0.005f)
.ClearThreshold(0.03f)
.ExitMode(LockExit::Latch) /* or LockExit::Return */
.RecordStyle({0xFF, 0x00, 0x00}, LockAnim::Solid)
.PlayStyle ({0xFF, 0x00, 0x00}, LockAnim::Blink);
UseClock(clock)
MusicalClock and enable Clocked mode. Whether the clock is internally timed or driven by a ClockFollower makes no difference to the lock. See Clocked mode below.SnapGrid(mask)
LockGrid::Straight, LockGrid::Triplet, LockGrid::Dotted, combined with |. Straight and triplet by default. Integer bar counts above one bar are always candidates regardless of the mask, so a five-and-a-half bar take snaps to 5 or 6 bars rather than 4 or 8.ArmThreshold(t) / ClearThreshold(t)
ExitMode(LockExit::Latch | LockExit::Return)
settings.UseLocks(locks) the player chooses instead, and this call is superseded.RecordStyle(rgb, anim) / PlayStyle(rgb, anim)
Solid and red Blink. Pass the animation alone to keep the color: PlayStyle(LockAnim::Sweep). See LED styles below.Clocked mode
A free loop replays at the wall-clock length it was recorded, so against a sequencer it drifts within a few bars. In Clocked mode a finished recording snaps its loop boundary to the nearest musical duration: record roughly one bar of motion and the loop restarts on exactly one bar, and keeps restarting there through tempo changes. Only the boundary snaps, and three properties follow:
Motion is never resampled. Playback runs at the recorded speed. A take longer than the snapped length is truncated: samples past the boundary are never reached. A shorter take holds its final value until the restart. At a stable tempo either correction is the few milliseconds by which the take missed the grid.
The boundary follows the clock. The loop starts where the gesture ended; the start is not quantized to the bar. Restart times are computed from the clock's absolute position rather than accumulated, so tempo changes and follower drift move the restart and never the motion speed. If the clock slows, the motion plays out, holds its final value, and restarts on the later grid. If it speeds up, each pass replays only the front of the take.
A stopped transport freezes clocked loops. Free loops keep running. On signal loss, behavior follows the follower's loss policy.
The take's duration is measured in clock ticks, a stamp at arm and a delta at release, so control-loop frame jitter cannot shift the snap and a tempo change made during the recording is accounted for. The grid is nearest-by-ratio across the enabled note families, from a 16th to a whole note, plus integer bar counts above one bar.
The mode is consulted at record time only. Every slot stores which timing it was captured under, so changing the setting never re-times an existing lock, and one preset can mix free and clocked slots. Storing musical ticks rather than milliseconds has two more consequences: a lock saved as two bars restarts every two bars at any later tempo, and slots restored from a preset anchor at the clock's origin, so slots of equal musical length restart together. If Clocked is selected but the clock is not running when a recording ends, or no clock was wired, that slot falls back to free and replays at its recorded length. Recording is never refused.
LED styles
The stock overlay is a pip at 6 o'clock on any ring whose slot is active. RecordStyle and PlayStyle set its color and animation. The animations have no tuning parameters; for anything else, skip the stock Render() and draw the ring yourself from IsActive(), IsRecording(), and PlayPhase().
| LockAnim | Reads as |
|---|---|
Solid | lock exists |
Blink | 2 Hz square wave; the playback default |
Breathe | 2 s triangle brightness ramp |
LoopPulse | flash at each loop restart, decaying over the first fifth of the loop; solid while recording, since no loop exists yet |
Sweep | pip follows the play head around the ring; while recording it follows the write head, showing buffer fill |
Reading playback
Delta(pot) -> float
Norm().IsActive(pot) / IsRecording(pot)
DeltaAtPage(page, pot) / IsActiveAtPage / IsRecordingAtPage
AnyRecording() / AnyActive()
IsButtonHeld() -> bool
PlayPhase(pot) / PlayPhaseAtPage(page, pot) -> float
Control and persistence
Clear()
ClearSlot(pot) / ClearSlotAtPage(page, pot)
Freeze(on)
Save(out[]) / Restore(in[])
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)
RecordStyle() and PlayStyle(), red solid and red blink by default. The loop calls it automatically when the surface is attached.Note
SchemaHash() folds in the slot count, buffer length, sample rate, and storage policy, so changing any of them makes existing preset slots read as empty rather than replaying at the wrong speed. The header grew by two bytes in v0.11, so locks saved by earlier firmware load as empty. Because the lock component's size changed, the preset schema hash changed with it: presets saved by an earlier build of a firmware that manages locks read as empty in a v0.11 build.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)
Manage(serializable)
Init(). Up to eight; order defines the byte layout and feeds the schema hash, so keep it stable across firmware versions.Init()
hw.Init() and every Manage() call, before StartAudio() is safest.Save(slot) / Load(slot) -> bool
BootLoad() -> bool
HasValid(slot) -> bool
EraseSlot(slot) -> bool
UseNames() -> PresetName&
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
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
Settings stores and renders up to eight pages of controls. Hold B2+B3 for two seconds to enter, press B1 to cycle pages, and tap B2 or B3 to leave. On exit, the performance pager re-arms pot pickup.
static Settings settings(hw, &pager);
settings.UseBrightness(); /* page 0, pot 0 */
settings.UsePresets(presets); /* pots 2 + 3: save / load */
settings.UseLocks(locks); /* pots 4 + 5: sync / exit */
settings.Page(1).Name("Mod")
.Pot(0).Selector(3); /* a custom 3-zone option */
loop.Use(settings);
presets.Manage(settings); /* use the serialization */
Page indices are 0–7. HostLink describes each declared page. midi::MonoSettings uses two consecutive pages for MIDI Notes and MIDI Control; see MIDI settings for setup and applying their values in the audio callback.
Stock options
UseBrightness() -> BrightnessHandle
.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&
Presets store. Reserves (page 0, pot 2) as the slot selector and (page 0, pot 3) as the action pot. See the gesture below.UseLocks(locks) -> LocksHandle
UseLocks pushes Return the moment it is called, so the selector, not ExitMode(), is the exit policy in effect; the surface alone defaults to Latch, which keeps firmware that never calls this on its earlier behavior. The handle refines both controls: .At(page, pot) and .ExitAt(page, pot) relocate them, .Colors(palette) restyles the sync zones, .Default(LockSync::Clocked) and .DefaultExit(LockExit::Latch) set boot defaults, .Ident(), .Name(), and .Help() describe the sync control, and .Value() and .ExitValue() read them. Call it after UseClock(), since offering Clocked with no clock wired is a debug-build assert, and only with the paged lock form, since Return writes into a Pager.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
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")
Name labels the page's tab in the web editor.Page(i).Pot(p).Knob() -> KnobHandle
.Default(v), .Color(rgb), .Anim(fill_anim).Page(i).Pot(p).Bipolar() -> BipolarHandle
.Default(v), .Color, .AltColor, .CenterColor.Page(i).Pot(p).Selector(n) -> SelectorHandle
.Default(idx), .Colors(palette), .InactiveColor, .InactiveDim, .Geometry; .Value() returns the zone index.Page(i).Pot(p).Brightness() -> BrightnessHandle
Page(i).Pot(p).Custom() -> CustomHandle
.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.State and appearance
IsActive() -> bool
CurrentPage() / NumPages()
SetIndicatorColor(rgb)
Settings(hw, &pager, hold_ms)
Note
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)
.FrameMs(ms) / .PollMs(ms)
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() */
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.
Attaching surfaces
| Use(...) accepts | Role it fills | Canonical example |
|---|---|---|
KnobStorage& | stored values + catch | Pager |
LockSource& | looping automation | ParamLock<N> |
CvSource& | CV dispatch | CvMatrix |
Settings& | the settings mode | Settings |
Page& | a knob page (up to 8) | Page |
ButtonBank& | button state + gestures | ButtonBank |
HostService& | USB host link | hostlink::Host |
Hooks and reads
.OnFrame(fn)
PollTimeUs() → uint32_t
OnPoll so application timing shares the same epoch as the control loop’s CV edge processing..OnPoll(fn(t_ms))
.OnRender(fn(t_ms))
.OnPageChange(fn)
Tick()
Phys() / Cv() / NumCv()
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.