Hermetic Modular

05/LED Animations

Rings, Pips, and Fields

The ring of LEDs around each pot is probably why you bought an Alchemy Lab, let's be real. The SDK provides a declarative language to make it easier to work with. One base layer shows summed value, and overlays that add motion and additional information. This page shows that animation vocabulary. You are welcome to design new animations and submit them to the SDK via pull request!

The model

A Base (exactly one) encodes the value: a filled arc, discrete zones, or a color morph. Overlays (any number) sit on top, and there are only two kinds: a Pip, one positioned element, and a Field, a brightness texture across a lit region. Everything that looks like a different animation (a cursor, a comet, a ping, a shimmer) is a parameterization of those two. The catch pip for pot catch paints last within a declarative ring: a Custom ring gets none, and overdraw callbacks, OnRender, and the param-lock overlay all paint after it.

One Ringpotfill tip = the value0 · 7:301 · 4:30bottom pip · off-arc at 6:00flanking pair: raw access onlyThe Stack1Base · exactly one · encodes the valuefilled arc · zones · color morph2+ Field · brightness texture, lit regionbreathe · ripple · shimmer · stutter · bands3+ Pip · one positioned elementcursor · comet · ping · playhead · notch4catch pip · painted last, on topwaits at the stored value while uncaught

Consider some of these best practices, but do as you please:

RuleMeaning
Base = summed balueThe Base fill should encode the value post-modulation; overlays modulate and decorate.
White is for pot catch onlyMaintaining this visual language keeps indication clear.
Color code pagesIt's much easier to tell what page you are on when all pots are a similar, unique page color.

Two ways to author a ring. Declarative: pick a ring style on the knob and the SDK renders it. Compositional: take the ring over with a callback and stack the primitives yourself. Both run on the same rendering core, so a firmware can mix them per ring.

Ring styles

The declarative catalog: each style is one .Ring(...) call on a VirtualKnob.

Level

Basic, but beautiful. A filled arc grows from the ring's edge: the standard "how much" bar.

/* Plain level, no motion. */
static VirtualKnob mix   = VirtualKnob(0, "Mix")
    .Ring(Level(kAmber));

/* Pulsing fill over a faint unfilled region, 60% depth. */
static VirtualKnob decay = VirtualKnob(1, "Decay")
    .Ring(Level(kViolet, FillAnim::Pulse)
              .Passive({0x14, 0x08, 0x1C})
              .Depth(0.6f));
Level fill: plain, then Pulse and Ripple
P1P2P3P4P5P6
  • P1plain fill
  • P2plain · passive trail
  • P3Pulse
  • P4Pulse · passive trail
  • P5Ripple
  • P6Ripple · passive trail

The base fill is the workhorse: it shows the summed value after CV and lock modulation, and the Passive and Depth options style the unlit remainder and the animation amplitude. Watch how Pulse breathes the whole lit region together while Ripple moves through it.

Bipolar

A two-color fan for signed values: one arm color above the pivot, another below. Boost/cut gains and pan positions live here.

/* Boost / cut around center detent. */
static VirtualKnob gain = VirtualKnob(2, "Gain")
    .Ring(Bipolar(kBoostGreen, kCutBlue, kCenterGrey));

/* Asymmetric range: pivot at 25% of travel. */
static VirtualKnob env  = VirtualKnob(3, "Env Amt")
    .Ring(Bipolar(kPos, kNeg, kZero).Pivot(0.25f));
Bipolar fan sweeping through its pivot
P1P2P3P4P5P6
  • P1boost/cut fan
  • P2single-color fan
  • P3warm/cool arms
  • P4pivot at 25%
  • P5pivot at 75%
  • P6Ripple on the arms

Everything here is one FillDesc in Center mode. The pivot can sit anywhere in the value space, and each arm is normalized to its own side, so both arms reach their end stops wherever the pivot lives: boost/cut gains and asymmetric envelope amounts read correctly at a glance.

Selector

Discrete zones for mode knobs: the active zone lights in the selected color, the other zones stay dark. ZoneGeometry chooses whether zones spread across the whole arc or light as contiguous regions.

static const char* kModes[3] = {"Series", "Parallel", "Spectral"};

static VirtualKnob mode = VirtualKnob(4, "Routing")
    .Selector(3)
    .Ring(SelectorRing(kActive, kOff, 3, ZoneGeometry::Region))
    .Labels(kModes, 3);          /* names surface in the web editor */
Selector zones: Distributed and Region geometry
P1P2P3P4P5P6
  • P13 zones · Distributed
  • P23 zones · Region
  • P34 zones · Distributed
  • P44 zones · Region
  • P58 zones · a color per zone
  • P62 zones · Region

The pairs show the two geometries at 3 and 4 zones: Distributed spreads positions across the whole arc, Region draws contiguous blocks. Every pot steps at its own rate, P3, P4, and P5 keep unselected zones dimly lit, and P5 gives each zone its own color: the engine-picker pattern, where a mode's color is its identity.

Gradient

