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)
n zones; Value() returns a whole number 0 to n-1 as a float.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)
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
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.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
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, ...)
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")
Index() / Count() / At(i) / NumButtons() / ButtonAt(i)
Pager: state and gestures
Pager(button, num_pages, num_pots)
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)
LockPage(page, phys)
GoToPage(page, phys)
ConsumeButton() when the gesture involves the pager's own button.SetPageColor(page, rgb)
ConsumeButton()
Note
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);
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
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.
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)
Tick(now_us)
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()
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.
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)
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
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
Norm().IsActive(pot) / IsRecording(pot)
DeltaAtPage(page, pot) / IsActiveAtPage / IsRecordingAtPage
AnyRecording() / AnyActive()
IsButtonHeld() -> bool
Control and persistence
Clear()
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)
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
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
.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.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() */
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)
.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.