Previous build aborted at the ASCII guard; the .mpy on the server didn't update. This
adds the patch as a new commit (no history rewrite).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BPM lower bound dropped 30 -> 5 (engine + ramp + slave-clock-in interval filter; pure
clamp choice, no engine reason for the higher floor). Very slow practice is now
possible. Sub-musical (<5) sits below the slave-decay timeout so wasn't worth pursuing.
Hamburger menu (☰ at the far right of the header, 3 thin recolorable rects; tap zone
covers the corner). Reuses the existing overlay / _ovbtns pattern - no new framework.
- Main menu (_show_menu): Save edits / Revert edits (dimmed when not _dirty) / Continue
on-off (live) / Settings > / Help > / About / Done.
- Settings sub-modal: LED brightness 5..50% in 5% steps, Speaker mode (Auto / Always /
Off, combining MUTE_SPEAKER + SPEAKER_AUTO_MUTE), MIDI Out on/off, MIDI Channel 1..16,
Clock Out on/off, Clock In on/off. < value > adjusters reusing the lane-editor row
pattern. Each tap calls _save_settings(); the modal redraws live.
- Help sub-modal: 3 paginated pages (Transport & Nav / Editing / Status & Hardware)
with < Done > nav.
- About sub-modal: version, free RAM, uptime, CircuitPython version, site URL. Defensive
about gc.mem_free and sys.implementation (CircuitPython-specific).
- Persistence to /settings.json: read in __init__ (_load_settings overrides defaults),
written on every change (settings are small + infrequent). NAKs silently if read-only.
Verified in the harness: all 4 modals render, settings.json round-trips (channel 10->11,
clock_out off->on persisted), tap zones populate as expected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New config: MIDI_CLOCK_IN (default OFF) + MIDI_CLOCK_IN_TRANSPORT (default ON).
_feed_midi now intercepts 0xF8 (clock tick) -> smoothed bpm tracker (exponential, a=1/8;
rejects out-of-range intervals so noise can't drag bpm wild), 0xFA / 0xFB (Start /
Continue) -> start playback without echoing 0xFA on output (would feedback), 0xFC ->
stop. _slaved flag is True while ticks are arriving and decays after 1s of silence.
While slaved: per-master-step continuous ramp is OFF (the master's tempo wins) and
Clock Out emission is suppressed (no feedback loop if the user enables both).
Verified in the harness: target 60/120/180 BPM all track exactly after 60 ticks
(2.5 quarter notes of smoothing); 0xFA -> running, 0xFC -> stopped; slave flag
correctly decays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Push to 0.0.14 stalls at ~150 chunks with the MIDI badge going gray (=device silent for
>1s). It's an occasional slow flash flush on the device side — the file buffer fills,
flush takes several seconds, the editor's 4s timeout cuts it off too early.
- Editor: bump per-chunk ACK timeout 4s -> 10s; progress log every 25 chunks now with
elapsed seconds so you can see it advancing through a slow chunk.
- Device 0.0.14: flush per chunk (small, predictable per-chunk cost instead of
infrequent multi-second bursts) + gc.collect() every 50 chunks to keep the heap
fresh during a long push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If Continue was toggled on mid-segment, _prepare_next never ran during bar (bars-1)
and the seam stayed un-armed. Fall back to prep on the spot at the boundary itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Speaker rename (production device has a full audio circuit, not a buzzer):
- MUTE_BUZZER -> MUTE_SPEAKER, self.buz -> self.spk, P_BUZ -> P_SPK
- New SPEAKER_AUTO_MUTE flag (default True): mute the speaker when a MIDI host is
detected (the old hardcoded behavior; now a setting).
Gapless seam between tracks (Continue):
- _prepare_next() pre-parses the next playlist item during the LAST bar so the swap is
allocation-free. _do_advance() swaps lanes/bpm/bars/ramp/trainer in with
lanes[0]['next'] = seam_t (the wall-clock time of the boundary step we just hit), no
_reset_clock - the next tick fires step 0 of the new track exactly at the boundary.
tick() breaks out at the seam so the old voice's boundary beat is NOT fired (it'd be
the new track's step 0 a few ms later). Visuals (build_grid + draws) are deferred
one display-refresh cycle behind the audio via _need_redraw, so the audio doesn't
wait for them.
Continuous ramp:
- Replaced the bar-boundary set_bpm step with per-master-step linear interpolation:
bpm = _ramp_base + amt * ((m_steps/mlen) % bars) / ramp.every (clamped 30..300).
The integer-clamped bpm glides smoothly across the segment. draw_bpm() is now lazy
(skips the bitmap alloc if the displayed integer hasn't changed), and the periodic
meter tick in run() also redraws BPM so the big number follows the ramp.
MIDI Clock Out (master):
- New flags: MIDI_CHANNEL (default 10 = GM drum), MIDI_CLOCK_OUT (default OFF),
MIDI_CLOCK_OUT_TRANSPORT (default ON). midi_send() now uses the configured channel.
In tick(), when running + MIDI_CLOCK_OUT, stream 0xF8 at 24 PPQN with the interval
computed live from self.bpm (so it follows the continuous ramp). toggle() sends
0xFA on Start and 0xFC on Stop when transport is enabled.
Verified in harness: seam keeps lanes[0]['next'] = seam_t (no _reset_clock); ramp 80
glides via +0.25/step (visible as 80->81 in 4 master steps at rmp80/4/4); Clock Out
math sound (60/120/180 BPM -> 41.67/20.83/13.89 ms tick interval).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per request, stay on 0.0.x; the lane-editor build is relabeled 0.0.13 (no code change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tap the instrument name -> a modal to change Sound (cycle the GM voices), Beats (1-12),
Subdivision (1-8), Swing, and Mute, plus + Lane / Remove (1..MAXLANES). Beats/sub changes
regenerate the lane's default accents; sound/swing/mute keep the pattern. Reuses the
existing dirty + Save/Revert + .mpy machinery (edits to a built-in save a copy to
"My edits"). The modal redraws live as you adjust; tap Done or outside to close.
Verified in harness: editor opens (13 hit-zones), sound cycles, beats/sub regen steps,
swing/mute toggle, add/remove lanes, the edited track serializes + round-trips, Done
closes; modal renders cleanly. app.mpy builds (C/v6).
This completes the Phase-2 editing set (beats + lanes + Continue + built-in/user split).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Built-in Song playlist: every section is now b4 (~4 bars) so Continue rolls one into
the next quickly.
- On-screen timer now counts WITHIN the current segment and resets every time the bar
counter wraps (new _seg_start, reset at each b<n> boundary + on _reset_clock). The
practice-log duration still uses play_start (total). Unified the segment-boundary
handling (timer reset + ramp restart + Continue advance) in _on_new_bar.
- MIDI stutter: display.refresh() BLOCKS on the SPI stream and was delaying the next
beat's note. Cap refresh to ~30Hz and poll the GT911 touch ~30Hz (was every loop) so
the scheduler fires notes on time; visuals lag a few ms (imperceptible).
Verified in harness: Build(b4,rmp92/4/2) bpm 92->96->reset@bar4, seg_start resets only at
the boundary, Continue arms there; edit tests pass; app.mpy builds (C/v6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the .mpy loading (no more OOM), the full app ran on hardware for the first time and
hit _slkey()'s c.isalnum() in the set-list dedup -> AttributeError (MicroPython/CircuitPython
str omits isalnum; my CPython harness has it, so it slipped through). Replaced with a
membership test against an explicit alnum set (uses only .lower()). Also compile the .mpy
from inside pico-cp/ so tracebacks read "app.py" instead of "pico-cp/app.py".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single-file app grew to ~57KB; CircuitPython compiling it at boot fragments the
RP2040 heap so badly that the fonts can't get a contiguous block (161KB free, yet a
~16KB alloc fails). Fix: precompile to app.mpy (Adafruit mpy-cross for CP 10.2.1, emits
CircuitPython mpy v6) so the device loads bytecode without compiling -> no fragmentation.
- build.sh precompiles pico-cp/app.py -> dist/app.mpy via tools/mpy-cross (gitignored
binary); the bundle ships app.mpy (NOT app.py); serves pico-cp-app.mpy + pico-cp-app.py
(the .py only for the editor's version regex + as readable reference).
- Loader (code.py) imports app.mpy and rolls back app.bak as .mpy.
- One-click updater now pushes the .mpy: editor base64-encodes it and sends it over the
existing flow-controlled chunked transport (512-char = mult-of-4 chunks); the device
base64-decodes each chunk to /app.new and verifies the CircuitPython .mpy header
(magic 'C', v6, >=4KB) before the A/B install. Version still read from the served .py.
Verified: mpy-cross emits magic 'C'/v6; build produces a 21.8KB app.mpy; editing-logic
harness + scene render still pass; and a simulated push (base64 -> 57 chunks -> a2b_base64)
reassembles the .mpy byte-exact and passes the device's header check.
One-time recovery: delete app.py from the drive, copy app.mpy + code.py from the new zip.
After that, updates are one-click again (and can't brick: header check + A/B rollback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Tap a beat to cycle it (off->normal->accent->ghost); the title turns red (unsaved).
Tap the title -> SAVE / REVERT modal. Editing a built-in saves a COPY into a "My edits"
user playlist (built-ins stay read-only); editing a user item updates it in place.
Saves persist to programs.json (NAKs gracefully in editor mode / read-only).
- New round-trippable serializer (lane_to_str/_prog_str): parser now keeps groups + @db
gain + ramp start; verified parse->serialize->parse on all 23 built-ins (0 mismatches).
- Continue (CONT) toggle, top-right of the tab line: when on, a playlist auto-advances to
the next item at the end of each item's b<n> segment (no log spam, keeps the stopwatch).
- Touch routing consolidated: tab=switch playlist / CONT, title=save-revert, pads=cycle,
log=delete; modal overlay drawn on top.
Verified in the harness: beat cycle+dirty, built-in edit -> My edits persisted (built-ins
untouched), revert, Continue arming at segment end, overlay SAVE-tap, and both renders.
Next (0.1.0): tap the instrument name -> lane-parameter table (reuses this save machinery).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The standard editor defaults (Styles / Practice / Song) are baked into app.py as
BUILTIN_SETLISTS (ASCII-fied — emoji/accents would break the 7-bit push + the fonts),
so they update with firmware and the user can't change or delete them. User playlists
live separately in programs.json and are merged after the built-ins.
Device:
- Set-list model: self.setlists = built-ins + user lists (deduped by normalized title,
so a baked built-in always wins). load()/goto() work within the current list.
- Navigation: a set-list "tab" (small, above the title) shows playlist + position,
muted for built-in / cyan for user; TAP it to switch playlists. Joystick L/R = item.
- SysEx 0x10 (push) writes programs.json -> rebuild user lists; built-ins untouched.
- Shipped programs.json is now empty ({"setlists":[]}) — built-ins come from firmware.
Editor:
- "Save to device" now syncs only YOUR set lists (filters out the built-in seeds) in the
new {setlists:[...]} format; warns if you have none. Load-from-device imports both the
new multi-list and old flat formats.
Verified in the harness: 3 read-only built-ins, set-list switching, user-list merge +
dedup of a pushed "styles", and the ramp engine on a built-in track (80->84->88, +4/4 bars).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Device parser now reads the rmp<start>/<amt>/<every> and tr<play>/<mute> tokens it
previously ignored, and the firmware performs them:
- Tempo ramp: steps bpm by <amt> every <every> bars (resets to the start at each b<n>
segment boundary). Shows an amber ramp arrow + "+amt/everyb" (up/down by sign; no
starting bpm, per request).
- Gap trainer: cycles <play> audible bars then <mute> silent bars (no click/MIDI/LED;
playheads keep moving). Shows a play|rest symbol + "play/muteb".
- Practice log entries now record + show bars played.
Verified in the CPython harness: ramp 92->96->100->104->108 (+4 every 2 bars), gap
mute cycle play,play,mute,mute, and the on-screen ramp indicator renders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
APP_VERSION -> 0.0.6. Device firmware + editor change in lockstep — one-time manual
copy of 0.0.6 needed (the broken single-shot updater can't deliver it).
Update transport (fixes the failed/bricking updates):
- Editor now pushes app.py in 512-byte flow-controlled chunks: begin(0x21,len) ->
data(0x22)* -> commit(0x23), waiting for each ACK before the next. A single ~38KB
SysEx overran the device's USB-MIDI input buffer and arrived corrupt.
- Device writes chunks straight to /app.new, and on commit verifies length + no NUL +
App().run()/APP_VERSION present before the A/B install; rejects (NAK) otherwise and
keeps the working build. All errors caught -> never bricks.
Run/stop indicator moved off the screen onto the RGB LED (per feedback that recoloring
the whole background is wrong — it forces a full-screen SPI repaint and fringes the
anti-aliased text):
- Dim GREEN when stopped ("on"), dim RED while playing; the beat pulse flashes brighter
and now decays back to the running base instead of to black. Background is static black.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause: a non-ASCII em-dash in an app.py comment. The A/B updater pushes app.py
as 7-bit SysEx (charCode & 0x7F), which turned the em-dash's bytes into a NUL byte ->
corrupt source -> the pushed build crashed on boot (black screen, onboard LED blinking
CircuitPython's error/safe-mode pattern). A dragged copy was fine (valid UTF-8); only
the over-MIDI path mangled it.
- Replace the em-dash with ASCII; app.py is now pure ASCII.
- build.sh now ASSERTS pico-cp/app.py is pure ASCII (fails the build otherwise) so this
class of bug can never ship again.
- Device 0x20 handler VALIDATES the pushed app.py before installing (reject if it
contains a NUL byte, or is missing App().run()/APP_VERSION) and now catches ALL
exceptions (not just OSError) -> a corrupt/truncated/oversized push NAKs and keeps the
working build instead of bricking. Longer pre-reload sleep so the ACK flushes.
APP_VERSION -> 0.0.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App.py-only (ships over the one-click updater). APP_VERSION -> 0.0.3.
- Run/stop is now a background tint (gray running / near-black stopped) instead of
STOP text, reclaiming the space.
- Running time + bar counter show "of total" when the track has a b<n> length:
"1:23 of 2:00" and "bar N of 16" (bar cycles 1..N); total time derived from
bars x master-beats-per-bar x 60/bpm. Parser now reads the b<n> token.
- Practice log is filtered to the current track (drops the redundant track column).
- Pads: squares for the main pulse, circles for subdivisions (was square + hollow
outline); fewer vectorio shapes too.
- Track number set apart from the title (small + dim, right) so it no longer reads
as part of the title.
On-device editing (tap instrument -> lane table; tap beat -> cycle state; dirty-name
-> confirm save/revert) is deferred to Phase 2, where "save" has a correct destination
(an edited built-in saves as a user copy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Device screen redesign (CircuitPython app.py), built proportional to WIDTH/HEIGHT
so it scales to other panels (one adaptive firmware, per-panel config — not a fork):
- gen_assets.py bakes logo.bin (VARASYS wordmark, no tagline), midi.bin (DIN-5),
usb.bin (trident) as 4-bit-alpha bitmaps (same packing as the fonts).
- Header: VARASYS logo (brand cyan) replaces the "PM_K-1 KIT" text; MIDI icon goes
green when a host is listening, USB icon lights when supervisor.runtime.usb_connected.
load_alpha/make_glyph are non-fatal — a missing .bin falls back to text, never a
black screen (addresses the corrupt-file failure mode we just hit).
- Pad grid: filled squares on main beats, hollow outline squares (outer+inner rect) on
off-beats; playhead fills the lit pad. Vertical gridlines at the master lane's beats
(full height) so beats line up across lanes.
- Stopwatch (m:ss) + bar counter (master-lane cycles), refreshed ~4x/s only on change.
The .bin assets ship in the drive bundle (the A/B updater only pushes app.py), so a
one-time re-copy is needed to pick them up. APP_VERSION -> 0.0.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split the CircuitPython firmware into a tiny stable loader (code.py) + the application (app.py,
carries APP_VERSION). The editor's ⋯ → "⬆ Update firmware" queries the device version (SysEx 0x02
-> 0x03 reply), fetches the latest app from the site (/pico-cp-app.py), shows device-vs-latest, and
pushes the new app.py over USB-MIDI (SysEx 0x20). The device installs it to a trial slot (old build
kept as app.bak), reboots, and the loader AUTO-ROLLS-BACK to app.bak if the new build fails to start;
a build that runs cleanly ~5s is confirmed (clears /trial). No BOOTSEL, no dragging; Chromium/Firefox.
app.py forced to pure ASCII so it pushes raw (no base64); SysEx buffer raised to 60KB.
build.sh/deploy.sh: bundle code.py+app.py and serve /pico-cp-app.py. Docs updated.
Verified in CPython: version reply, update install+reboot+ACK, rollback file dance; editor loads clean
with the updater wired.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>