A filled arc whose color morphs between snap points as the knob sweeps: the fill saturates toward each snap's color as the value approaches it, and dim markers sit at the snap positions so you can find them by feel. Built for engine and model pickers that morph rather than switch. Pair it with GradientSnapPip for a lit confirmation when the value lands on a snap.

static constexpr MorphSnapPoint kEngines[4] = {
    {0.f,      kDigiTeal},
    {1.f / 3,  kBbdAmber},
    {2.f / 3,  kTapeRust},
    {1.f,      kVinylViolet},
};

static VirtualKnob engine = VirtualKnob(0, "Engine")
    .Ring(Gradient(kEngines, 4))
    .Pip(GradientSnapPip());     /* lights in the morph color on a snap */
Gradient morph: six snap tables at once
P1P2P3P4P5P6
  • P14 engine snaps
  • P22 snaps · A to B
  • P33 snaps · uneven
  • P45 snaps
  • P54 warm snaps
  • P63 cool snaps · uneven

The fill color is the value. Every ring carries its own snap table: different counts, colors, and spacings, down to a two-point A-to-B morph and unevenly placed snaps. Each glides at its own rate, saturating inside the approach window, with dim markers at the positions you can find by feel; the bottom pip confirms every landing without adding an element to the arc.

GradientFill

A plain level fill that wears a sibling knob's gradient color. The classic use: a depth or amount knob tinted by its companion's engine, so related controls read as one voice.

/* Depth's fill color follows the Engine knob (pot 0). */
static VirtualKnob depth = VirtualKnob(3, "Depth")
    .Ring(GradientFill(kEngines, 4, /*src_pot=*/0, kFallback));
Depth ring tinted by the engine selection
P1P2P3P4P5P6
  • P1engine · engines set
  • P2depth · follows P1
  • P3engine · warm set
  • P4depth · follows P3
  • P5engine · cool set
  • P6depth · follows P5

GradientFill exists for control pairs that read as one voice, and here they sit paired by row: engine on the left, its depth right beside it. Each row wears a different snap table, and the depth pot borrows its partner's morph color live through that table. 'How much' and 'of what' stay visually bound while both values move independently.

Pips on any ring

Independent of the arc style, every knob can paint the off-arc bottom LED as a marker. SolidPip is a fixed indicator; ThresholdSnapPip watches the value and switches color inside a window, the natural unity-gain or detent marker.

/* White at the 12 o'clock detent window, red when pushed past. */
static VirtualKnob drive = VirtualKnob(5, "Drive")
    .Ring(Level(kDriveOrange))
    .Pip(ThresholdSnapPip(kWhite, 0.45f, 0.55f, kRed));

And one pip belongs to the system: the catch pip. After a page switch it marks where the stored value waits, painted by the declarative renderer as the last step of that ring. A Custom ring skips the renderer, so it gets no catch pip, and everything ControlLoop paints afterwards (overdraw callbacks, OnRender, the param-lock overlay) lands on top of it.

A Custom ring can still draw one: pass the Pager as the ring's ctx, ask it for State(page, pot), and either call DrawCatchPip(panel, pot, ps, geo, kWhite) or hand the same PotState to the three-argument RingFrame::Emit(panel, pot, ps), which paints it as the top layer.

Threshold pip at the detent; catch pip after a page switch
P1P2P3P4P5P6
  • P1threshold pip · detent
  • P2solid marker
  • P3threshold at 0.75
  • P4catch cycle
  • P5catch cycle
  • P6catch cycle

P1, P2, and P3 give the bottom pip a fixed meaning: white inside the detent window and red past it, an always-on marker, a second threshold higher up. P4, P5, and P6 are the system's own pip, cycling out of phase so one of them is always mid-story: after a page switch the fill dims to the stored value and the white catch pip parks at it; the moment the pot catches, the pip vanishes and the ring returns to full brightness.

Fields: brightness as motion

A Field never adds an element. It modulates the brightness of an already-lit region: the LEDs keep their color while their level breathes, ripples, twinkles, stutters, or bands. The declarative FillAnim::Pulse and Ripple are presets over this same math; composition opens the full set.

PresetCharacter
FieldPulse()the whole region breathes together
FieldRipple()a wave drifts along the arc
FieldShimmer()per-LED twinkle, spatially incoherent
FieldStutter()sample-and-hold jumps, rhythmic
FieldStaircase()chunked bands, static unless phase-driven

Consider driving the amount with an envelope, not a constant. It's cool.

The five field presets, side by side
P1P2P3P4P5P6
  • P1Pulse
  • P2Ripple
  • P3Shimmer
  • P4Stutter
  • P5Staircase · phase-crawled
  • P6envelope-driven Ripple

A Field never adds an element: the color stays put while the brightness moves. All five presets run side by side on static fills, so the textures compare directly, and every one is the same three choices of pattern, grain, and step. Staircase is static by design and crawls here because its phase is driven externally; P6 is the tip from the table, a ripple whose amount follows a decaying envelope instead of a constant.

Composition: RingFrame

