Changelog
Format loosely follows Keep a Changelog.
Versioning
This project uses date-based version numbers (vYYYY.MM.DD), not
semantic versioning – there is no compatibility contract between
versions to track (it’s a single dedicated appliance build deployed to
one Pi, not a versioned library with multiple independent consumers).
A version is just “the state of this component as of this date,”
matching the date-grouped structure this changelog already used before
versioning was introduced.
Each independently-installable component tracks its own version, in
its own VERSION file, bumped whenever a meaningful change to it lands
on main:
VERSION– the base pedal system (src/,systemd/, root docs).expansions/setlist-admin-usb/VERSIONexpansions/setlist-admin-wifi/VERSION
To check what’s actually deployed on the real Pi: ssh -4 pedal
"cat ~/chocolatepi-repo/VERSION ~/chocolatepi-repo/expansions/*/VERSION"
(compare against the git commit it’s checked out to, since a VERSION
file only changes when someone remembers to bump it – the commit hash
is always the ultimate source of truth; the version number exists to
give a human a memorable string to reference instead of a hash).
This changelog stays the source of truth for what changed and why;
[Unreleased] collects changes not yet cut into a dated version below
it. Cutting a version means: confirm the change actually works
end-to-end (real hardware for anything touching it, not just unit
tests), bump the affected component(s)’ VERSION file(s), and rename
[Unreleased] to that date – reopening a fresh empty [Unreleased]
above it for whatever comes next.
[Unreleased]
- Fixed a real race in
rollback.sh, found running it for the first time ever against the real Pi (2026-10-03): disablingsetlist-admin.servicebeforeusb-tether-watchdog.serviceleft a window where the watchdog – now also WiFi-aware, not just phone-tether-aware – started it right back up moments later, while its unit file was still on disk. Confirmed live:systemctl statusshowed itactiveagain minutes after the script finished,Loaded: not-found. Fixed by disabling the watchdog first, closing the window. Re-ran the full uninstall → reinstall cycle afterward and confirmedpedal-core.servicewas never touched, the PIN hash and every Bank in the library survived byte-for-byte, and the admin app came back up on its own over WiFi immediately after a fresh install. library-optimizer.servicenow automatically cleans up leftover scratch files from an interrupted job, real user request after noticing ~470MB of them had piled up over one day of testing (2026-10-03): a service restart mid-job (manual, a crash, a reboot) abandons whatever_process_job()was mid-copy/mid-encode –recover_orphaned_jobs()already reset the queue’s status back to “queued” in that case, but had no idea the abandonedscratch_input/scratch_outputfiles under~/pedal-optimizer-scratch/existed, so they just sat there forever (previously only removed byrollback.sh, i.e. only on a full uninstall).run_forever()now sweeps the entire scratch directory once, unconditionally, at every startup, before the first tick – always safe, since a job reset to “queued” gets a brand new scratch copy under a fresh random filename once it’s picked up again, so nothing already on disk at startup can ever be resumed or referenced. Mirrors the same pattern already used for the USB’s own.partfiles and the admin app’s upload-scratch directory (see the entry below). 5 new tests intests/test_library_optimizer.py.- Fixed: a slow upload held
pedal-core.servicestopped (no standby, no playback at all) for its entire network-transfer time, real incident found live testing the new WiFi-reachability feature above (2026-10-03):upload_song()/assign_track()(expansions/setlist-admin-usb/src/admin/api.py) used to read the upload’s raw, network-backed stream directly inside_writable_usb()– so the window pedal-core.service stayed down for was bounded by the uploader’s own connection speed, not by the USB write itself. Confirmed live: a 76MB upload over a weak WiFi connection left the pedal dark for roughly 15 minutes. Fixed by receiving the upload to local scratch storage on the Pi first (AdminAPI._receive_to_scratch(), new--upload-scratch-dir, cleaned up at startup the same way.partfiles on the USB already were) and only wrapping the short, local-disk-speed copy from there onto the USB in_writable_usb()– the same scratch-then-copy patternlibrary_optimizer.pyalready uses for its own, often larger,ffmpegoutput. 4 new tests intests/test_admin_api.py, two of which fail immediately (by design) if a future change ever goes back to reading the upload stream inside the writable-USB window. -
setlist-admin-usbcan now also be reached over WiFi, no phone needed, real user request (2026-10-03):usb-tether-watchdog.service(expansions/setlist-admin-usb/src/admin/usb_tether_watchdog.py) gained a second, independent condition alongside phone-tether detection –wifi_connected_to_profile()checks vianmcliwhether the Pi’s active WiFi connection is an exact match for one specific, named NetworkManager profile (default"preconfigured", the one Raspberry Pi Imager saves during initial setup – confirmed live vianmclithat this is exactly how the Pi already reconnects to the band’s home WiFi with no extra configuration whenever a USB WiFi dongle is plugged in).setlist-admin.servicenow starts if either a phone is tethered or the Pi is on that trusted WiFi network, with both able to be true at once with no conflict; losing both stops the service, same as before. Deliberately trusts only that one named profile, never “any WiFi with a usable IP” – an unrelated network (a venue’s guest WiFi, say) must never expose the PIN-gated admin app. See that expansion’sSPECIFICATION.mdsection 16 andUSAGE.md’s “Reaching the app over WiFi” for the full design and usage. 10 new tests inexpansions/setlist-admin-usb/tests/test_admin_usb_tether_watchdog.py.As part of the same work,
setlist-admin-wifi(the separate, more complex expansion with its own hotspot-configuration UI and encrypted on-USB credential storage) is now formally reframed in its own docs as a roadmap item, not near-term work – it’s implemented and unit-tested, and its original hardware blocker (a dead USB WiFi dongle) was resolved 2026-10-01, but a full revival isn’t worth doing now that the simpler feature above covers the actual need. No code in that expansion changed. Also documented: the actual procedure for changing which WiFi network the Pi connects to after its initial setup (systemd/README.md’s new “Changing which WiFi network the Pi connects to” section, cross-referenced fromTROUBLESHOOTING.mdandsetlist-admin-usb’sUSAGE.md) – there wasn’t one written down anywhere before this. -
Added a resource-aware video lane, real user request (2026-10-03): the video lane’s
mpvprocess was measured live costing a full CPU core (~106%) and ~300MB RAM (34% of the reference Pi 2’s total ~921MB) continuously, just to loop standby – real, ongoing cost for nothing if no display is physically connected to show it. A newDisplayMonitor(src/core/display_monitor.py) polls the DRM connector’s sysfsstatus(debounced, so a loose/flickering cable doesn’t flap the lane) andCore.set_display_connected()starts/ stops the video lane accordingly, including once at startup with the real boot-time state. With no display connected, a video track plays its audio only (through the same lane an audio-only track already uses) instead of doing nothing – the system stays fully functional. Deliberately never interrupts what’s already playing: a clip already playing when the display disconnects keeps playing, audio and all, until it ends on its own or STOP is pressed; a live mid-clip handoff between lanes was considered and rejected (real audio-glitch risk syncing position between two independentmpvprocesses). SeeMASTER_SPECIFICATION.md’s “Resource-aware video lane” entry for the full decision record. 20 new tests (tests/test_core.py,tests/test_display_monitor.py–core.pyandplayer.pypreviously had zero automated tests, only the manualcore_smoke_test.py; the new logic here is pure routing/state, genuinely unit-testable with mockedPlayer/AudioPlayer).As a side effect, this also self-heals a real incident from the same day: connecting the HDMI cable after powering on used to leave the Pi showing no video at all until a manual
systemctl restart pedal-core.service– the kernel’s own DRM hotplug detection worked, but the already-runningmpvprocess never rebound to it. Now the video lane simply wasn’t running yet in that case, and starts fresh (correctly binding to the now-connected display) within a few seconds of the monitor detecting the cable – seeTROUBLESHOOTING.md’s updated entry. - Fixed a batch of real issues found during a full user QA pass
(2026-10-02), both expansions unless noted:
pedal-core.servicecould crash-loop for several minutes under heavy CPU load: a library write’s cleanup remount losing a race withpedal-core.service’s ownmpv(an existing, legitimate fallback – stop it, force the remount, restart it) means a brand-newmpvhas to spawn and open its IPC socket from scratch, and that took longer than the 5s budget while a concurrent “Optimize” job’sffmpegwas pinning the CPU – confirmed live, the pedal showedmpv’s idle screen for a couple of minutes. Raised to 20s insrc/core/player.py(base pedal system, not an expansion) – normal startup is unaffected (well under 1s with no contention).list_sets()listed filesystem-reserved folders (e.g. an NTFS-created system folder) as selectable Sets – confirmed live on the real library USB.- The “Optimizing”/”Queued” states rendered identically (both green “Optimizing”) – with two jobs in flight, there was no way to tell which was actually running vs. just waiting its turn. Confirmed live: this caused cancelling the wrong one by mistake. Now visually distinct.
- Uploading to the library always sent the whole file before learning about a name collision – normally fast, but can wait minutes if the USB is busy with an unrelated scratch-copy. Now checks the already-loaded song list client-side first, instantly, for the common case.
- A slow/overloaded Pi could abort a request at the network level, surfacing a raw, untranslated “TypeError” instead of a clear message.
- “Set as standby”/”Unlock”/”Set PIN” buttons gave no tap feedback, and the standby dropdown stayed on the just-applied video afterward, looking unfinished.
- New Bank creation asked for a number by hand – Banks are sequential slots a MIDI controller steps through one at a time, so it now auto-assigns the next one. Not capped at any specific controller’s own physical bank count.
- Fixed, real incident (2026-10-02): starting a second “Optimize”
job while another’s source was still being copied off the USB could
500 (
RemountError) –setlist-admin.serviceandlibrary-optimizer.serviceare two independent OS processes with separate memory, sousb_mount.py’s existing in-processthreading.Lock()did nothing to stop a write in one from racing a long read in the other. Added a real cross-process file lock (flock()on a well-known path, skipped on Windows wherefcntldoesn’t exist – local tests still rely on the in-process lock alone) that every write and the scratch-copy step now share, via a newusb_mount.exclusive_read()– a concurrent write now simply waits its turn (bounded at 5 minutes) instead of racing aumountagainst an open file descriptor and losing. - Fixed, real incident (2026-10-02): cancelling an “Optimize” job
could 500 (
RemountError) if tapped within the first stretch of a job’s life – the cancel marker lived on the USB alongside every other job marker, so writing it needed the same writable-USB remount as any other write, but the scratch-copy step that runs before every encode (see the write-blocking fix below) holds a read handle open on the mount for as long as that copy takes, well past the mount’s own bounded EBUSY retry (tuned for a briefpedal-core.service/mpv collision, not a multi-second-to-minutes file copy). Moved the cancel marker to local Pi storage instead (optimize_queue.DEFAULT_STATE_DIR,~/.pedal-optimizer-state/) – it only ever needs to reach this Pi’s ownlibrary_optimizer.pyprocess, never the USB stick itself, and never needs to survive a reboot (a job that survives a reboot gets requeued fresh viarecover_orphaned_jobs()anyway, so a stale cancel marker surviving would have incorrectly re-cancelled the next run).cancel_song_optimization()no longer needs a writable-USB window at all now. Both expansions. - Added a ES/EN language toggle, both expansions: a dropdown in the
topbar (now reading “ChocolatePi - Setlist Admin”) switches all UI
chrome – labels, buttons, confirms, alerts, toasts – between English
and Spanish, persisted per-browser via
localStorage(static/i18n.js). Song/track/Set/Bank names are user data and are never routed through translation, by design – every render function keeps those out of the translation calls on purpose. Server-sent error messages stay in English for now, a separate, explicitly deferred piece of work. - Added auto-refresh for optimize/cancel status, both expansions:
cancelling or starting a job resolves on the backend in seconds, but
the app only ever re-fetched song state on an explicit action, so the
Cancel/Optimize button stayed stuck showing the old state until a
manual page refresh. Polls
GET /api/songsevery 5s only while something is actually queued/running, and stops itself the moment nothing is. - Fixed, real incident (2026-10-02): a song’s “Optimize”/”Cancel”
state could show stale (e.g. “already optimized” while a job was
genuinely still running) after a refresh –
GET /api/songsis a plain GET with noCache-Controlheader, so a browser’s own heuristic caching served a stale response. Same fix as the static-file one below, applied to every JSON response too (_send_json()). Both expansions. - Added a power-loss warning and a real Cancel button for “Optimize”,
both expansions, after a real question about what happens to the Pi
and the file if it loses power mid-job: tapping “Optimize” now shows
an explicit warning not to unplug the Pi while it’s running, and once
a job starts, a “Cancel” button appears next to it
(
optimize_queue.request_cancel()/is_cancel_requested(), checked bylibrary_optimizer.pybetween ffmpeg polls and before a queued job starts). The in-progress indicator reads “Optimizing…” (shown as a standing green button, not hidden, alongside “Cancel”) rather than disappearing in favor of the Cancel button. - Fixed, real incident (2026-10-02): a stale
app.js/index.htmlmismatch could leave a refresh showing only the topbar, nothing else – noCache-Controlheader at all on static files, so a stale cached copy of one file could be served alongside a fresh copy of another. AddedCache-Control: no-cache, no-store, must-revalidateto_serve_static(). Both expansions. - Fixed, real incident (2026-10-02): the Export Set share/download
image still had the old “supported formats” disclaimer baked into the
PNG itself, even after an earlier commit removed it from the on-screen
view – a separate, canvas-drawn copy in
renderSetlistToPngBlob()that the earlier fix missed. Removed entirely (SUPPORTED_FORMATS_DISCLAIMER/wrapTextLines()deleted). Both expansions. - Fixed, real incident (2026-10-02): an “Optimize” job running
ffmpegdirectly against the USB-mounted source held that file open for the entire encode (confirmed live, 45+ minutes for one 4K source) –umountrefuses outright while anything holds a file open on that mount, reading included, so every other library write (even an unrelated “create Bank”) failed with a 500 the whole time. Fixed:library_optimizer.pynow copies the source to local scratch before encoding;ffmpegonly ever touches the local copy. The USB mount is held open only for the brief copy itself, not the encode’s full duration. - Fixed, real incident (2026-10-02): a transient
ffprobetimeout under heavy concurrent load (several parallel codec checks plus a long-runningffmpegjob) got cached as a permanent “not optimized” result by the then-new per-file codec cache (codec_check.py) – three files got permanently stuck showing “Optimize” even after load cleared. Fixed: the cache now only ever stores a successful probe, never a failure. - Fixed, real incidents (2026-10-02): forms (PIN entry, etc.)
weren’t centered on a wide screen –
maincenters viamargin: 0 auto, but a narrowerforminside it never did, invisible on a phone, glaring on a tablet. Also fixed a ~37-second gap between logging in and the song library actually appearing (confirmed live, ~24 songs, several multi-GB videos):list_songs()ranffprobeserially, every call, for every song – now cached per(path, mtime, size)and parallelized across up to 4 workers (I/O-bound, not CPU-bound). - Fixed, real incident (2026-10-02): an “Optimize” job stuck
“running” forever in the UI, with no “retry” option, after the Pi was
rebooted mid-job –
list_queued()deliberately never picks “running” jobs back up (on the assumption a different, live tick is already working on it), an assumption that breaks the moment the daemon itself is the one that died.library_optimizer.pynow callsoptimize_queue.recover_orphaned_jobs()once at startup, resetting any orphaned “running” marker back to “queued”. - Real data-loss incident (2026-10-01) and the gap it exposed: the
band’s actual standby video (a ~55-minute, ~754MB recording) was
permanently overwritten with no backup when it was replaced through
the app’s own “Set as standby” picker –
library_ops.set_standby_video()does a plain_atomic_copy_file()straight overstandby.mp4, with no copy of whatever it’s replacing. Recovered only because the user happened to still have the original, unconverted source file on a separate computer; re-encoded it with hardware-accelerated H.264 (h264_v4l2m2mfor both decode and encode – confirmed live, about 0.87x realtime for 1080p/25fps source already in H.264, versus the ~0.1x this project saw earlier for a software-only HEVC decode) to ~800MB, re-added it to the library, and the user re-selected it as standby. Not yet fixed in code:set_standby_video()still has no backup step – a real, now-confirmed-costly gap. Deferred product decision, seeNEXT_STEPS.md. - A working USB WiFi dongle was found and tested (2026-10-01): a
different chipset (Ralink/MediaTek
MT7601U) than the dead one that originally pausedsetlist-admin-wifi(Realtekrtl8192cu). Confirmed on real hardware: the kernel’s ownmt7601udriver attached cleanly,wlan0came up and associated with the home network automatically via an already-saved NetworkManager profile, and SSH worked over it witheth0fully disconnected. This is basic OS-level WiFi connectivity only – it unblocks resumingsetlist-admin-wifi’s own hardware validation (see that expansion’sSPECIFICATION.mdstatus note), but the expansion itself (its own systemd units,nmcliconfig read/write flows) still hasn’t been installed or exercised on this dongle. In the meantime,setlist-admin-usb’s server was reached directly over this WiFi connection too, as a manual, temporary workaround (it already binds0.0.0.0:8080, so nothing-specific to USB tethering was needed – just starting it by hand withusb-tether-watchdog.servicestopped so it wouldn’t immediately stop it back). - Fixed, found deploying the “Optimize” feature below to the real
Pi:
library-optimizer.servicecrash-looped immediately (ModuleNotFoundError: No module named 'admin') –library_optimizer.pylives undersrc/admin/and doesfrom admin import ..., same asserver.py, but unlikeserver.pyit never addedsrc/tosys.pathfirst. Invisible to the unit tests, since those already putsrc/onsys.paththemselves before importing it – only running it exactly the waysystemddoes (a barepython3 library_optimizer.py) surfaced it. Fixed with the samesys.path.insert(0, "..")lineserver.pyalready had. Both expansions. - Deployed and verified on the real Pi (2026-10-01): full overlay-
disable/deploy/test/re-enable/reboot cycle completed; 181 (USB) + 200
(WiFi) tests passed on the Pi itself;
library-optimizer.serviceinstalled and confirmed genuinely running (not just “active” mid- crash-loop, which is how the bug above was first caught); a real end-to-end pass – a tiny synthetic HEVC clip uploaded straight throughAdminAPI.upload_song(), queued viarequest_song_optimization(), picked up by the daemon in a few seconds, and reported H.264/needs_optimization: Falseonce done – confirmed the whole pipeline (queue, daemon pickup, scratch encode, atomic copy-in,pedal_core_guardfallback, queue clearing) works for real, not just under mocks.pedal-core.servicestayed up andstandby.mp4kept playing throughout. Not yet confirmed: the actual phone UI flow (tapping “Optimize”, watching the button read “Optimizing…”, disconnecting/reconnecting the phone mid-job) – that part was exercised viaAdminAPIcalls directly over SSH, not from the app itself. Seeexpansions/setlist-admin-usb/NEXT_STEPS.md. - Added a library-wide “Optimize” button, both
setlist-adminexpansions. Real user request: uploads already warned if a video wasn’t H.264 (section 9’scodec_check.py), but fixing it meant manually re-encoding over SSH – the same ~90-minute, one-off process this project had just done by hand for the standby video. Now the Song library lists an “Optimize” button next to any file (codec_check.is_optimized()) that isn’t already in the recommended format; tapping it queues the same re-encode (scale=-2:1080,fps=25, H.264 high@4.0, AAC 128k, documented inLIBRARY.md) without blocking the UI. Scoped to the library only –_Songs/entries aren’t loaded into any live Set or the standby slot until the user separately assigns them, so an in-progress optimization never affects what’s actually playable right now.- The job queue is a small JSON file per song under
.setlist-admin/optimize-queue/on the USB itself (optimize_queue.py), not in-memory – so “Optimizing…” (shown instead of the button while a job is active, confirmed by pollingGET /api/songs) survives the phone that queued it disconnecting and reconnecting later, exactly as requested. A failed job (corrupt/unreadable source, or a timeout past 4 hours) shows “Optimize (retry)” with the reason in the button’s tooltip. - The actual encoding runs in a brand new, always-on systemd unit,
library-optimizer.service, deliberately independent ofsetlist-admin.service’s own lifecycle: a job this long has to survive the admin UI being stopped by the USB-tether/network watchdog the moment a phone disconnects, which a plain subprocess of that service wouldn’t. It polls the queue every 5s and processes one job at a time (this hardware can’t usefully run two ffmpeg encodes at once). - Unlike the manual standby-video conversion (which held
pedal-core.servicestopped, and playback dark, for the entire ~90-minute encode), the daemon reads the source with the USB still mounted read-only and encodes to a local scratch directory (~/pedal-optimizer-scratch/, off the USB entirely) – the sharedpedal_core_guard.writable_usb()fallback (now its own module, extracted out ofapi.pyonce this daemon needed the identical stop-pedal-core/recover-broken-mount logic as a second caller) is only invoked for the few seconds it takes to copy the already- finished, much smaller output back into place.Nice=15/CPUWeight=10(more aggressive thansetlist-admin.service’s ownNice=10/CPUWeight=20) keeps a running encode from ever starving live playback. install.sh/rollback.sh(both expansions) now install/removelibrary-optimizer.servicealongside the existing units, androllback.shalso clears the scratch directory.
- The job queue is a small JSON file per song under
- Moved the “supported formats” hint (audio MP3/WAV, video MP4/MOV/ MPEG/MPG with embedded audio, plus H.264/1080p/~8-12 Mbps video guidance) from the Export Set screen – where it was pointless noise on a view meant to be read on stage, not uploaded from – to right next to the actual upload control in the Song library section, where it’s useful before picking a file. Real user request, both expansions.
- Fixed: found live while certifying the replace-on-duplicate
feature just above – remounting
/media/usbback tororight after a large write (a multi-hundred-MB video) timed out at 10s more than once, even though the write itself had already completed successfully. The file was never at risk (confirmed: it landed correctly both times this happened), but the request still failed with a 500, and once the broken-mount auto-recovery above wasn’t even needed (the mount itself stayed intact – this was a plain timeout, not the dead-FUSE-mount incident from earlier). Root cause: the FUSE layer appears to still be flushing buffered data to the physical device at that point, which can outlast the write call itself having already returned.usb_mount._remount()now runs a best-effortsync -f <mount>before the umount/mount pair, forcing that flush to happen up front instead of leaving it to block inside the time-limited calls that follow. Both expansions, kept convergent. - Added: uploading a song to the shared library with a name that’s
already taken now offers “… already exists in the library.
Replace it?” instead of just failing – confirm to overwrite it in
place (
_atomic_write_stream()already makes that itself safe, same as any other write here), or cancel to leave the existing one untouched. Real user request, made right after hitting the duplicate- name rejection during the same live testing session that found the two incidents below.library_ops.upload_song()gained anoverwriteparameter and a more specificSongAlreadyExistsError(aLibraryOpsErrorsubclass);api.pytranslates that specifically intoApiError(409)instead of a generic 400, so the frontend can offer the replace-it confirmation without string-matching the error text. Both expansions, kept convergent. - Fixed: a third real incident from the same live certification
session – a validation rejection that happens before the request
body is read at all (e.g.
upload_song()’s duplicate-name check, which runs before anything touches the stream) was sending its 400 response without first draining the still-unread body. For a real, multi-hundred-MB video, that left the connection in a state the client’s own TCP stack treated as reset – “Load failed” in Safari – even though the server’s response, with the correct rejection reason, had been sent. (Also discovered via this: the video had actually uploaded successfully on an earlier attempt, during the chaos of the broken-mount incident below – every retry since was correctly, if confusingly, being rejected as a duplicate.) Fixed: every raw-body upload handler (assign_track,upload_song, both expansions) now drains any unread body in afinally, regardless of success or failure, via a new_LimitedReader.drain(). Also: 400 responses are now logged server-side with their exact reason (server.py), not just the status code – a 400 is an expected outcome, not a bug, but not logging it meant no way to find out afterward what was actually rejected and why when a phone’s ownalert()wasn’t seen in time. - Fixed: a real incident, found live while certifying the new
standby-video feature – every write in both
setlist-adminexpansions (uploading, renaming, assigning, the new standby picker, all of it) started failing with a 500 as soon aspedal-core.serviceran continuously and stayed stable (which it now does, thanks to this same day’s MIDI-disconnect fix above – see that entry). Root cause:usb_mount.writable_usb()remounts the entire/media/usbvolume rw for any write at all, no matter which file – Linux has no per-file read/write mode within one mount, so the whole volume has to have zero open file handles anywhere on it.pedal-core.service’smpvkeeps whatever it’s currently looping (alwaysstandby.mp4in practice) open continuously and never releases it on its own – confirmed on real hardware: 30 straight seconds of retries, zero successes, whilepedal-core.serviceran normally; stopping it unblocked the remount instantly, every time.usb_mount._remount()’s bounded retry (0.3s x 5, built for a brief, transient busy window) was never going to get past a hold that doesn’t release at all. (Likely explanation for why this worked in earlier sessions: the MIDI-disconnect bug fixed earlier today used to restartmpvfrequently on its own, which incidentally created the brief gaps this depended on – today’s stability fix removed those by accident.) Fixed inapi.py(both expansions): every mutating method now goes through_writable_usb(), which tries the normal fast path first via a cheap, side-effect-free probe (an immediate rw-then-ro round trip, nothing written) – if the volume is free, nothing changes, zero disruption. Only if that probe itself fails does it stoppedal-core.service(releasing every open handle, including the looping standby video – playback briefly interrupts, screen goes black for a few seconds, same as any otherpedal-core.servicerestart), perform the real write exactly once, then restart it afterward. The probe’s own full remount cycle is what makes this safe to fall back from without any risk of running the actual write twice. - Fixed, found minutes after the fix above while actually certifying
it on real hardware: a phone’s connection reset mid-upload (the new
pedal-core.servicestop adds a few real seconds before a write starts, which can trip a client-side timeout/backgrounding), and the recovery from that failed write – the remount back toro– itself timed out after 10s and left the FUSE mount genuinely dead:mountstill listed/media/usbas mounted, but every access returnedENOTCONN(“Transport endpoint is not connected”), because thentfs-3gprocess backing it had simply exited. The fix above then restartedpedal-core.serviceinto that broken mount, which made it worse – confirmed live:mpvshowed its own “Drop files or URLs to play here” idle screen, with nothing loaded, until manually recovered over SSH (umount -lthen a freshmount -o ro).api.py(both expansions) now does exactly that recovery automatically: right before restartingpedal-core.service, it runs a real filesystem check (lsthe mount point, not just trustingmount’s own output, bounded by a timeout so a hung rather than fully-dead backing process can’t block it) and self-heals first if it’s broken. If recovery itself doesn’t work,pedal-core.servicestill gets restarted regardless (logged as an error) rather than left stopped forever on a headless appliance with no one available to notice.
[v2026.10.01] – MIDI-disconnect resilience, standby video management
- Reverted same day: this version briefly logged
mpv’s ownstderrintopedal-core.log(see the next bullet for why it was added) – rolled back a few hours later. Reasoning:pedal-core.loglives under/home/, which under the normal, protective root overlay is RAM-backed (tmpfs), not disk – and nothing rotates it. Checked on the real Pi: the overlay’s effective cap is ~461MB (half of this Pi’s 921MB RAM,tmpfs’s own default sizing), with no rotation on any of this project’s three long-running logs (pedal-core.log,setlist-admin.log,usb-tether-watchdog.log). Unbounded log growth on an appliance meant to run indefinitely without a reboot is a real RAM-exhaustion risk, not just a disk-space one. A log that only earns its keep during an active debugging session isn’t worth that standing cost left on permanently –core/player.pyis back to sending bothmpvlanes’stdout/stderrtoDEVNULL. If a future investigation genuinely needsmpv’s own error output, capture it temporarily for that session (e.g. patchstderr=subprocess.PIPEand tail it live over SSH) rather than leaving it logged permanently.TROUBLESHOOTING.md’s “Audio (and video) stop completely…” entry reflects the final (reverted) state. - Fixed: a real, reproduced incident – the M-VAVE being off or
disconnected (at startup, or mid-session: a loose cable, a USB hub
glitch, the kind of brief dropout a real undervoltage event can cause,
see TESTING.md) used to be treated as fatal by
src/main.py: the whole process exited and depended entirely onsystemdblindly restarting it everyRestartSec=5, which also killed and relaunched bothmpvlanes every single cycle (visible as the screen flickering back to black) for as long as the controller stayed missing – total silence, no footswitch could do anything, and nothing on screen indicated why.main.pynow retries the MIDI connection in-process, without tearing Core/mpvdown in between attempts: standby keeps looping solidly the whole time a reconnect is pending, and a footswitch press works again the instant the controller reappears. Confirmed live: powering the M-VAVE back on mid-session was picked up on the very next retry (a few seconds later), no reboot needed. - Both the MIDI fix above and the
mpv-stderr-logging attempt (first bullet, reverted the same day) came out of investigating a real report of audio stopping entirely after about an hour of intensive real-hardware testing – seeTROUBLESHOOTING.md’s new entry for the full writeup. The M-VAVE gap is the strongest confirmed lead; no direct log evidence of the Behringer itself losing power was found, since the relevant boot’s logs didn’t survive a reboot. - Added standby video management to both
setlist-adminexpansions: a new “Standby video” panel shows what’s currently looping (standby.mp4’s size/last-changed time) and lets you pick any video already in the shared song library (_Songs/) to become the new one. To use a brand new file: upload it to the library first (already supported), then choose it here – reuses the existing upload/rename/ delete song-library UI rather than duplicating it, so “save it to the library, rename it, or delete it” all already work for standby candidates the same way they do for any other song. Newlibrary_ops.set_standby_video()/get_standby_info(), always writing the chosen file to the fixedstandby.mp4name regardless of the source’s own extension (mpv plays by sniffing content, not by filename, same as every other assignment in this app).
[v2026.09.27] – Set/Bank rename, real-hardware validation, Export Set, active-Set-on-reboot fix, power-loss documentation
- Renamed the library’s terminology throughout the whole project (code,
tests, and docs): the top-level folder, previously called a “Show”, is
now a Set (a group of Banks + songs usable in a single live
performance); what was previously called a “Set” (the mid-level
folder holding up to three
A/B/Ctracks) is now a Bank, matching the M-VAVE PD41’s own bank/group terminology. Track letters (A/B/C) are unchanged.active_show.txtis nowactive_set.txt; folders go from<Show Name>/Set N/to<Set Name>/Bank N/. Bothsetlist-adminexpansions’ REST routes moved from/api/showsand/api/shows/{show}/sets/...to/api/setsand/api/sets/{set}/banks/.... The internal Mapper/Core protocol parameter namesetlist(Library.resolve(setlist, track),src/core/library.py) is deliberately not part of this rename – it’s an internal implementation detail never surfaced to a user, kept stable on purpose. Existing library USBs need their folders/file renamed by hand to match (seeLIBRARY.md) before this version’s code will find anything on them. - Real-hardware validation of
expansions/setlist-admin-usb/(iPhone USB tethering), which found and fixed several real bugs:- Transient
umountfailures (pedal-core.service’s mpv holds/media/usbopen continuously) now get a bounded retry instead of failing the whole write. - The library USB has been observed to spontaneously unmount itself on
real hardware with no corresponding log evidence anywhere – root
cause not confirmed, but a real power-supply brownout was caught in
the act (
vcgencmd get_throttledshowed under-voltage) during the same session, the strongest lead so far.usb_mount.pynow self-heals:_remount()treats “already not mounted” as success instead of erroring, and newusb_mount.ensure_mounted()(used byis_first_run()) attempts a recovery mount before a read-only check ever draws a wrong conclusion from an empty directory (previously misreported as “first run,” prompting to overwrite an existing PIN). - Two overlapping requests (e.g. a real double-tap on a button) used to
race each other’s raw
umount/mountcalls with no coordination, occasionally corrupting the remount state.writable_usb()is now held under a process-wide lock, serializing every write. - A phone disconnecting mid-upload (
usb-tether-watchdog.servicestopping the admin server via SIGTERM the instant the tethered interface disappears) never corrupted real data (the atomic temp-file write design held), but did leave large orphaned.part/.swaptmpfiles behind forever, since SIGTERM skips Python’s normal exception-cleanup path. Newlibrary_ops.cleanup_stale_temp_files()sweeps these on every server startup. rename_song()/delete_song()didn’t verify a song file still existed before touching it (unlike every other function inlibrary_ops.py) – a stale reference to a song deleted by hand directly on the USB hit a raw, unhandledFileNotFoundErrorinstead of a clean error message.- Frontend: several button handlers had no error handling at all (a server error was an invisible unhandled promise rejection); the per-track upload handler never checked the response status; list reloads reset scroll to the top of the page after every save/assign/rename (root cause: the container was genuinely empty, sometimes for as long as several sequential network round-trips, forcing the browser to clamp scroll – fixed by building new content off-screen first and swapping it in in one step); and the “this worked” button flash fired only after the server responded instead of the instant the button was tapped, so a slow upload looked unresponsive for its whole duration.
- Transient
- Added an Export Set view to both
setlist-adminexpansions: a full-screen, large-print running order for the selected Set (each track’s exact stored filename + extension, in Bank/letter order), with a supported-formats disclaimer and a “Share” button that renders the list to a PNG image and hands it to the phone’s native share sheet (desktop/unsupported-browser fallback: plain download). Closes via an on-screen ✕, Escape, or the browser back gesture. - Fixed: in
setlist-admin-usb, picking a different Set from the dropdown only changed what the app was showing/editing – it never told the pedal which Set to actually play. Only creating a new Set called theset_active_setAPI; switching between Sets that already existed had no way to become “the” active one at all short of deleting and recreating one. Fixed by having “Reboot now to apply” set whichever Set is currently selected as active immediately before rebooting – the one moment this app already asks the user to confirm intent, so also the right moment to commit to it. (Not yet ported tosetlist-admin-wifi, which has no reboot button of its own – seeexpansions/setlist-admin-usb/NEXT_STEPS.md.) - Documented (en/es
TROUBLESHOOTING.md, plussystemd/README.mdsection 4) a consolidated answer to “is the Pi safe against a power cut, and what are the actual risk windows”: during normal band use,/,/boot/firmware, and/media/usbare all RAM-backed orro, so there’s nothing for a power loss to corrupt; the real (brief) windows are a library edit’s/media/usbrwremount, the overlay being disabled for development, and hand-editingcmdline.txt– the last two are maintenance-only, never during a show. Also calls out that an underpowered supply, not a clean unplug, is the more likely real-world trigger, per this project’s own directly-observed under-voltage event. - Added a shared song library (
_Songs/at the USB root) to bothsetlist-adminexpansions, so a song only needs to be uploaded once and can be reused across any number of Sets instead of re-uploading it into every new Set. Documented as a base-project convention inLIBRARY.md(en/es) – works by hand over SSH too, not just through either app – with an explicit warning not to delete songs from it, since doing so only removes them from future picking, never from a Bank they’re already assigned to (that’s always an independent copy).library_ops.py’s new functions (list_songs/upload_song/rename_song/delete_song/assign_song_to_slot/save_track_to_library) are identical between the two expansions.scripts/rollback.shgained a separate--purge-libraryflag (distinct from--purge, which only ever touched the small PIN/credentials file) since deleting actual song files is a bigger, more deliberate action. Also fixed two real, pre-existing bugs found while building this (present since the original WiFi design, unrelated to the song library itself):library_ops.LibraryOpsErrorwas never translated into a proper HTTP response (fell through to a generic 500 “Internal error” instead of the 400 with a helpful message it should have been), and URL path segments (Set names, now also song filenames) were never percent-decoded server-side despite the frontend percent-encoding them, so any name actually needing encoding (any space or accented character) silently failed. - Introduced
expansions/: a top-level home for optional, independently installable/removable addons to the base pedal system – movedsetlist-admin(USB-tether design) intoexpansions/setlist-admin-usb/as the first one, fully self-contained (its ownsrc/,systemd/,scripts/install.sh+rollback.sh,tests/, docs), so installing or rolling it back can never collaterally affect the base project or any other expansion. Seeexpansions/README.mdfor the model. The earlier WiFi-based attempt, previously only living on theexplore/setlist-adminbranch, was brought ontomaintoo as a second, equally independent expansion (expansions/setlist-admin-wifi/) – still paused on its dead USB WiFi dongle blocker, not installed by default, but now visible in the same checkout instead of requiring a branch switch to see. - Added
setlist-admin(USB-tether design): a second attempt at the companion web app for managing the library USB (Sets/Banks/tracks, full CRUD) from a phone’s browser, this time reachable by plugging the phone into the Pi with a USB cable (Android USB tethering or iPhone Personal Hotspot over cable) instead of the Pi needing its own WiFi radio – seeexpansions/setlist-admin-usb/SPECIFICATION.mdfor the design and why (the earlier WiFi-based attempt is paused on a dead USB WiFi dongle, not this feature’s fault). Reuses that design’s CRUD backend and frontend UX unchanged (library_ops.py,usb_mount.pyincluding itsntfs-3gfix,auth.py, the CRUD screens) so the two stay convergeable later. A phone is identified by its USB network driver name (rndis_host/cdc_ether/cdc_ncmfor Android,iphethfor iPhone), not by IP range, so a permanently-attached Ethernet cable never spuriously starts the admin server. No WiFi credential storage, no NetworkManager profile juggling – that whole layer is gone. Applying a setlist change still requires a reboot (this project already tried and abandoned a live-reload mechanism once, see below in this same file); the app adds a “Reboot now to apply” button so that doesn’t need SSH. Not yet validated on real hardware. - Designed and implemented
setlist-admin(a companion web app for managing the library USB and the Pi’s WiFi from a phone/computer browser), then reverted it frommain: real-hardware validation (SETLIST_ADMIN_SPECIFICATION.mdsection 8a) went cleanly through install and the watchdog’s dry-run/live stages, but then the USB WiFi dongle (Realtek rtl8192cu) turned out to be failing hardware – confirmed by testing it on a separate computer, where it didn’t enumerate as any USB device at all. Since the whole feature depends on a working WiFi adapter, further testing is blocked until there’s a known-good one. The full design, implementation, tests, and a real bug found along the way (ntfs-3gdoesn’t supportmount -o remount,rw– fixed to a real umount+mount cycle) are preserved on theexplore/setlist-adminbranch for a future attempt with an alternative design. - Added
REBUILD.md(en/es): execution-order runbook for reproducing this project on a new PC + new Raspberry Pi, written for an AI coding agent working cold, without conversation history. - Added
TROUBLESHOOTING.md(en/es): symptom-indexed reference of every real failure this project hit during development/testing. systemd/README.md(en/es): added a “can a human actually do this alone” review chapter (walked the guide as a non-programmer musician would), which found and fixed several real gaps –git/cloning the repo onto the Pi was never mentioned, no SSH-client guidance, no text-editor (nano) instructions for the two files that need manual edits, and the power supply requirement was only documented reactively (after the fact inTESTING.md) instead of as an upfront requirement (now also inREADME.md’s hardware list).- Fixed a real EN/ES content gap in
TESTING.md: the Spanish version was missing the section that closes out test 4.0 (the real-TV frame-rate follow-up), leaving a Spanish-only reader thinking it was still open when it had already been resolved. - Fixed: booting without the library USB dropped into systemd
emergency mode (no SSH, unrecoverable on a headless appliance) instead
of the local standby fallback. Cause: the overlay filesystem’s default
recurse=1wraps every mount (including/media/usb) in its own overlay with nonofail, so a missing USB failed a boot-critical mount. Fixed by settingrecurse=0(root only) incmdline.txt– seesystemd/README.mdsection 4. - Added
LIBRARY.md(en/es): how to name library USB folders/files, and the exact filename-spacing mistake (A - x.movvsA - x.mov) that fails completely silently – found live while testing a Bank 5 video that didn’t play. - Project renamed from “Sequence Pedal” / “Pedal de Secuencias” to Chocolate Pi – a proper product name (playing on the M-VAVE Chocolate foot controller + Raspberry Pi actually used) instead of a literal description. Repo, badges, and docs updated to match; GitHub redirects the old repo URL automatically.
2026-09-05 – Initial public release
First open-source release. Actively used and validated against real hardware (Raspberry Pi 2, Behringer U-PHORIA UM2 USB audio interface, M-VAVE PD41 MIDI controller, library USB drive).
- Layered architecture (Adapter → Mapper → Core) so controller-specific code never leaks into playback logic.
Adaptervalidated against an M-VAVE PD41 in Program Change A mode (seeMAVAVE_ANALYSIS.mdfor the empirical mapping and its correction: 8 groups, not 32).Core: library resolution from a USB drive,mpv-driven video with a standby loop, a dedicated audio-only lane for standalone tracks, and audio always prioritized over video.- Local fallback standby video for when the library USB isn’t present at boot.
systemdauto-boot service; USB presence checked once at boot only (no live hot-swap – a reboot is required to pick up library changes).- Read-only root filesystem (Raspberry Pi OS overlay) so the Pi can be power-cycled at any moment without filesystem corruption risk.
- MIT license, bilingual documentation (English primary, Spanish under
docs/es/), GitHub Sponsors.