alchemy-sdk
Hype13/100Embedded SDK for Hermetic Modular eurorack modules, built on Electrosmith Daisy.
alchemy-sdk is a tool for the Hermetic Modular Alchemy Lab, by @hermetic-modular. Open source under MIT, latest release v0.12.0 (September 2026), 38 stars on GitHub.
forHermetic Modular Alchemy Lab·by@hermetic-modular
Unclaimed. Is this yours? Sign in with GitHub as @hermetic-modular to claim it.
No release files yet. Build from source.
MIT· hermetic-modular/alchemy-sdk·hermeticmodular.com·downloads come from the creator’s GitHub releases
v0.12.0Latest0 files
Alchemy SDK v0.12.0 | 2026-09-29
USB diagnostics now share the HostLink connection, including on firmware without presets. Selectors fixed to match across values, rings, and the browser; firmware can refresh its published metadata while connected. Also: corrected codec CV scaling, reliable codec-input trigger detection, a rollover-safe control-loop timebase, and stricter calibration validation.
Control-loop timebase (#35)
- Fixed a major issue causing a 50 millisecond stutter every 21 seconds on clock analysis.
- Added
TickTimebase, which accumulates wrapping hardware ticks into microseconds with the wrap behavior expected by the timing APIs. ControlLoopnow uses this timebase for CV edge processing and exposes the current poll timestamp throughPollTimeUs().- Custom clock wiring should use
loop.PollTimeUs()insideOnPoll, or its ownTickTimebase, instead of passingdaisy::System::GetUs()directly to timing consumers. - A timebase must be updated before the underlying raw counter completes a full wrap. Initialize it after hardware startup and keep related timing consumers on the same epoch.
USB diagnostics and Host lifecycle (#40)
- Added the optional
hostlink::Diagnosticsextension.Debug(),Info(),Warn(), andError()write bounded messages without waiting for a USB client.PrintLine()is an Info-level migration alias. - Added typed
Gauge<float>,Gauge<int32_t>,Gauge<uint32_t>, andGauge<bool>values.Set()publishes the latest scalar and can run in an audio callback; formatted logging belongs on the main/control thread. - Default storage is 32 log records with 160 text bytes each, plus up to 16 registered gauges. Overflow and truncation are reported. Logs are volatile.
- Added a Host constructor without
Presets, for identity, reboot, and protocol extensions without a dummy preset store. - The default Host starts at its first
Poll(), after setup has attached the module’s declarations. Factory-state capture remains separate from link startup. - Added CLI log capture and live-value watching. The web programmer’s Device console uses the same diagnostics protocol.
- Do not combine HostLink with raw
seed.PrintLine()or direct USB writes on the same port.
Selector consistency (#53; issue #47)
- Settings rings now render the resolved selection index instead of independently quantizing the knob position.
VirtualKnob::Selector(N)uses equal-width bins. Its value count also controls selector-ring selection, independently of builder order.- Fixed selected zones being overwritten by inactive zones when several choices share an LED.
- Auto-derived selector display hints now include
disp.bins. The browser uses matching selection boundaries and writes bin centers. - Existing display hints without
bins, explicit.Disp()overrides, and standalone selector-animation conventions retain their previous interpretation. - Settings selections remain bytes and performance values remain normalized floats. Preset offsets, sizes, and schema formats are unchanged by these fixes.
Manual capacity and control visibility (#54; issues #41 and #43)
- Increased the module manual limit from eight to sixteen sections.
- Added
Page::HidePot(pot, hidden)andPotHidden(pot). - Hidden controls remain in serialization and factory defaults. Visibility affects editor/manual presentation without removing stored values or changing physical pot positions.
- Unassigned positions are not automatically treated as explicitly hidden controls.
Runtime descriptor refresh (#55; issue #45)
- Added
Host::RequestDescriptorRefresh()andDescriptorRefreshStatus(). - Refreshes rebuild auto-derived metadata on the main/control thread. Repeated pending requests coalesce.
- Field identities and storage layout must remain stable. Factory defaults remain the originally captured defaults rather than becoming the current live state.
- A rejected refresh retains the previously published descriptor. Correct the declarations and explicitly request another refresh to retry.
- Added
DescriptorRefreshBuffer()for applications whose custom descriptor capacity exceeds the default workspace. - Hand-written
Descriptor()data and preset-free Hosts do not support this refresh path. - Hosts discover changes through descriptor length/CRC and restart descriptor transfers from offset zero.
Button gesture metadata (#37)
- Root button descriptions now include structured tap and hold declarations and their help text.
- Firmware no longer needs duplicate metadata-only actions to expose those gestures.
- Existing explicit
.Action()descriptions remain supported.
Codec-input triggers (#38)
- Reworked J1/J2 detection around fast positive transitions through the AC-coupled input path, with hysteresis and ringing rejection.
RisingEdge()consumes an atomic latch. Multiple rises between reads coalesce; applications should read once and reuse the result.- V2
StartAudio()now accepts an omitted application callback. The SDK still processes trigger inputs and claimed codec CV outputs; unclaimed outputs are cleared. - Removed
SetTriggerThreshold(). Detection no longer requires threshold configuration. - These inputs detect transitions, not sustained gate levels or DC pitch voltage.
J9/J10 CV conversion (#52; issue #48)
- Corrected voltage-to-sample polarity and gain for the standard Seed2 DFM DC-coupled output circuit.
- Nominal conversion is
sample = volts / -8.666667, with generated samples bounded to the codec range. - Non-finite voltage requests are rejected without replacing the previous target.
- This is a circuit-based conversion, not per-board calibration. J9/J10 have no ADC readback through this path.
- Ordinary audio output and the existing CV ownership API are unchanged.
Volts()continues to report the requested target, not a measured output.
Calibration validation (#36)
- Added checks for reference validity, input noise and saturation, disconnected-output isolation, sweep progression, fitted response, and usable range.
- Failed measurements are rejected before replacing the stored calibration.
- Calibration persistence now verifies the written record before reporting success.
- Stored records receive stronger validity checks before use.
- Calibration remains specific to J3–J8. It does not calibrate the codec outputs.
Compatibility and companion web changes
- Descriptor additions remain optional and
dvstays 1. - The updated programmer supports selector bins, hidden controls, runtime metadata refresh, diagnostics, and Hosts without presets.
- Existing firmware retains its supported editor behavior.
- Applications using
SetTriggerThreshold()must remove those calls. - Clock code outside
ControlLoopmust explicitly adopt a suitable timebase; updating the SDK does not rewrite an application’s timestamp source.
v0.11.00 files
Alchemy SDK v0.11.0 | 2026-09-04
Parameter locks can now follow the musical clock, shift layers are just pages, and pots no longer drift with ADC noise at idle. Also: reliable lock on fast external clocks, batched CV output (thanks to Nicholas Marrone), and a compile-time guard against braced-list temporaries.
Parameter locks v2 (#29)
- Added clocked mode.
locks.UseClock(clock_)supplies the clock;settings.UseLocks(locks)adds a persisted Free/Clocked selector on P5. In Clocked mode a finished recording snaps its loop boundary to the nearest musical division and restarts against the clock through tempo changes. Straight and triplet divisions by default;SnapGrid()adds dotted. - Slot header
'PLK1'to'PLK2', 5 bytes to 7. Locks saved under the old header load as empty. - Arm threshold lowered from 3% to 0.5%. Clear threshold stays 3%.
record_baseis now the pre-nudge origin. AddedArmThreshold()andClearThreshold(). - Added
ExitMode(LockExit::Return): on release the value returns to the recorded origin and pot catch re-arms.LockExit::Latchis the surface default and matches v0.10.0.UseLocksalso adds a persisted Return/Latch selector on P6, defaulting to Return. Default settings stack: P1 brightness, P2 free, P3-P4 presets, P5-P6 locks. - Added
RecordStyle()andPlayStyle()(Solid, Blink, Breathe, LoopPulse, Sweep; red default),ClearSlot(),Freeze(),PlayPhase(). - Fixed: a trigger release landing under the Settings gate was dropped, so
OnButtonUpnever ran and the manager kept the hold. - Added
ParamLock::TakeCleanRelease(): true once per trigger release whose hold armed or cleared nothing. A release under the Settings gate still reaches the manager but never counts as a tap. - Behavior documented in
docs/param-locks.md.
Pager navigation bindings (#30)
- Added
Pager(num_pages, num_pots)with.Cycle(btn, pages...),.Shift(btn, page),.Latch(btn, a, b)and.From(pages...). ThePager(b1, n, m)constructor is unchanged and equivalent to.Cycle(b1)over every page. Serialized image and'PAG0'schema unchanged. - Shift layers engage on the press edge. Pot catch re-arms in both directions and runs only on the active page.
- Added
Pager::HoldUsed(). A hold that edited no parameter does not claim its release; a hold that edited one does. - Latch pairs occupy one cycle slot and remember the selected member.
- Added arbitration: presses under or spanning the Settings gate are
poisoned, co-pressing two navigation buttons aborts layers, one layer
runs at a time, and
GoToPagecancels an active layer. - Added
KnobStorage::ConsumesRelease(),HoldClaimed()andIndicatorColor(). ButtonBanksuppresses a tap whose release a used hold claimed and resolves its consume handshake throughConsumesRelease(). Hold gestures claim at the release rather than at fire time.ControlLooppaints navigation indicators per button throughIndicatorColor(). It previously painted the page tint on button 0 regardless of which button the pager used. A customKnobStoragemust implementPageButton()or overrideIndicatorColor()to get one.ParamLockbanks its record gesture at the trigger's press edge, which fixes aGoToPageduring a hold retargeting an in-flight recording. It holds the pager view latch (RetainPage,ReleasePage) and consumes a release only when its trigger carries a release-driven binding.- Fixed: a pager button pressed under or across the Settings gate no longer advances the page on release.
ConsumeButton()claims this frame's releases and the release of any bound button held when it is called. A claim made with no bound button held is discarded at the end of the frame.- Added
docs/pages-and-layers.mdandtests/host/pager_nav_tests.cpp.
Pot conditioning (#32)
- Added a 20 Hz one-pole filter on the V2 pots, applied at the 1 kHz
ProcessAllControls()cadence and primed on the first ADC scan. - Added a motion gate to pot catch, in a timed
UpdateCatch(state, phys, t_ms)overload. A caught pot holds its stored value exactly while asleep, wakes on more than 0.4% of travel, tracks the knob 1:1 while awake, and sleeps after 400 ms within a 0.15% band.PagerandSettingscall the timed overload. The untimed overload is unchanged. - Added
tests/pot_gate_test.cpp.
ClockFollower (#31)
OnPulse()writes to an 8-deep single-producer ring instead of a one-slot mailbox. Pulses are no longer dropped when a poll runs later than the pulse period. Fixes loss of lock on fast external clocks.- Phase error is measured at the pulse timestamp through
MusicalClock::LastTickUs().Update()andTick()may be called in either order. - The PI target scales with the number of pulses drained per update.
- Added
tests/clock_follower_test.cpp.
Batched CV output (#28)
- Added
CvJack::StageVolts()andAlchemyLabV2::FlushCvOutputs().StageVolts()writes the shared MCP4728 shadow and sets a dirty flag.FlushCvOutputs()latches every channel with one WriteAll and one LDAC pulse, about 430 us, and returns immediately when nothing is staged. - On J7 to J10
StageVolts()is identical toSetVolts(). SetVolts()is unchanged.- Thanks to Nicholas Marrone.
Builders reject braced-list temporaries (#33)
Selector,Labels,Colors,ButtonsandJacksreject a braced list at compile time. The list was a temporary and the retained pointer dangled. Pass a named array.VirtualButton::Colors(ptr, n)no longer defaultsn..Colors(kArray)resolved to this overload without a count, skipping the zone-count check.
Also
- Added
BrightnessHandle::At()andSettings::RelocateSlot(), which move a settings slot to another page and pot. - No descriptor or wire changes.
dvstays 1.
- Added clocked mode.
v0.10.00 files
Alchemy SDK v0.10.0 | 2026-08-19
Modules can now ship their own documentation, and the descriptor build stopped failing quietly. Plus, a new clip indicator light tool. And some errors are fixed.
Interactive manual support (#21)
- Firmware can carry its own manual.
.Help()attaches prose to knobs, buttons, settings, pages, and jacks;.SeeAlso()makes typed cross-references that resolve and validate at build time. alchemy::Jack(surface/jack.h): pure descriptor metadata for a panel jack: id, name, silk label, signal class, normalling. No runtime behavior, no state, no schema-hash impact.alchemy::Manual(surface/manual.h): module-level tagline, preamble, and long-form sections, plusstock_helpfor the features the SDK owns (presets, parameter locks, storage, brightness).VirtualButton::GestureHelp(key, md)keys help to the gesture string passed to.Action(); a key matching no declared gesture fails the build.- Settings slots gain identity:
.Ident(),.Name(),.Help(), labeledSelector, and per-page help. - Descriptor rendering no longer depends on the order of calls in
main(). Factory defaults are captured before the boot preset loads; the descriptor renders once everything is declared. - A failed descriptor build reports its reason over the wire instead of returning zero, so a bad cross-reference surfaces in the host rather than as a module that silently claims to have no descriptor (protocol §5.2).
- Additive on the wire;
dvstays 1. Prose never reaches any schema hash, so editing help text cannot invalidate a saved preset.
Calibrated VDDA for CV input volts (#22)
- CV input voltage conversion uses the calibrated VDDA rather than the nominal value.
ClipIndicator: SDK clipping light (#19)
-
alchemy::ClipIndicator(anims/clip_indicator.h) — audio-fed clip light with a 98 % full-scale threshold, leaky-bucket transient suppression (a one-sample graze no longer flashes the panel), and a 50 ms minimum hold that retriggers while clipping persists. FeedProcess()from the audio callback (ISR-safe single-word handoff); everything else is wired byloop.Use(clip)— detection ticks at the poll cadence, pips draw above the perf render. -
Declares like a VirtualKnob, with good defaults (every pot pip, red, 50 ms):
static ClipIndicator clip = ClipIndicator() .Hold(120.0f) .Pots(kPotTopLeft, kPotTopRight) .Buttons(kButtonB3);Also
Threshold,Suppression(samples, window_ms),Accent,PipHour,Color,AllPots, andSetEnabled()for a settings toggle. Bespoke loops callTick(dt_ms)/Draw(panel)directly;Draw(panel, color)serves palette-driven modules. -
The sample scan runs as integer bit-pattern compares (abs = clear the sign bit; IEEE ordering is monotonic), so the audio-side cost is ~7 integer instructions per checked sample with no FPU flag transfers — and it probes at a stride derived from the suppression config (
min_clipped_samples / 2, capped at 16; 4 for the default 8), with each hit counting as one stride of samples. The derivation keeps the suppression contract intact at any setting — a lone sub-stride burst can never reach the trigger — while the scan gets proportionally cheaper as suppression relaxes, and exact when it drops below 4. Non-finite samples (NaN/Inf) count as clipped — a broken signal lights the alarm.
- Firmware can carry its own manual.
v0.9.00 files
Alchemy SDK v0.9.0 | 2026-08-14
The first versioned Alchemy SDK release, trying out a new cadence instead of merging PRs as I please.
Parameter locks: configurable length (#11)
ParamLock<slots, LockLength<seconds, rateHz, storage>>— length, rate, and storage policy are per-module choices; the default is 16 s @ 30 Hz (up from 4s). Guide:docs/param-locks.md.- Motion stores as u16 : half the preset bytes per sample, so double the length fits the same preset budget.
LockStore::RamOnlykeeps a lock surface out of presets entirely if you're playing tight with the preset bundel.
Buttons: VirtualButton runtime API + ButtonBank surface (#12)
- One declaration derives zones, labels, the tap-cycle gesture, browser chips,
per-zone LEDs, and the preset byte. Buttons self-describe as a
kind:"buttons"component;Anchor()renders a button beneath a named field. Guide:docs/buttons.md. - Misc fix that snuck into this PR: the root
CMakeLists.txtself-locates, so the SDK works underadd_subdirectory().
ControlLoop: CV keeps running through Settings (#14)
- Fixed stupid issue where CV matrix was skipped when settings was open.
Pager direct addressing + settings gestures metadata (#17)
Pager::GoToPage(page, phys)goes to a target page with pot catch as expected.- Settings components advertise gesture-owned pots (
gestures: [{page, pot, name}]) so the web tool can show that clearly.
Named pot position constants (#13)
- Some nice aliases for the canonical pot positions; examples updated to use them.
Flash without touching the module
hostlink-clican reboot a running module straight into the system bootloader over the same USB connection the web editor uses, so no need to power cycle the rack. Pair it with dfu-util in a Makefile target for one-command flashing:.PHONY: program-live program-live: all node $(ALCHEMY_DIR)/tools/hostlink-cli/hostlink.mjs reboot bootloader dfu-util -w -a 0 -s $(FLASH_ADDRESS):leave -D $(BUILD_DIR)/$(TARGET_BIN) -d ,0483:$(USBPID)So: you can now use
make program-liveto reboot the module over USB and flash new firmware. Just add this to your makefile. This has already been added to the template repo.New documentation
- Incredible new docs are now at https://hermeticmodular.com/docs
Upgrading from unversioned main
- Presets survive except recorded lock motion, which resets once (
'PLK0'→'PLK1'). - Lock configs whose arena exceeds 16 KiB stop compiling until placed deliberately — see
docs/param-locks.md. This probably won't impact you. - Back compat looks good: older hosts ignore the new descriptor keys; older firmware keeps working against the updated web editor.