When a ring needs a second signal, take it over. A Custom ring replaces the declarative render; an Overdraw layers after it. Either way you compose a RingFrame: begin, stamp a base, stack overlays, emit.

RingFrame f;
f.Begin(geo);
f.Base(fill, value);
f.Field(Region::Active, FieldShimmer(), wet_env, t_ms);
f.Pip(Region::Full, playhead, pos01);
f.Emit(panel, pot);

Overlays target regions the base stamped, in region-relative 0..1 positions, never LED indices, so a composition ports across ring geometries: Full (the whole arc), Active (the lit value region), Passive (tip to end), BottomPip, or a custom Span.

A pip's parameters are where the classic looks come from:

ParameterWhat it makes
motionDirect clamps · Wrap laps into a cursor · Bounce ping-pongs into a comet
smoothsub-LED interpolation: continuous glide instead of stepping
tail_intensity / trail_*comet tails and long afterglows
composeReplace owns its LEDs · Add rides on top · Carve cuts a notch
blink_hz / backgroundblinking markers; a dim floor under the pip

The shipped composition to copy is examples/ring_demo, a stereo tremolo whose rings render the LFO itself. The audio callback publishes the LFO phase once per block; the rate ring layers a bouncing comet over a dim value fill:

ring_demo.cpp
static void RateOverdraw(LedPanel& panel, uint8_t pot,
                         const ArcGeometry& geo, float norm,
                         uint32_t t_ms, void* /*ctx*/)
{
    RingFrame f;
    f.BeginOverlay(geo);

    FillDesc fill;                       /* re-draw the dim value fill:  */
    fill.color = LedPanel::Scale(kRateColor, 0.35f);
    f.Base(fill, norm, t_ms);            /* frames compose on contents   */

    PipDesc comet;
    comet.color          = kRateColor;
    comet.compose        = PipCompose::Add;
    comet.motion         = PipMotion::Bounce;
    comet.smooth         = true;
    comet.tail_intensity = 0.25f;
    f.Pip(Region::Full, comet, 2.0f * g_lfo_phase01);

    f.Emit(panel, pot);
}

static VirtualKnob rate = VirtualKnob(0, "Rate")
    .Ring(Level(LedPanel::Scale(kRateColor, 0.35f)))
    .Overdraw(RateOverdraw);

And the depth ring goes fully custom: a value fill, a Carve notch sweeping at the LFO phase, and a shimmer whose amount follows the depth, so the ring literally shows what the tremolo does to the signal.

ring_demo.cpp
static void DepthRing(LedPanel& panel, uint8_t pot,
                      const ArcGeometry& geo, float norm,
                      uint32_t t_ms, void* /*ctx*/)
{
    RingFrame f;
    f.Begin(geo);

    FillDesc fill;
    fill.color = kDepthColor;
    f.Base(fill, norm, t_ms);

    PipDesc notch;                       /* darkness as an element */
    notch.compose = PipCompose::Carve;
    notch.motion  = PipMotion::Bounce;
    notch.smooth  = true;
    f.Pip(Region::Active, notch, 2.0f * g_lfo_phase01, norm);

    f.Field(Region::Active, FieldShimmer(70u), 0.3f * norm, t_ms);

    f.Emit(panel, pot);
}

static VirtualKnob depth = VirtualKnob(1, "Depth")
    .Ring(Custom(DepthRing));
Six compositions on one LFO phase
P1P2P3P4P5P6
  • P1comet · Bounce + tail
  • P2cursor · Wrap
  • P3comet · one-sided trail
  • P4carved notch + shimmer
  • P5notch · Carve alone
  • P6two pips · anti-phase

Six RingFrame stacks driven by one published LFO phase, held slow enough to watch: 0.15 to 2 Hz, its speed set by P1's value, the ring_demo pattern. Bounce folds the phase into comets, Wrap laps it into cursors and trails, Add rides on the fill, and Carve cuts darkness through it; P4 layers the shimmer field on top for the full stack.

Stateful animators

Two effects (as of now) need memory: Sparkle spawns and decays scattered sparks across a ring, suited to noise-like and granular states; BeatPip flashes the bottom pip in tempo. Both keep caller-owned state structs and compose alongside the normal stack.

Sparkle density sweep; BeatPip locked to tempo
P1P2P3P4P5P6
  • P1Sparkle · white
  • P2Sparkle · gold
  • P3sparkle over a fill · Overlay
  • P4BeatPip · 120 BPM
  • P5BeatPip · accelerating
  • P6BeatPip · 60 BPM

Sparkle and BeatPip keep caller-owned state between frames, spark decay and beat phase, which is what separates them from the stateless primitives. The two bare sparkle densities sweep in anti-phase while P3 overlays its sparks on a value fill, and the three pips hold two locked tempos plus the regime switch: as the period shrinks past the beat threshold, blinking fades to a solid glow.

The full parameter tables, region semantics, and migration notes live in ring-animations.md. For how rings slot into the frame and where Overdraw runs, Firmware Anatomy has the render order.