Skip to content

Coach / one-shot-transition assist layer (Coach advice, bounded track suggestions, physical + browser Assist bank) - #44

Open
djdefi wants to merge 52 commits into
CircuitMess:masterfrom
djdefi:djdefi-dj-coach-integration
Open

Coach / one-shot-transition assist layer (Coach advice, bounded track suggestions, physical + browser Assist bank)#44
djdefi wants to merge 52 commits into
CircuitMess:masterfrom
djdefi:djdefi-dj-coach-integration

Conversation

@djdefi

@djdefi djdefi commented Aug 22, 2026

Copy link
Copy Markdown

Deferrals

  • No Auto DJ (separate, later PR).
  • No cue support or library browsing/loading over the wireless API yet — arm-transition requires the target track already loaded via the device's own SD workflow (never loads an arbitrary path).
  • reason_flags bitmask is exposed decoded (as text) client-side but shipped over the wire as a raw integer to keep the state payload bounded; primary user-facing fields are already fully decoded server-side.

Review fixes (6 material issues, all addressed, history not amended)

An independent principal/code review of this PR found 6 material issues (5 HIGH, 1 MEDIUM). Fixed in 3 follow-up commits, 478667a/c03ecc4 and earlier history untouched:

  • 2b544b4Mix screen #1 stale guard (HIGH): DjSession::loop() applied commands, then tickAssist() read the previous published snapshot, so a same-iteration deck reload/swap could let Assist act once against a stale, unconfirmed track. publishSnapshot() now runs after apply/tickLoops/tickSync/pollRecording but before tickAssist().
  • c863d79Playback #2, Song list #3, Main menu #4, added Text Input screen for text entry feature #5 (all HIGH), kept together since they share the same tick/actuator flow:
    • Playback #2 boundary semantics: resolveBoundary()'s "next boundary" hint is true almost every tick, so WAIT_BOUNDARY was advancing immediately instead of waiting. Added DjAssistBoundaryHint::reached, set only by a new capture-once-then-compare resolveWaitBoundaryReached(); the engine now gates strictly on reached.
    • Song list #3 cancel/failure rollback: added a bounded tickRollback() state machine (MIX → SYNC → STOP_DECK → DONE, one actuator submit/poll per tick, only undoing steps this plan itself submitted, never touching pre-existing user state, never restarting the source deck once stopped).
    • Main menu #4 crossfade confirmation + manual override: the actuator now tracks the exact final-mix command id and only reports APPLIED once confirmed (not from elapsed-time math); added DjAssistGuardSnapshot::manualMixOverride (bounded scan for a non-system SET_MIX since arming), checked unconditionally in guardOk() so a manual override mid-ramp aborts instead of being silently overwritten.
    • added Text Input screen for text entry feature #5 hot-loop SD I/O: refreshCandidateTable()'s fill loop was reusing the 256-record scoring-scan budget for genuine (possibly SD-backed) metadata reads. Split out a separate DJ_ASSIST_FILL_RECORDS_PER_TICK = 1.
  • eca7930Intro screen #6 response buffer (MEDIUM): bumped MAX_RESPONSE 3072→4096 and added a constexpr/static_assert-checked worst-case formula for /api/v2/state so a future capacity change fails the build instead of silently returning response_too_large. (The formula's first draft used the wrong bound — DJ_RECENT_RESULT_COUNT, the backing array size — instead of handleState()'s actual 8-result emission cap; caught by the static_assert itself failing during the real wireless firmware build, since this file isn't part of any host test target.)

New test coverage: reached-semantics regressions (including a case proving hasPhrase/hasDownbeat alone must not advance), a manual-override-during-crossfade regression, and full dj_assist_bridge_selfcheck.cpp coverage for the 3 new pure bridge functions (nextRollbackPhase, evaluateCommandOutcome, detectManualMixOverride).

Review fixes, round 2 (4 material issues, all addressed, history not amended)

A focused re-review of eca7930 found 4 more material issues (3 HIGH, 1 MEDIUM). Fixed in b822c52:

  • Mix screen #1 rollback ownership (HIGH): rollback inferred ownership from which steps were submitted, so a target deck already playing/synced before arming would have rollback incorrectly stop/release pre-existing user state, and a manual mix override mid-crossfade could be silently overwritten by the MIX rollback phase. DjAssistTransitionPlan now captures armedToPlaying/armedToSynced from the guard snapshot at arm() time; nextRollbackPhase() takes manualMixOccurred/syncOwnedByPlan/startDeckOwnedByPlan instead of raw submitted flags, skipping MIX on any manual override and skipping SYNC/STOP_DECK whenever that step was already true at arm time (an idempotent no-op, not a plan-owned mutation).
  • Playback #2 durable command tracking (HIGH): recentResults is a bounded/evictable presentation ring, not a control channel — a burst could evict the final crossfade command's outcome or a manual-override marker before the controller polled it. Added DjSession::assistTrackCommand()/assistTrackedStatus() (a single controller-owned tracked-command slot, retained until consumed) and DjSession::assistNonSystemMixGeneration() (a monotonic counter incremented only on non-system SET_MIX apply, captured at arm time). Replaces the removed detectManualMixOverride() ring scan entirely.
  • Song list #3 background candidate fill + phrase-cache throttling (HIGH): candidate-table hydration (one metadata record per tick, full string/provenance decode) still ran synchronously on DjSession::loop(); resolveBoundary()'s phrase lookup also rescanned up to 128 records every tick whenever no future phrase existed (a permanently-terminal track). Candidate-table fill now runs entirely off the session loop via a genuine background Task, with the real I/O performed outside the candidate-table mutex and staleness re-checked before/after each read. Added a phrase-lookup cache invalidated only by identity change, deck change, backward seek, or a previously-found boundary being passed — never by forward playback alone while terminal.
  • Main menu #4 boundary tolerance (MEDIUM): resolveWaitBoundaryReached() fired as soon as currentFrame >= target with no tolerance, so a boundary could release arbitrarily late. Added evaluateBoundaryArrival(), reusing the established DJ_QUANTIZE_TOLERANCE_FRAMES policy: reached only at/within tolerance past the target, missed beyond it (never releases late — re-captures the next boundary and keeps waiting).

New/updated test coverage: arm() capturing the toDeck playing/sync baseline both true and false; nextRollbackPhase ownership-vs-submitted distinction and manual-override-skips-mix; evaluateBoundaryArrival not-yet/reached-at-tolerance/missed-beyond-tolerance; phrase-cache empty/found-not-yet-passed/terminal-not-rescanned/identity-or-deck-change/backward-seek. Also removed DJ_ASSIST_FILL_RECORDS_PER_TICK, a leftover constant from the old per-tick synchronous fill model that this round's background task made dead code.

Validation

  • ctest: all 8 host self-checks pass (DjAssistSelfCheck, DjAssistBridgeSelfCheck, plus the pre-existing suite), -Wall -Wextra -Werror + ASan/UBSan.
  • node tests/wireless_ui_self_test.mjs: 43/43 checks pass.
  • Full arduino-cli build, cm:esp32@1.8.3, Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA b822c52:
    • default (no wireless): 1,380,014 B flash (38%) / 47,160 B RAM (14%)
    • -DJAYD_ENABLE_WIRELESS: 1,974,606 B flash (54%) / 52,856 B RAM (16%)

Two genuine pre-existing bugs were found and fixed only because this was the first real compile with JAYD_ENABLE_WIRELESS enabled (round 1): a DjSnapshot::recording field that doesn't exist (real field is recordingInfo.state), and an odr-use link failure on SongList's static const members (fixed with out-of-class storage definitions).

Review fixes, round 3 (6 material issues, all addressed, history not amended)

A focused re-review of b822c52 found 6 more issues (4 HIGH, 2 MEDIUM), all fixed in 2b8f319; 478667a/c03ecc4/c863d79/eca7930/b822c52 untouched:

  • Mix screen #1 rollback ownership still unsafe (HIGH): b822c52's baseline-capture-at-arm still let a target already started/synced after arm but before the plan's own step submitted be mistaken for plan-owned on rollback. armTransition() now hard-requires the target deck stopped and sync-off at arm (fails outright otherwise); toDeckStartOwnedByPlan/toDeckSyncOwnedByPlan replace the old baseline fields and are set only the instant the plan's own START_DECK/ENABLE_SYNC step reaches DJ_COMMAND_APPLIED — never inferred from submission. guardOk() also aborts with MANUAL_OVERRIDE if the target starts playing/syncing before the plan's own step is even submitted.
  • Playback #2 tracked command supersession (HIGH): the durable tracked slot only updated on pop/finish, so a command superseded while still queued stayed ACCEPTED forever. DjCommandQueue::supersede() now returns a 3-state DjSupersedeOutcome; a new admitAssistCommand() free function routes every superseded id through the tracked slot at admission time, immediately.
  • Song list #3 manual mix admission race (HIGH): a queued manual SET_MIX could be superseded by the next Assist ramp command before it ever applied, silently erasing user intent. supersede() now returns BLOCKED (rejecting the system command outright) whenever a system-origin mix would replace a still-queued non-system mix; nonSystemMixGeneration is latched the instant a non-system mix is admitted (replace or fresh push), not when it applies.
  • Main menu #4 candidate generation race (MEDIUM): fillWorkerStep()'s commit-time check compared loadedGeneration_ against the same value it had just been set from earlier in the same call — a self-comparison that could never catch a refresh completing during the unlocked SD read. Commit points and candidateTableReady() now re-read the live generation fresh, immediately before the lock, via DjAssistBridge::candidateFillGenerationCurrent().
  • added Text Input screen for text entry feature #5 phrase cache misses metadata refresh (MEDIUM): the cache invalidated only on identity/deck/seek change, so a metadata refresh that left the same track loaded (re-resolved grid/phrase) could serve a stale terminal result. DjAssistPhraseCacheState now also keys on metadataGeneration/metadataState.
  • Intro screen #6 host integration test harness: added direct admitAssistCommand() coverage in dj_session_self_test.cpp against the real DjCommandQueue/DjCommandResults/DjAssistTrackedCommand types (supersede→durable routing, both manual/system mix admission orderings, BLOCKED rejection, queue-full isolation from tracked slot, >ring-capacity result bursts not affecting the durable slot), plus new dj_assist_selfcheck.cpp/dj_assist_bridge_selfcheck.cpp coverage for the above.

Preserves the round-2 same-tick guard, boundary tolerance, background fill worker, and 4 KiB response bound unchanged.

Validation

  • ctest: 8/8 host self-checks pass under -Wall -Wextra -Werror + ASan/UBSan.

  • node tests/wireless_ui_self_test.mjs: all checks pass (unchanged this round).

  • Full arduino-cli build, cm:esp32@1.8.3, Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA 2b8f319:

    • default (no wireless): 1,380,298 B flash (38%) / 47,160 B RAM (14%)
    • -DJAYD_ENABLE_WIRELESS: 1,480,370 B flash (40%) / 52,768 B RAM (16%)

    Note: every wireless build size previously reported on this PR (rounds 1-2) was inflated by a bug in the local arduino-cli invocation, not the firmware — passing --build-property build.extra_flags=-DJAYD_ENABLE_WIRELESS=1 silently replaced the board's default build.extra_flags=-O2 instead of extending it, so those builds compiled at -O0. The default (non-wireless) config never needed that override and was correctly optimized all along, which is why only the wireless numbers were affected. Fixed this round by passing -O2 -DJAYD_ENABLE_WIRELESS=1 together; verified via -v that -O2 reaches every compile line. No code changes resulted from this - it only corrects the reported size.

Auto DJ remains out of scope for this PR.

Review fixes, round 4 (5 material issues, all addressed, history not amended)

A focused re-review of 2b8f319 found 5 more issues (3 HIGH, 2 MEDIUM), all traced to fragmented origin/intent handling rather than another tail-only guard. Fixed together in 8d387ce; 478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319 untouched:

  • Mix screen #1 LOCK_TEMPO self-abort/leak (HIGH): LOCK_TEMPO and ENABLE_SYNC both call setSync(true), but only ENABLE_SYNC updated toDeckSyncOwnedByPlan, so the default plan (tempoLock=true) saw sync go active with no recognized owner — a false self-abort, and rollback would then skip releasing a sync it never marked as plan-owned. Ownership is now set from either step reaching DJ_COMMAND_APPLIED (both resolve to the same idempotent mutation, so attribution is correct regardless of which one fires first).
  • Playback #2 user intent invisible once the plan's own step submits (HIGH): guardOk() compared raw state (deckPlaying/syncActive/mix), which a system-origin Assist step mutates to the same value it's checking — masking any user command that raced in around the same time. Replaced with DjAssistIntentGenerations (mix/playing[]/sync[] monotonic counters), bumped in a new djBumpIntentGeneration() called from admitAssistCommand() at admission, only for non-DJ_ORIGIN_SYSTEM commands. armTransition() captures a baseline; guardOk() compares the live snapshot against it unconditionally every tick, independent of whether the plan's own step has submitted or applied.
  • Song list #3 mix/origin priority only checked the queue tail (HIGH): hasNonSystemPending()/removeSystemTargeting() previously only reached the slot supersede() could replace, so a manual command "buried" behind an unrelated queued command wasn't seen — a system command could jump ahead of it, or cancel could leave a stale plan-owned command queued behind something else. Both now scan the entire bounded queue (≤16 entries), and every purged/superseded id is routed through the durable tracked-outcome path immediately, not only at dequeue.
  • Main menu #4 candidate generation check-then-lock gap (MEDIUM): fillWorkerStep() read the live library generation, then separately locked and read the metadata entry, leaving a window for a concurrent refresh to swap the reader in between. DjSession::assistTrackEntry() now captures the metadata revision under the same metadataMutex acquisition as the trackByIndex() read itself; DjAssistBridge::candidateGenerationCurrent() collapses to a plain 2-arg equality check against that atomically-captured revision — no separate probe.
  • added Text Input screen for text entry feature #5 real controller/session paths untested (MEDIUM): admitAssistCommand() is the same free function DjSession::submit() calls under its real commandMutex, so host-testing it already exercises real production admission logic (pre-existing architecture, extended this round with generation-aware assertions rather than a parallel reimplementation). Added direct coverage for: LOCK_TEMPO-only ownership (no ENABLE_SYNC in the plan), post-arm divergence detected both before and after the plan's own step has submitted, queue-wide origin scan in both directions (system command buried behind an unrelated one still purged when a manual command is admitted; manual command buried behind an unrelated one still blocks a later system command), and manual mix admitted mid-crossfade (after CROSSFADE has submitted but before it's applied) still aborts the transition.

Preserves the round-3 stopped/sync-off arm precondition, durable slot supersede routing, boundary tolerance, background candidate fill, phrase cache key, and 4 KiB response bound unchanged.

Validation

  • Host self-checks (DjSessionSelfCheck, DjAssistSelfCheck, DjAssistBridgeSelfCheck, WirelessApiSelfCheck, default + -DJAYD_ENABLE_WIRELESS) all pass under g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.
  • node tests/wireless_ui_self_test.mjs: all checks pass (unchanged this round).
  • Full arduino-cli build, cm:esp32@1.8.3, Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA 8d387ce:
    • default (no wireless): 1,381,390 B flash (38%) / 47,160 B RAM (14%)
    • -DJAYD_ENABLE_WIRELESS: 1,481,442 B flash (41%) / 52,768 B RAM (16%)

Auto DJ remains out of scope for this PR.

Review fixes, round 5 (4 material issues, all addressed, history not amended)

A focused re-review of 8d387ce found 4 more issues (1 HIGH, 3 MEDIUM). Fixed in 4292807; 478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319/8d387ce untouched:

  • Mix screen #1 rollback overrides later user intent (HIGH): after the plan's own START_DECK/LOCK_TEMPO/ENABLE_SYNC step applied, a later user SET_PLAYING(true)/SET_SYNC(true) on the target deck correctly failed the transition (intent-generation divergence), but ownership stayed true, so rollback would then stop/release the user's own newer intent. guardOk() now relinquishes toDeckStartOwnedByPlan/toDeckSyncOwnedByPlan for the exact property that diverged, immediately before failing — rollback can then never touch it.
  • Playback #2 source-deck sync unguarded (MEDIUM): the plan captures both decks' sync generations at arm time, but guardOk() only ever compared the target (toDeck). A non-system sync command on the source deck now aborts the transition (MANUAL_OVERRIDE) every tick too; there's no rollback ownership for fromDeck sync (the plan never touches it), so this is a plain abort with no additional bookkeeping.
  • Song list #3 candidate-generation race remains (MEDIUM): libraryGeneration is now std::atomic<uint32_t>, bumped before refreshLibraryMetadata()/invalidateLibraryMetadata() mutate the reader; assistLibraryGeneration() is now a lock-free read, safe to call from inside DjAssistController's own candidateMutex_ critical section with no lock-ordering risk against metadataMutex. assistTrackEntry() still captures its revision under the same metadataMutex acquisition as the read. fillWorkerStep()'s commit path now re-checks the entry's captured revision and a fresh in-lock live-generation reload and fillCursor_ == cursor immediately before writing — any mismatch discards the record instead of committing it. candidateTableReady() reloads the live generation inside its own lock immediately before the readiness decision.
  • Main menu #4 real integration coverage still absent (MEDIUM): added DjAssistSessionPort (src/DjAssist/DjAssistSessionPort.h), the Arduino-free interface DjAssistController now depends on instead of a concrete DjSession (DjSession implements it, unchanged behavior). This lets the real, unmodified DjAssistController.cpp/DjAssistEngine.cpp be driven by a new host test, DjAssistIntegrationSelfCheck (tests/dj_assist_integration_selfcheck.cpp), against a fake port whose command admission reuses the exact real, shared admitAssistCommand()/DjCommandQueue/DjCommandResults/DjAssistTrackedCommand/DjAssistIntentGenerations primitives DjSession itself uses — not a parallel reimplementation. Seven scenarios: full default transition (arm→boundary→tempo-lock→sync→crossfade→stop→release→complete), cancel/rollback restoring only plan-owned state, user play-divergence relinquishing rollback ownership (Mix screen #1), source-deck sync divergence aborting the transition (Playback #2), manual mix mid-crossfade aborting with rollback skipping the mix phase, queue-wide supersede/removal through the real admission coordinator, and the candidate-fill generation race (Song list #3) being caught and discarded. Minimal host stubs (tests/host_stubs/{Arduino.h,Sync/Mutex.h,Util/Task.h,host_arduino_shim.cpp}) provide the handful of Arduino/CircuitOS primitives DjAssistController.cpp needs (micros/delay/ps_malloc, Mutex, Task) without any real ESP32 headers.

Preserves the round-4 LOCK_TEMPO/ENABLE_SYNC ownership unification, admission-time intent generations, queue-wide origin scan, boundary tolerance, background candidate fill, phrase cache key, and 4 KiB response bound unchanged.

Validation

  • ctest: DjAssistSelfCheck, DjAssistBridgeSelfCheck, DjSessionSelfCheck, DjAssistIntegrationSelfCheck (new), LibraryIndexSelfCheck, DjBeatEngineSelfCheck, MetadataIntegrationSelfCheck, WirelessApiSelfCheck — all pass under g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.
  • Full arduino-cli build, cm:esp32@1.8.3, Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA 4292807:
    • default (no wireless): 1,384,114 B flash (38%) / 47,160 B RAM (14%)
    • -DJAYD_ENABLE_WIRELESS: 1,485,306 B flash (41%) / 52,768 B RAM (16%)

Auto DJ remains out of scope for this PR.

Review fixes, round 6 (4 material issues, all addressed, history not amended)

A focused re-review of 4292807 found 4 more issues (3 HIGH, 1 MEDIUM). Fixed in ccca174; 478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319/8d387ce/4292807 untouched:

  • Mix screen #1 rollback ownership guard cleared only the first divergent property (HIGH): guardOk()'s divergence checks were an early-return chain — if both play and sync diverged in the same user action, only the first check's ownership flag was relinquished before returning, leaving rollback free to stop/release the user's own newer state on the other property. Cancel/failure could also enter rollback with no further guard pass at all, since guardOk() only runs while mode_ is ARMED/RUNNING. Replaced with reconcileOwnershipOnDivergence(), a single pass that independently ANDs each ownership flag against its own generation match with no early return, called unconditionally at the top of guardOk() and at the start of every DjAssistController::tickRollback() tick using freshly re-fetched live intent generations — so a user re-asserting play/sync after cancel is still caught before rollback acts on it.
  • Playback #2 candidate freshness keyed off the wrong generation (HIGH): candidate-table validity was tied to libraryGeneration, an externally-supplied semantic identity that can stay unchanged across a same-generation sidecar-file replacement or a metadata loss/reopen cycle landing back on the same value. Added a dedicated std::atomic<uint32_t> metadataRevision on DjSession, bumped before every reader mutation/invalidation/shutdown-close regardless of the external generation value; renamed the assist port accessor assistLibraryGeneration() -> assistMetadataRevision() throughout (it was DjAssistController's only consumer) so the candidate worker/table/cache key off this new revision instead.
  • Song list #3 readiness/consumption TOCTOU (HIGH): tick() called candidateTableReady() (its own lock/unlock), decided the revision-based reset outside any lock, then tickSuggestions() separately re-locked to scan — a fill-worker refresh landing in the gap between these independent acquisitions could expose a stale suggestion list. Collapsed the readiness check, the reset, and the scanTick() scan into one candidateMutex_ critical section inside tickSuggestions(); on a revision mismatch mid-tick, suggestion state is cleared and the scan is skipped for that tick rather than exposing stale entries. candidateTableReady() is preserved for tests/diagnostics but no longer gates the tick.
  • Main menu #4 shutdown race (HIGH): DjSession::shutdown() closed metadataReader while the background candidate-fill task could still be running, racing a use-after-close. shutdown() now calls the already-idempotent, blocking assistController.end() before touching metadataReader, and marks metadataRevision++/metadataInitialized=false/metadataReaderStatus=Missing under metadataMutex before metadataReader.close().

Preserves the round-5 LOCK_TEMPO/ENABLE_SYNC ownership unification, admission-time intent generations (including the round-5 relinquish-on-divergence fix, now generalized by #1), queue-wide origin scan, boundary tolerance, background candidate fill, phrase cache key, and 4 KiB response bound unchanged.

Added DjAssistIntegrationSelfCheck coverage: simultaneous play+sync divergence relinquishing both ownership flags together (#1), a user re-asserting play/sync after cancelTransition() still being caught by tickRollback()'s fresh reconciliation (#1), suggestions never going stale across a metadata-revision refresh landing between readiness and scan (#2/#3), and DjAssistController::end() making further worker calls safe no-ops (#4's achievable half — the host Task.h stub deliberately never spawns a real thread, so a literal join-timing race isn't reproducible without introducing nondeterminism into the other deterministic scenarios; this is a documented scope limitation, not a claim of full race coverage). DjSession.cpp is not part of any host test target (needs real AudioLib/FS.h/SD.h/LoopManager), so #2's revision bump points and #4's shutdown reordering are validated by code review plus the full firmware build rather than a host unit test.

Validation

  • ctest: DjAssistSelfCheck, DjAssistBridgeSelfCheck, DjSessionSelfCheck, DjAssistIntegrationSelfCheck, LibraryIndexSelfCheck, DjBeatEngineSelfCheck, MetadataIntegrationSelfCheck, DjRecordingSelfCheck, WirelessApiSelfCheck — all 9 pass under g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.
  • Full arduino-cli build, cm:esp32@1.8.3 (CircuitOS 1.7.1), Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA ccca174:
    • default (no wireless): 1,384,250 B flash (38%) / 47,160 B RAM (14%)
    • -DJAYD_ENABLE_WIRELESS: 1,485,582 B flash (41%) / 52,768 B RAM (16%)

Auto DJ remains out of scope for this PR.

Review fixes, round 7 (1 HIGH production issue + 2 test-quality gaps, all addressed, history not amended)

A focused re-review of ccca174 found a real production bug plus two test gaps that let it slip through. Fixed in ebdd3a9; 478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319/8d387ce/4292807/ccca174 untouched:

  • Mix screen #1 shutdown deadlock on fill-task launch failure (HIGH): bundled CircuitOS's Task::start() sets stopped=false unconditionally before calling xTaskCreate(); on launch failure it only logs and never restores stopped, and only the task's own trampoline (which never runs if creation failed) ever sets stopped=true. Task::stop(true)'s while(!stopped) delay(1); therefore deadlocks forever, and DjAssistController::end() calls exactly that. Patching bundled CircuitOS was out of scope. Replaced Util/Task usage with a new, locally-owned DjAssistFillWorker: begin() honestly reports launch success/failure via observable launched()/entered()/exited() state, so a failed launch leaves the controller disabled and end() returns immediately instead of blocking; the firmware path uses raw FreeRTOS xTaskCreate/self-vTaskDelete directly (same priority/affinity as before, never touching CircuitOS's own Task class); the host path (new JAYD_ASSIST_HOST_TASK_SEAM macro, needed because -DARDUINO is applied at directory scope to all targets including host tests) uses std::thread, with a useManualSteppingForTest flag (default true) preserving byte-for-byte behavior of every pre-existing deterministic test that drives fillWorkerStep() directly via friend access.
  • Playback #2 candidate TOCTOU test didn't exercise the real gap (MEDIUM): the round-6 test bumped metadataRevision before calling tick(), a case even the pre-fix code already handled correctly, so it proved nothing about the actual readiness/consumption race. Added a genuine second, defense-in-depth live-revision recheck inside tickSuggestions(), under the same candidateMutex_ critical section, immediately before the real DjAssistScoring::scanTick() consume call, plus a test-only hook (testHookBeforeScanConsume_) invoked at exactly that point so a test can inject a revision bump precisely between "readiness decided" and "table consumed" — a window a pre-tick bump cannot reach.
  • Song list #3 shutdown test's Task stub never ran real concurrency (MEDIUM): the host Util/Task.h stub was a pure no-op, so the round-6 shutdown test only proved "safe no-op after end()" rather than any genuine ordering guarantee. DjAssistFillWorker's real std::thread path on host now gives tests actual concurrency to exercise.

New DjAssistIntegrationSelfCheck tests: launch-failure leaves the controller disabled and end() returns immediately (no hang); a real background thread enters, runs, and exits cleanly on end(); end() blocks until an in-flight reader call is released and only completes after the worker has actually exited (proves no close-before-exit race); suggestions are discarded when the live revision changes in the exact window between readiness and consume (proves #2's recheck is load-bearing).

Two incidental host-stub fixes were required for the new real-thread tests to be valid and not introduce false races: tests/host_stubs/Sync/Mutex.h was a no-op stub with no real exclusion — now wraps std::mutex so candidateMutex_ provides genuine mutual exclusion once a background thread exists; tests/host_stubs/host_arduino_shim.cpp's delay() was a no-op, which would have made the new background thread busy-spin at 100% CPU on host — now does a real sleep (confirmed by grep that delay() is reachable only from DjAssistFillWorker.cpp's host-thread path, so this has zero effect on any pre-existing single-threaded test).

Added a new DjAssistIntegrationSelfCheckTSan CTest target (same sources, -fsanitize=thread, Clang/GNU only, since TSan can't combine with ASan/UBSan in one binary) to validate the new real-concurrency paths for data races — passes with no races reported.

Preserves the round-6 ownership reconciliation, dedicated metadataRevision, readiness/consumption critical section, and shutdown-close ordering unchanged; only the fill-task lifecycle mechanism itself moved off CircuitOS Task.

Validation

  • ctest: all 10 targets pass — LibraryIndexSelfCheck, DjSessionSelfCheck, DjBeatEngineSelfCheck, MetadataIntegrationSelfCheck, DjAssistSelfCheck, DjAssistBridgeSelfCheck, DjAssistIntegrationSelfCheck (ASan/UBSan), DjAssistIntegrationSelfCheckTSan (new, ThreadSanitizer), DjRecordingSelfCheck, WirelessApiSelfCheck — under g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined (or -fsanitize=thread for the TSan target).
  • Full arduino-cli build, cm:esp32@1.8.3 (CircuitOS 1.7.1), Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA ebdd3a9:
    • default (no wireless): 1,384,774 B flash (38%) / 47,168 B RAM (14%)
    • -DJAYD_ENABLE_WIRELESS: 1,486,114 B flash (41%) / 52,776 B RAM (16%)
    • (both ~+530 B flash / +8 B RAM vs. round-6, consistent with the new fill-worker code)

Auto DJ remains out of scope for this PR.

Dependency revalidation: Library pin updated to 35b4f80

Coach ebdd3a9 (round-7 head, unchanged, no source edits needed) revalidated against the canonical JayD-Library PR #22 head moving 78a9f3d35b4f80a47f5e5eef029e0c57db76699a076df4a (confirmed via git rev-parse HEAD on the pinned checkout).

Library diff in this range (OutputAAC.cpp/OutputWAV.cpp/SourceAAC.cpp/SourceMP3.cpp/SourceWAV.cpp/SDScheduler.cpp/.h + tests) reworks SDScheduler::addJob from a transient unmerged bool addJob(SDJob*) (only ever present at 78a9f3d) back to the master-compatible void addJob(SDJob*), adding a new bool tryAddJob(SDJob*). grep confirms no Coach/DjAssist source references SDScheduler/addJob at all — zero surface overlap, no source change required. DjRecordingSelfCheck only includes WavHeader.h/AudioSetup.hpp (unchanged in this diff), so it validates unaffected too.

Validation

  • ctest: all 10 targets pass, clean rebuild, no source changes — LibraryIndexSelfCheck, DjSessionSelfCheck, DjBeatEngineSelfCheck, MetadataIntegrationSelfCheck, DjAssistSelfCheck, DjAssistBridgeSelfCheck, DjAssistIntegrationSelfCheck (ASan/UBSan), DjAssistIntegrationSelfCheckTSan, DjRecordingSelfCheck, WirelessApiSelfCheck.
  • Full arduino-cli build, cm:esp32@1.8.3 (CircuitOS 1.7.1), Library 35b4f80a47f5e5eef029e0c57db76699a076df4a, both configurations, still at SHA ebdd3a9:
    • default (no wireless): 1,384,926 B flash (38%) / 47,168 B RAM (14%)
    • -DJAYD_ENABLE_WIRELESS: 1,486,290 B flash (41%) / 52,776 B RAM (16%)
    • (both ~+150-180 B flash / +0 B RAM vs. round-7's 78a9f3d baseline of 1,384,774 B / 1,486,114 B, matching the small tryAddJob addition)

These figures are cross-confirmed by an independent isolated rebuild (clean environment, singular --library checkout) that reported identical default (1,384,926 B / 47,168 B) and closely matching wireless (1,486,290 B / 52,776 B) numbers. An earlier build attempt on this shared, non-isolated machine transiently reported inflated numbers (~+20 KB on both configs) due to environment/toolchain contention from a concurrent unrelated arduino-cli compile running on the same host at the same time; those transient figures are superseded by the cross-confirmed numbers above and were never a Coach or Library regression.

No Coach source changes were required for this revalidation. Auto DJ remains out of scope for this PR.

djdefi and others added 30 commits August 20, 2026 11:58
Previously MixScreen::start() required both f1 and f2 to be chosen
before any audio would play, forcing users through the song list
twice with nothing happening in between. Also, replacing a track
via encBtnHold called stop(), destroying the whole MixSystem (and
the other deck's playback) just to swap one file.

Now:
- start() constructs MixSystem and begins playback as soon as one
  file is available; the second deck can be loaded later while the
  first keeps playing (mirrors Denon Prime GO-style independent
  per-deck load).
- returned()/loadChannel() route into the new
  MixSystem::openChannel() so loading/replacing a deck live no
  longer stops the other deck.
- encBtnHold no longer calls stop(); it just closes the selected
  deck's file and reopens SongList, tracking which deck to fill via
  loadingChannel.

Depends on the MixSystem::openChannel()/_openChannel() addition in
JayD-Library.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2cfc57-c6c7-44ce-95c9-72368d409ca8
Replace per-track UI objects with a bounded PSRAM-backed path table and draw only the five visible rows into the existing screen sprite. Preserve recursive AAC discovery, navigation, loading, and SD recovery while making allocation limits explicit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Move mixer ownership and semantic control state out of MixScreen into a long-lived DjSession with a bounded command queue and cached snapshot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Store bounded AAC records in two validated SD generations, preserving the last usable cache across interrupted rebuilds. Load matching caches as verifying, detect selected-file changes, and keep manual L3+R3 rebuilds explicit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Move mixer ownership and semantic control state out of MixScreen into a long-lived DjSession with a bounded command queue and cached snapshot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Parse JAYDMETA/1 sidecars from SD with streaming CRC and record validation, expose concrete lookup records, and add converter-backed malformed fixture checks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep empty-deck effect commands authoritative and defer Speed activation until a track is loaded.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep DjSession effect state authoritative for empty decks and defer Speed attachment until a channel is successfully loaded.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep empty-deck effect commands authoritative and defer Speed activation until a track is loaded.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep a successfully reloaded generation in verifying state after rebuild failure, and run the host regression checks through the existing CMake/CTest build.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep DjSession effect state authoritative for empty decks and defer Speed attachment until a channel is successfully loaded.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace hidden MixScreen chords with persistent Mix, Cues, and Browse banks backed by DjSession commands and snapshots.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Compare fractional cue, loop, grid, and phrase times exactly on ESP32 without fixed-width multiplication overflow, and reject the same invalid timelines in the converter.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Gate WiFi provisioning and authenticated API v2 behind a build flag, with physical-presence pairing, writer leases, bounded HTTP handling, command deduplication, and cached DjSession snapshots.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Bind bounded JAYDMETA sidecars to DjSession deck loads, publish compact metadata snapshots, invalidate associations with library generations, and gate index rebuild work on playback state.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Expose physical pairing through the matrix palette, return recent command results in insertion order, and apply the origin guard to WiFi setup mutations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep the last verified card/index identity when a gated reload cannot allocate its index, and only refresh or invalidate deck metadata after a verified identity or proven card change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Introduces a hardware/Arduino-free, host-testable coach/suggestions/
one-shot-transition layer, deliberately decoupled from the in-flight
Sync/quantize/loop work (djdefi-psychic-fiesta) and browser/API v2:

- DjAssistTypes.h: modes (Off/Coach/Transition Armed-Running-Complete-
  Failed), reason/warning/exclude-reason flags, and bounded POD records
  (library entry, suggestion, coach advice, transition plan/step, guard
  snapshot) safe to copy into a snapshot/API/browser payload as-is.
- DjAssistScoring: pure, bounded, deterministic scoring/ranking -
  Camelot-equivalent key relationships, checked fixed-point tempo-rate
  math (0.5x-1.5x with a preferred +/-8% narrow band), a stable top-N
  merge (descending score, ascending libraryIndex tie-break), and a
  scan-budget tick that processes a fixed number of entries per call
  with no heap growth and identical results regardless of chunking.
  Missing fields reduce confidence rather than reject a track; only
  loaded/recently-played/corrupt-or-unsupported entries are excluded.
- DjAssistEngine: nonbinding Coach advice (phrase/downbeat window,
  target rate, crossfader direction, warnings) plus a one-shot
  transition state machine that only arms on explicit user confirmation,
  enqueues semantic actions and waits for applied results, and fails
  safely on metadata loss, command rejection, media removal, recording/
  loop conflicts, or a manual crossfader/play/rate override detected via
  a caller-supplied guard snapshot. DjAssistActuator is the seam a
  future adapter binds to real Sync/quantize primitives once that
  branch lands - this state machine never talks to Deck/Sync directly.

tests/dj_assist_selfcheck.cpp covers deterministic ranking/ties, scan-
budget chunking equivalence, missing/stale-vs-corrupt metadata handling,
key/tempo boundary values, recent/loaded exclusion, reason codes, phrase-
vs-downbeat coach windows, the full transition happy path plus cancel/
command-rejection/media-loss/metadata-loss/manual-override/recording-
conflict failure paths, applied-result waiting, crossfade curve
endpoints, and overflow/wrap guards in the rate math. Built via CMake
with -Wall -Wextra -Werror and ASan/UBSan on Clang/GCC hosts; all four
host self-checks (LibraryIndex, DjSession, MetadataIntegration,
DjAssist) pass under ctest.

Deferred to a follow-up commit once djdefi-psychic-fiesta (Sync/
quantize/loops) and the browser/API v2 branch publish final SHAs:
wiring DjAssistEngine into DjSession, a concrete DjAssistActuator over
real Sync/quantize primitives, the physical Assist control bank, and
browser/API surfacing. No Auto DJ in this PR.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
DjSession gains a firmware-owned recording lifecycle mirroring
JayD-Library PR CircuitMess#20's async accepted/applied RecordingStatus (starting,
active, stopping, complete, failed), polled non-blockingly each loop
tick and mapped into a stable DjRecordingSnapshot/DjRecordingState
shape for the physical UI and a future API v2. Starting a recording
never claims active before the library applies it; a duplicate start
while one is already starting/active/stopping is rejected as busy,
while a stop is always forwarded (even mid-start) since the library's
own state machine is designed to accept that transition safely.

MixScreen's existing two-deck recording chord now issues only semantic
DjSession commands (setRecording), guards against re-triggering while
a start/stop is still in flight, and shows non-modal status text for
each lifecycle stage instead of the legacy blocking AAC-conversion +
TextInputScreen save flow, which is removed entirely.

New DjRecordingStorage adds bounded, collision-safe recording storage
under /Recordings (fixed-width JAYD_NNN.wav names, capacity 250,
allocation is a bounded scan -- never unbounded or heap-heavy) and
conservative boot-time recovery of a WAV left behind by an interrupted
session: only a file matching Jay-D's own RIFF/WAVE PCM header shape
and a sample-aligned size is repaired (RIFF/data sizes recomputed from
actual file length via checked arithmetic); anything else is left
untouched. Repaired/failed counts surface in the snapshot for
diagnostics. The naming search and header-repair arithmetic are
extracted into a pure, dependency-free DjRecordingLogic.h shared by the
on-device SD-backed storage and a new host self-check
(tests/dj_recording_self_test.cpp) covering naming collision/
exhaustion, the start-busy/stop-always-forwarded gate, WAV repair
arithmetic/truncation/misalignment/unknown-format rejection/overflow,
and failure/invalid completion never reporting as success.

Validated against JayD-Library PR CircuitMess#20 final commit a4e0fdb (recording
state machine); host self-checks and a size-delta build against that
pinned commit are described in the PR body.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Independent core review of 7f768f8 found two issues, fixed here:

1. (HIGH) mergeSuggestion returned immediately for an excluded candidate
   before searching for a matching libraryIndex already in the ranking.
   A track that was previously ranked (e.g. free on an earlier scan
   pass) but has since become loaded/recent/unsupported stayed stale in
   the suggestion list forever, since it would never be revisited as
   non-excluded. Now the existing-entry search/removal always runs
   first, and the exclusion check only gates the insert step.

2. (MEDIUM) The transition plan captured plan.targetIdentity at arm()
   time, but the guard snapshot had no per-deck loaded identity and
   guardOk() never compared it. If the target deck's loaded track
   changed after arming (re-load or deck swap) while still reporting
   loaded=true - most notably during a WAIT_BOUNDARY wait, before any
   command has touched that deck - the transition would proceed onto
   the wrong, unconfirmed track. Added deckIdentity[] to
   DjAssistGuardSnapshot, a new DJ_ASSIST_FAIL_TARGET_CHANGED failure
   code, an identity check in armTransition() itself (refuse to arm if
   the confirmed target doesn't match what's actually loaded), and an
   unconditional per-tick identity check in guardOk() that fails safely
   before any further action is taken.

Tests: testMergeSuggestionExcludedRemovesStaleEntry (single-entry and
full-list stale-exclusion cases, including that a freed slot accepts a
fresh candidate afterward) and testTransitionTargetSwapDetected (swap
while waiting at the boundary, swap after the first step has already
been submitted, cancel semantics still work cleanly on top of a swap,
and arm() itself refuses a target that doesn't match what's loaded).

Verified: DjAssistSelfCheck builds clean under -Wall -Wextra -Werror
and runs clean under ASan/UBSan; all four host self-checks
(LibraryIndex, DjSession, MetadataIntegration, DjAssist) pass under
ctest. Still isolated - no DjSession/Sync/browser wiring; publication
still held for final Sync/browser SHAs per prior instructions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Single static HTML/CSS/vanilla-JS artifact, gzip-embedded in flash and
served at "/", giving DJ-booth control of the paired v2 semantic API
from any browser: no framework, no CDN, no WebSocket/SSE, no
client-side authority.

Flow implemented against the reviewed v2 API:
- First-run WiFi setup detection, then physical-pairing instructions
  and six-digit code entry, consuming the real /setup, /setup/wifi and
  /api/v2/pair endpoints (no duplicate pairing authority). Token is
  kept out of DOM-visible diagnostics/logs, persisted only in
  localStorage, with an explicit "Forget device" action. Handles
  pair-closed/expired/rate-limit/origin failures with recovery copy.
- Single writer lease acquire/renew/release with a visible
  read-only-vs-controlling state, conflict/expiry handling, and a
  reconnect path that always drops back to read-only on a new
  boot/session id rather than assuming control survived (see
  leaseReducer + the RECONNECT self-test).
- Two decks (identity/status, play/pause, elapsed/duration where
  timing is coarse, seek, gain, 3 effect slots) around a central
  crossfader, plus recording start/stop, all rendered from
  authoritative server state; optimistic UI only marks controls
  pending, never fakes a value.
- Adaptive single-in-flight polling (active/idle/hidden intervals,
  stale-response rejection via a request token, pause when
  offline/hidden, bounded exponential backoff on failure) and
  per-control command coalescing so continuous inputs (gain, seek,
  crossfader) don't storm the ~1 req/sec device.
- Bounded, unique command ids with accepted/applied/failed/
  superseded/rejected lifecycle tracking and a hard no-replay
  guarantee (a used id is never resent, including across reconnects).
- Cue pads and the track library are rendered as truthful
  capability-disabled panels rather than fake local state, since the
  current v2 API exposes neither cue state/commands nor a bounded
  library browse/load endpoint. The UI documents the exact follow-up
  needed: GET /api/v2/library (paged, stable opaque ids, no
  filesystem paths) plus a load_by_id action on POST /api/v2/command.
- Accessible help/shortcut panel, semantic landmarks/labels, a polite
  live region for state announcements, native range/select controls
  (no custom sliders), 44px-minimum touch targets, visible focus,
  prefers-reduced-motion support, and no color-only state.

tools/generate_wireless_ui_asset.py inlines ui/{index.html,styles.css,
app.js} into one document and regenerates the gzip PROGMEM byte array
in src/Wireless/WirelessUiAsset.h (42,773 raw / 11,781 gzip bytes).
WirelessBringup.cpp gains a single handleIndex() route (no-store,
nosniff, restrictive CSP) gated the same as the rest of the wireless
build behind JAYD_ENABLE_WIRELESS.

tests/wireless_ui_self_test.mjs adds 25 framework-free assertions
covering command-id bounding/uniqueness, no-replay, lease reducer
transitions, backoff/poll-delay bounds, throttler coalescing, request
body shaping, static DOM-safety (no innerHTML/document.write/eval),
and accessibility invariants. Writing it caught a real bug in
createThrottler (a legitimate 0 timestamp was treated as unset by
`|| -Infinity`), now fixed via an explicit `.has()` check.

Validation:
- node tests/wireless_ui_self_test.mjs: 25/25 pass.
- Existing wireless_api_self_test.cpp, dj_session_self_test.cpp,
  wireless_source_self_test.py: unchanged, still pass against a137b32.
- Stock and wireless firmware both build clean against reviewed
  JayD-Library commit 4ad5108 (PR CircuitMess#19), isolated from the officially
  bundled core's differing 1.2.1 revision.
- Isolated size delta (feature vs. baseline tree, identical
  core/library/flags): wireless +13,224 bytes flash (~12.9 KiB, ~0.37%
  of the 3,612,672-byte app partition), +8 bytes RAM. Stock builds
  unaffected (+28 bytes link-order noise inside the existing
  JAYD_ENABLE_WIRELESS guard, 0 bytes RAM), confirming compile-time
  gating.
- Visual/accessibility pass via a local mock v2 API server + Playwright
  Chromium at 390x844, 820x1180 and 1440x900: pairing screen and full
  two-deck/mixer/lease control view render correctly, no JS runtime
  errors, all interactive controls measured >=44px, prefers-reduced-
  motion rule present and honored, first Tab stop lands on a real
  control.

Note for follow-up: CMakeLists.txt still pins JayD-Library/board core
1.7.3, but the "jayd" board is only defined starting at core 1.8.3;
this is a pre-existing discrepancy independent of this change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Implements the minimum deterministic DjSession-owned layer for beat
quantization, loop triggering, and tempo sync, stacked on the approved
track-metadata-integration work.

- DjBeatEngine.h: pure, host-testable frame<->beat math. Exact
  integer/rational conversion between source frames and beat phase (no
  float drift). Sparse anchor/downbeat grid with confidence/provenance
  gating; capability disables itself when metadata/timebase/confidence
  is absent, stale, ambiguous, or out of the supported BPM/rate range.
  Never derives timing authority from estimated AAC byte position.
  Quantize: off/1/1/2/1/4 beat resolution with a late policy bounded by
  one audio block (apply immediately within tolerance, else next
  boundary). Loops: 1/2, 1, 2, 4, 8 beat lengths with EOF/track-bounds
  validation and source-frame-aligned wrap. Sync: Off/Armed/
  Locked/OutOfRange/Error states, checked fixed-point rate math with the
  library's hard 0.5x-1.5x limit, bounded nudge-based phase correction
  instead of discontinuous re-seeking during playback, and anti-
  oscillation guards.

- DjSessionState.h: new command types (SET_QUANTIZE, LOOP_ENGAGE/
  DISENGAGE/RELOOP, SET_SYNC), PENDING status and new error codes,
  bounded POD grid/quantize/loop/sync snapshot structs, and per-command
  target-frame/late-frames/missed diagnostics. All additions are
  fixed-size, no heap/pointers in the hot path.

- DjSession.h/.cpp: wires the engine into the existing command
  queue/apply/tick/snapshot pipeline. Quantizes cue/play-start/loop
  in-out against the grid using source-frame timing; loop
  engage/reloop/disengage with hot-load clear-on-success and
  retain-on-failure semantics and a single pending seek/action at a
  time (no busy-loop seek storms); sync master selection (explicit or
  deterministic auto), rate/phase convergence ticking, and explicit
  rejection (not silent no-op) for unsupported actions, SD/metadata
  invalidation, EOF/repeat, queue saturation, and two-deck conflicts.
  Sync never starts playback on its own. Mutual exclusion with the
  existing Speed effect is enforced and observable via
  DJ_COMMAND_ERROR_SYNC_CONFLICT.

- MixScreen.cpp: extends the previously-unused per-encoder
  button-hold gesture (encBtnHold, indices 0-5) with a stable LOOP
  bank (1-beat/4-beat toggle) and a SYNC/ASSIST arm-disarm toggle,
  gated entirely by DjSession's own validate()/apply() rejection.
  Mix, Cues/transport, and Browse mappings are untouched; no new
  hidden multi-button chords.

- tests/dj_beat_engine_selfcheck.cpp (new) and
  tests/dj_session_self_test.cpp (extended): host self-checks for
  fixed-point/rational frame math incl. overflow/boundaries, sparse
  anchors/downbeats, quantize late policy, loop bounds/wrap
  scheduling, sync convergence/out-of-range/loss, command
  supersede/lifecycle diagnostics, and capability-disabled default
  snapshots. Both compile clean with
  -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined and
  pass in full.

Scope: no beat analysis on-device, no time-stretching/key-lock
(tempo-sync changes pitch, labeled as such), no auto-DJ/track
suggestions, no browser UI, no persistent cue editing.

Validated against JayD-Library combined dependency tree (PR CircuitMess#19
hot-swap 4ad5108 + PR CircuitMess#21 AAC timing 6750262 + deck-rate control
2afe71c + PR CircuitMess#22 queue-contract fix 78a9f3d, independently approved
final SHA). DjSession.cpp/.h, DjBeatEngine.h, and the new MixScreen.cpp
code compile with zero errors against that tree's real MixSystem API
via the actual ESP32 toolchain. A full linked firmware image (for
flash/RAM size reporting) is blocked in this sandbox by pre-existing,
unrelated Sprite/Font/LovyanGFX version-skew errors in files this PR
does not touch (Fonts.h, InputTest.cpp, MainMenuItem.cpp, SongName.cpp,
MixScreen.cpp's pre-existing drawSaveStatus) - out of scope to fix
here. No JayD/ESP32 hardware was available in this environment, so
upload/boot and physical two-deck exercise are deferred and not
claimed as exercised.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
DjRecordingStorage::finalizeRecording() collapsed two distinct failure
modes into one bool: the bounded naming space being exhausted (or the
directory failing to be created) vs. a real SD.rename() I/O error once
a free path had already been allocated. pollRecording() then mapped
every finalize failure to DJ_RECORDING_ERROR_NAME_EXHAUSTED, leaving
the already-defined DJ_RECORDING_ERROR_RENAME_FAILED/"RECORDING SAVE
FAILED" text dead and misreporting rename failures as "storage full".

finalizeRecording() now returns a DjRecordingLogic::FinalizeOutcome
(SUCCESS / ALLOC_EXHAUSTED / RENAME_FAILED). The outcome-selection
logic itself is a small pure function (decideFinalizeOutcome) in
DjRecordingLogic.h, consistent with the rest of the naming/repair
logic there, so both failure paths -- including an injected rename
failure -- are host-testable without stubbing the filesystem.
DjSession::pollRecording() maps the outcome to the correct
DjRecordingError instead of a single hardcoded value.

Host self-check extended to cover success, alloc-exhaustion (missing
temp file or failed allocation), and injected-rename-failure paths
via decideFinalizeOutcome(). Both host self-checks re-verified against
Library CircuitMess#22 final combined SHA 78a9f3d (independently approved by the
task owner after fixing the prior SourceAAC queue-full hang), as well
as the original PR CircuitMess#20 pin a4e0fdb.

Verified via arduino-cli compile --fqbn cm:esp32:jayd:
- against a4e0fdb: 1,312,286 B flash / 47,152 B RAM (+80 B flash vs.
  pre-fix, from the new enum/branch; RAM unchanged)
- against 78a9f3d: 1,316,998 B flash / 47,152 B RAM

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two required fixes from independent review of PR #10 (b64b4f6):

1. CRITICAL: boot_id is a full-range random uint64_t that was serialized
   as a bare JSON number in handleState()/handleCommand(). A JS Number can
   only represent integers exactly up to 2^53-1, so JSON.parse silently
   rounds ~99.7% of possible boot_id values in the browser. The browser
   then echoes the rounded value back in its next command, and the
   device's exact uint64 identity compare (djCommandIdentityMatches)
   rejects it as stale_identity even though nothing had actually changed.

   Fixed by carrying boot_id end-to-end as an opaque quoted decimal
   string instead of a bare number, on both the state response and the
   command request. Added WirelessApi::parseUint64Decimal() (device-side,
   strict digit-by-digit parse with overflow rejection) and switched
   handleCommand() to getString()+parseUint64Decimal() instead of
   getNumber(). The client never Number-coerces boot_id; it is passed
   through as an opaque string in buildCommandBody/identitySnapshot, so
   JSON.stringify naturally re-quotes it. seq and session_id are left as
   plain JSON numbers: seq is monotonic and would take ~285M years at
   ~1 req/sec to exceed 2^53, and session_id is a uint32_t - both are
   exact as JS numbers. Also added a 'stale_identity' case to the
   client's describeError() so this failure mode now surfaces readable
   recovery text instead of a generic "Device error: stale_identity".

2. HIGH: applySnapshot()'s seq<=lastSeq staleness guard ran before
   comparing boot/session identity. After a device reboot, seq resets to
   0, which is <= whatever high lastSeq the browser saw pre-reboot, so
   every post-reboot snapshot was silently dropped forever - the UI froze
   on stale pre-reboot state and the reconnect-to-read-only lease reset
   never ran.

   Fixed by extracting the decision logic into a new pure helper,
   reconcileSnapshot(prev, snapshot), that compares identity first and
   only applies the seq staleness guard within the same identity (an
   identity change resets the effective lastSeq baseline before that
   guard runs). applySnapshot() now calls this helper, and on
   identityChanged resets app.tracker (fresh command-id tracker) and
   app.pollFailures before firing the existing RECONNECT/read-only lease
   transition, so nothing from the old identity can ever be treated as
   already-sent against the new one.

Device-side identity compare (djCommandIdentityMatches) was already
exact/correct at full uint64 range; only the wire encoding and the
client's parsing order needed to change.

Tests added:
- tests/wireless_api_self_test.cpp: parseUint64Decimal round-trip (0,
  UINT64_MAX, mid-range) and rejection (empty/non-digit/negative/
  leading-space/overflow-by-one/grossly-overflowed) cases; boot_id
  parsed as a JSON string via getString()+parseUint64Decimal(), plus
  confirmation that a legacy bare-number boot_id now correctly fails
  getString(); djCommandIdentityMatches at full uint64 range.
- tests/dj_session_self_test.cpp: stale-identity regression via
  djCommandIdentityMatches across a simulated reboot, plus confirming
  non-HTTP-origin commands are exempt.
- tests/wireless_ui_self_test.mjs: reconcileSnapshot coverage for the
  first-snapshot case, the exact high-lastSeq/new-boot/seq=0 reboot
  regression, session-id-only identity changes, same-identity
  stale/fresh seq handling, opaque-string boot_id comparison (using
  adjacent full-range uint64 strings that collide under naive Number()
  coercion), and a no-replay-after-reconnect fresh-tracker check; a
  buildCommandBody test asserting boot_id passes through untouched as a
  string, never coerced.

Re-ran full validation: 33/33 JS self-checks, both C++ self-test
binaries, and the Python source self-test all pass. Rebuilt stock and
wireless firmware against reviewed JayD-Library commit 4ad5108 (same
override methodology as the original PR): wireless 2,639,842 bytes
flash / 51,776 bytes RAM (+1,400 bytes flash vs the pre-fix commit,
attributable to the new parser + slightly larger embedded UI asset;
RAM unchanged); stock 1,770,342 bytes flash / 47,192 bytes RAM
(unchanged within link-order noise, confirming JAYD_ENABLE_WIRELESS
gating still isolates the feature). Live-verified the exact reboot
regression with a mock v2 API server + Playwright: after a simulated
reboot (new boot_id, seq reset to 0) the UI drops to read-only with a
"device restarted" message and remains responsive/pollable, rather
than freezing, at mobile/tablet/desktop widths.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Review of d9a977b found two required fixes:

- HIGH: DjSyncController::tick() re-commanded the sync target rate
  every tick via applyRate, and separately applied a bounded nudge
  every DJ_SYNC_NUDGE_COOLDOWN_TICKS ticks. Since SpeedModifier::setRate()
  unconditionally overwrites requestedRate (nudgeRate() is implemented
  on top of it), the very next tick's unconditional setRate(targetRate)
  silently erased any nudge just applied, defeating phase convergence.
  Fixed by caching the last-commanded baseline rate and only emitting
  applyRate when the target genuinely changes; the cache resets on every
  OFF/ERROR/OUT_OF_RANGE transition so resuming always re-asserts a real
  baseline and never fights a manual rate change made while sync was
  inactive.

- MEDIUM: DjBeatEngine.h claimed DjSession.cpp static_asserts its rate
  constants against SpeedModifier's at compile time, but no such assert
  existed. Added real static_asserts in DjSession.cpp comparing
  DJ_RATE_SCALE/MIN/NEUTRAL/MAX against SpeedModifier::RateScale/MinRate/
  NeutralRate/MaxRate, making the header comment's claim true.

Added testSyncNudgePersistsAndConverges to
tests/dj_beat_engine_selfcheck.cpp: drives DjSyncController::tick()
through a FaithfulSpeedModifierModel (mirrors real setRate/nudgeRate
overwrite-vs-additive semantics) across multiple cooldown windows,
asserting the persisted requestedRate after every nudge equals
baseline + cumulative nudge (this assertion fails immediately without
the fix, since the baseline re-command would flatten requestedRate back
to target every non-nudge tick) and that the controller converges to
DJ_SYNC_LOCKED (13 nudges, non-increasing magnitude, final phase error
49 frames, all analytically verified) without oscillation or spam.

Validated:
- Both host self-checks (dj_beat_engine_selfcheck, dj_session_self_test)
  pass under -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.
- Targeted compile of DjSession.cpp/.h/DjBeatEngine.h against the real
  combined JayD-Library API at 78a9f3d7e52fd151dc50872812ae2ed1da308af1
  (approved PR CircuitMess#22 head): zero errors, including the new static_asserts.
  Remaining build errors are the same pre-existing, unrelated Fonts.h/
  Sprite::setFont/TFT_eSPI::setPanel version-skew issues documented in
  d9a977b, confirmed present under both cm:esp32 1.7.3 and 1.8.3 board
  cores (checked again this round; not resolved by switching versions,
  no vendored fix found elsewhere on disk) -- full linked image remains
  unmeasured for the same pre-existing, unrelated reason.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Independent re-review of 89f9a39 found a MEDIUM regression-test gap
(the identity/reboot fix itself was not flagged as wrong): the only
coverage was reconcileSnapshot(), a pure decision helper. applySnapshot()
- the DOM-bound runtime function that actually resets app.tracker,
app.pollFailures, and app.leaseState when accepting a new boot/session
identity - lives inside a `typeof document !== 'undefined'` guard and
never runs under Node, so nothing in the suite ever called it. Deleting
the tracker/pollFailures/lease resets at the old app.js:739-741 would
still have left every existing test green.

Fixed by extracting the DOM-independent half of applySnapshot() into a
new applySnapshotCore(app, snapshot): it calls reconcileSnapshot(), and
when accepted, performs the exact same identity/tracker/pollFailures/
lease mutation the runtime previously inlined. applySnapshot() now calls
this shared function instead of re-implementing the mutation, so the
runtime and the test exercise literally the same code, not a parallel
reimplementation kept in sync by hand. Only DOM rendering (deck/mixer/
status text, lease-state UI update) remains in the document-guarded
applySnapshot().

Added three tests against applySnapshotCore() directly:
- identity change replaces the tracker with a fresh instance that has no
  memory of any pre-reboot command id (no replay), and resets
  pollFailures to 0 and leaseState to read_only from a prior
  'controlling' state.
- a same-identity fresh snapshot updates lastSeq but leaves the tracker
  instance, in-flight tracked ids, pollFailures, and leaseState
  untouched (proves the reset is identity-scoped, not run on every
  snapshot).
- a stale/duplicate snapshot within the same identity is rejected
  without mutating app state at all.

Verified the middle test actually catches the regression report
described: temporarily reverting applySnapshotCore() to skip the
tracker/pollFailures/lease reset reproduces the exact failure
("a new boot/session must install a brand-new tracker instance"),
confirming these tests would have caught 89f9a39's gap; restored before
committing.

Re-ran full validation: 36/36 JS self-checks (was 33; net +3 for the
three new applySnapshotCore tests, after folding the old
architectural-only "no replay" check into them), both C++ self-test
binaries (unchanged - no C++/wire-protocol change this round), and the
Python source self-test all pass. Regenerated WirelessUiAsset.h.
Rebuilt stock and wireless firmware from clean sketch/library trees
against reviewed JayD-Library commit 4ad5108: wireless 2,640,378 bytes
flash / 51,776 bytes RAM (+536 bytes flash vs 89f9a39, exactly the new
gzip-compressed asset size delta from the added
applySnapshotCore/test code; RAM unchanged); stock 1,770,314 bytes
flash / 47,192 bytes RAM (-28 bytes vs 89f9a39, within normal link-order
noise, confirming JAYD_ENABLE_WIRELESS gating is unaffected - this
refactor touches only browser-side JS).

No API/wire-protocol semantics changed; boot_id remains an opaque
decimal string end-to-end and identity is still compared before seq
staleness, as fixed in 89f9a39.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
testSyncNudgePersistsAndConverges previously subtracted nudgeAmount (a
Q16.16 DjRate, dimensionless input/output sample ratio) directly from
followerPhaseFrames (a frame count) -- a units/domain mismatch flagged
by independent review. Real SpeedModifier::nudgeRate() only changes
requestedRate; actual playback speed (currentRate) ramps toward it by
RateScale/BUFFER_SAMPLES per generated sample (advanceRate()), and
phase movement is a function of source frames actually consumed via
the sourcePosition accumulator in generate(), not a direct rate delta.

FaithfulSpeedModifierModel now mirrors the real per-sample ramp and
Q16.16 accumulator (currentRate, sourcePositionFrac, advanceRate(),
consumeSourceFrames()) with BUFFER_SAMPLES=256 matching AudioSetup.hpp.
Each simulated tick consumes one output block (256 samples) and the
phase correction uses the net difference between frames actually
consumed and nominal 1:1 neutral-rate consumption -- dimensionally
consistent with real playback.

Re-derived expected numbers via a faithful simulation of the real
ramp+accumulator mechanics: 17 nudges fire with strictly
non-increasing magnitudes, phase error decreases monotonically from
2000 to a final 26 frames, converging to LOCKED within 137 ticks.
Added an explicit non-increasing-magnitude assertion to prove no
oscillation. Kept the requested-rate persistence assertion (still
valid, rate-space only, unaffected by this fix).

Host self-checks (dj_beat_engine_selfcheck.cpp,
dj_session_self_test.cpp) pass clean with
-std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.

Full firmware build verified against JayD-Library PR CircuitMess#22 final SHA
78a9f3d7e52fd151dc50872812ae2ed1da308af1 using an isolated arduino-cli
1.5.1 environment (cm:esp32@1.8.3, bundled CircuitOS 1.7.1 +
LovyanGFX, plain library checkout via --library, confirmed used via
verbose log). Clean full link, zero errors, zero warnings:
flash 1,820,410 B (50%) of 3,612,672 B max;
static RAM 47,184 B (14%) of 327,680 B max (280,496 B free for locals).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
djdefi and others added 22 commits August 22, 2026 12:42
Post-lock review found a real production issue exposed by the
faithful test model: SpeedModifier::nudgeRate() only ever adds to
requestedRate, never removes; the applyRate dedup cache (added to fix
the earlier per-tick-flattening bug) meant the accumulated correction
never got explicitly cancelled once locked, since the BPM-derived
target itself hadn't changed. The follower would keep running at a
permanent nudge-biased offset from its true tempo indefinitely, and
any further drift would eventually require reverse nudges once it
overshot back past master.

DjSyncController now tracks whether a correction is currently active
(set on each applyNudge, cleared whenever the baseline is
re-established). On the lock transition -- and on hard align, since
the upcoming seek makes any leftover rate bias immediately
counterproductive -- it forces exactly one applyRate back to the pure
BPM-derived baseline if a correction was active, then clears the flag.
This does not reintroduce per-tick setRate flattening: while already
locked/settled with no active correction, no further applyRate is
issued, so a live nudge can still persist and converge normally on the
next drift. The flag is also reset on off/error/out-of-range
transitions and whenever the commanded baseline itself changes (a
brand-new setRate already overwrites any residual outright).

Extended testSyncNudgePersistsAndConverges to run well past the first
lock: asserts the residual offset is explicitly cancelled exactly once
at lock (requestedRate == baselineTarget, not baseline + leftover
nudge sum), then drives 32 further ticks confirming phase stays within
tolerance, requestedRate/currentRate stay pinned to baseline with zero
drift, no reverse corrective nudges fire, and no further applyRate
calls occur (no setRate spam) once settled. Kept the pre-lock
persistence/non-increasing-magnitude assertions (17 nudges, still
verified via the faithful ramp+accumulator model) unchanged.

Host self-checks (dj_beat_engine_selfcheck.cpp,
dj_session_self_test.cpp) pass clean with
-std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.

Full firmware build re-verified against JayD-Library PR CircuitMess#22 final SHA
78a9f3d7e52fd151dc50872812ae2ed1da308af1 (fresh isolated arduino-cli
environment, plain library checkout via --library, confirmed used via
verbose log). Clean full link, zero errors, zero warnings:
flash 1,820,610 B (50%) of 3,612,672 B max (+200 B vs prior commit,
added control logic only);
static RAM 47,184 B (14%) of 327,680 B max, unchanged (no new static
storage).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…jdefi-dj-coach-integration

* fork/djdefi-quantize-loops-sync:
  Restore BPM baseline on sync lock to cancel residual nudge offset
  Fix sync nudge regression test to use physically faithful ramp model
  Fix sync nudge persistence and add real rate static_asserts
  Add firmware-owned beat-grid, quantize, loop, and sync engine
  Preserve metadata across index reload failures
…-dj-coach-integration

* fork/djdefi-friendly-robot:
  Distinguish naming exhaustion from rename failure in finalizeRecording
  Add authoritative recording lifecycle and bounded storage
  Add visible DJ control banks
  Preserve effects before deck load
  Preserve effects before deck load
  Add firmware-owned DJ session

# Conflicts:
#	src/DjSession/DjSession.cpp
#	src/DjSession/DjSession.h
#	src/DjSession/DjSessionState.h
#	src/Screens/MixScreen/MixScreen.cpp
#	src/Screens/MixScreen/MixScreen.h
#	src/Screens/SongList/SongList.cpp
#	src/Screens/SongList/SongList.h
#	tests/dj_session_self_test.cpp
… djdefi-dj-coach-integration

* fork/djdefi-accessible-browser-v2:
  Test the actual reboot-reset runtime path, not just its inputs
  Fix boot_id JSON precision loss and reboot snapshot freeze
  Add accessible browser control UI (v2 API client)
  Fix wireless pairing access and result order
  Add paired wireless semantic API

# Conflicts:
#	src/DjSession/DjSession.cpp
#	src/DjSession/DjSession.h
#	src/DjSession/DjSessionState.h
#	tests/dj_session_self_test.cpp
Post-merge fixes surfaced by rebuilding the full Sync+Recording+Browser+
Coach dependency stack:
- dj_session_self_test.cpp: the new LOOP/SYNC palette regression test
  assumed openPalette() always seeds paletteSelection from MIX_BANK_MIX,
  but it seeds from the *current* bank (CUES, from the preceding
  assertions) -- fixed the relative movePalette() offset, not the
  underlying MixControlState logic.
- metadata_integration_selfcheck.cpp: referenced the pre-Recording flat
  DjSnapshot::recording bool, which Recording replaced with the richer
  DjRecordingSnapshot; updated to set recordingInfo.state to a busy
  state so the djAllowsLibraryWork() regression coverage still holds.
- CMakeLists.txt: wires the previously-unbuilt DjRecordingSelfCheck and
  WirelessApiSelfCheck targets into ctest (same -Wall -Wextra -Werror +
  ASan/UBSan bar as DjAssistSelfCheck), and adds an overridable
  JAYD_LIBRARY_SRC cache variable so DjRecordingSelfCheck can compile
  against a specific pinned JayD-Library checkout's headers instead of
  whatever core version happens to be installed.

All 7 host self-checks pass under
cmake -DJAYD_LIBRARY_SRC=<pinned JayD-Library>/src && ctest.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…sion bridge

Additive DjSessionState.h changes for the Coach/one-shot-transition
integration: DjDeckSnapshot gains a per-deck identity field (derived only
when metadata is DJ_METADATA_VALID, so an unresolved deck never falsely
identity-matches another), and three new command types
(DJ_COMMAND_ASSIST_SET_MODE/ARM_TRANSITION/CANCEL_TRANSITION, plus
DJ_COMMAND_ERROR_ASSIST_REJECTED) route Coach control through the same
async DjCommand queue/origin/audit pipeline as every other mutating action.
Always present (not wireless-gated) since the physical Assist bank needs
them too.

New DjAssistSessionBridge.h/.cpp: pure, host-testable glue between
DjSession's authoritative state and the Coach engine's POD inputs -
candidate-entry construction (mirrors DjSession::resolveMetadata()'s
capability-bit formula, deliberately without its per-grid/per-phrase
downbeat enumeration) and the crossfade-ramp mix curve (checked against
bpm/beat-count divide-by-zero, saturates correctly at both endpoints,
guards against elapsed-time overflow). Covered by a new
DjAssistBridgeSelfCheck CMake/ctest target at the same
-Wall -Wextra -Werror + ASan/UBSan bar as DjAssistSelfCheck.

DjSession.cpp/.h wiring (validate()/apply() cases, DjAssistController,
publishSnapshot() identity population) follows in a subsequent commit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds DjAssistBridge::buildTrackIdentity(fingerprint, sourceId), gating
each evidence flag on its 16-byte array being non-all-zero so an
unpopulated field reads as "no evidence" rather than a spurious
all-zero identity that could falsely identity-match another
unresolved track. This is the exact primitive DjSession's integration
wiring needs to populate DjDeckSnapshot::identity and the candidate
table's per-entry identity from JaydMetadata::Track's raw fingerprint/
sourceId bytes.

Adds testBuildTrackIdentityGatesZeroEvidence() covering: both-zero
(no evidence), fingerprint-only, sourceId-only, both-real, and that
two independently-built all-zero identities carry zero flags (so
DjAssistScoring::identityMatches never claims a match from absent
evidence). DjAssistBridgeSelfCheck passes under g++ -std=c++11 -Wall
-Wextra -Werror + ASan/UBSan.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…tate

Integrates the isolated DjAssistEngine core (478667a) into DjSession
now that Sync/quantize (dca7529), browser identity/API (ce4fe07),
recording (2579e93), and metadata (8c0ae10) are all merged into this
branch. DjAssistEngine.h/.cpp/DjAssistScoring/.h/.cpp/DjAssistTypes.h
remain byte-for-byte unmodified; this commit only adds the
integration layer around them.

New src/DjAssist/DjAssistController.{h,cpp}:
- Owns the engine, a PSRAM-backed bounded candidate table
  (DJ_ASSIST_MAX_INDEX_ENTRIES entries, ps_malloc/free matching
  SongList.cpp's precedent, silently disabled on allocation failure),
  the recent-track ring, and a phrase-boundary hint cache.
- refreshCandidateTable() does a bounded incremental fill from
  DjSession::assistTrackEntry() gated on a completed generation-scoped
  fill pass (fillComplete_) before DjAssistScoring::scanTick() ever
  runs, so scanTick()'s `total` parameter never changes mid-pass.
- resolveBoundary() recomputes the cheap in-memory downbeat hint every
  tick but throttles the bounded-but-real-SD-read phrase hint to only
  refresh once the previously cached boundary has been passed.
- DjAssistSessionActuator (file-local) maps transition steps to real
  commands: START_DECK/STOP_DECK -> setPlaying(), LOCK_TEMPO and
  ENABLE_SYNC -> the same idempotent setSync(true, ...), RELEASE_SYNC
  -> setSync(false, ...), CROSSFADE -> a polled ramp of setMix() calls
  driven by DjAssistBridge::computeCrossfadeMix(), with completion
  detected from the mix value reaching its exact target endpoint
  (0/255), which computeCrossfadeMix() only ever returns once the ramp
  has fully elapsed.
- WAIT_BOUNDARY's hint is resolved against the plan's outgoing
  (fromDeck), not the step's toDeck, matching Coach's own "next safe
  window" semantics; a deliberate integration-layer choice, not a
  change to the frozen engine.

DjSession.h/.cpp:
- New always-on (not wireless-gated) command surface:
  assistSetMode(), assistArmTransition(), assistCancelTransition(),
  three new DjCommandType values plus DJ_COMMAND_ERROR_ASSIST_REJECTED
  and a new DjCommand::libraryIndex field (appended after path[], so no
  positional-init call site anywhere in the tree is affected -
  confirmed via search).
- validate() bounds ARM_TRANSITION's deck/slot pair (in range,
  distinct) and crossfadeBeats (must be one of 4/8/16/32); SET_MODE's
  value must be 0/1.
- apply() delegates the three new command types to assistController;
  ARM_TRANSITION surfaces a rejected guard as
  DJ_COMMAND_ERROR_ASSIST_REJECTED without a redundant hasDeck() check
  (armTransition() already guards on deckPlaying[fromDeck]/
  deckLoaded[toDeck] via its own snapshot).
- loop() ticks the controller once per iteration, after pollRecording()
  and before publishSnapshot() (same one-tick-stale snapshot-read
  pattern already implicit in the existing tick order).
- publishSnapshot() now populates each deck's DjTrackIdentity from the
  attached metadata's fingerprint/sourceId via
  DjAssistBridge::buildTrackIdentity(), so browser/API/physical-bank
  consumers and the transition guard's per-tick target-identity check
  see the real loaded track identity, not a placeholder.
- New read accessors for the controller: assistLibraryGeneration(),
  assistTrackCount(), assistTrackEntry() (candidate-table data source,
  reusing the same mutex-guarded metadataReader path as
  resolveMetadata() - never a fresh file read outside these bounded
  calls), mediaPresent() (SD.cardType() != CARD_NONE, mirroring
  SongList.cpp's existing ad-hoc check), deckElapsedFrames(),
  nextDownbeatFrame() (cheap grid math), and nextPhraseFrame() (bounded
  real SD read over JaydMetadata::Reader::MaxPhrasesPerTrack phrases,
  intentionally throttled by the controller rather than by
  DjSession itself).
- copyAssistSnapshot() returns a bounded POD DjAssistSnapshot
  (mode/plan/suggestions/advice) safe for a browser/API payload or
  physical-bank UI cache - copied values only, no pointers.

All 8 existing host self-checks (LibraryIndexSelfCheck,
DjSessionSelfCheck, DjBeatEngineSelfCheck,
MetadataIntegrationSelfCheck, DjAssistSelfCheck,
DjAssistBridgeSelfCheck, DjRecordingSelfCheck, WirelessApiSelfCheck)
still pass unchanged; DjAssistController/DjAssistSessionActuator are
ESP32-coupled (same boundary as DjSession.cpp itself) and are
validated by the firmware build, not ctest.

Still pending in this integration branch: physical Assist bank UI,
browser/API v2 wiring, integration-level host/browser tests, and the
real arduino-cli firmware build/size report against the pinned
Library SHA.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The browser-workflow merge (loadSelected/updateBrowseResult) left an
extra closing brace at end of file with no matching opener - the
anonymous namespace for local helpers already closes at line 24; this
trailing brace was dead weight that only a real arduino-cli compile
catches (host self-checks never touch this ESP32-coupled UI file).

Found while running the first real full-firmware compile of the
Coach integration stack; fixed in isolation, no behavior change.
Adds a visible ASSIST bank/palette entry (MIX_BANK_ASSIST/MIX_PALETTE_ASSIST)
reachable only through the existing palette menu - no hidden chord, and
the existing recording chord (btnCombination) is untouched.

Bank renders (text only, never color-only status):
 - Mode header: OFF / COACH / ARMED / RUNNING / COMPLETE / FAILED.
 - Coach advice: suggested deck, next boundary type (phrase/beat/none),
   target rate %, crossfader direction, and one line per warning flag
   (out-of-range, no grid, ending soon, recording active, loop active,
   no metadata).
 - Current suggestion (rotate center encoder to browse): tempo delta,
   Camelot-equivalent key relationship, confidence %, library index -
   advisory only, never a load target.
 - Transition progress: from/to deck, current step/stepCount with a
   human-readable action name, crossfade beat count.
 - Context-sensitive footer instructions, always visible.

Input mapping (only two hold-actions on this bank, always labelled
on screen):
 - Center encoder rotate: move the suggestion cursor.
 - Hold L1 (encoder 0): toggle Coach mode on/off; also the only way
   back to COACH/OFF from COMPLETE/FAILED (setCoachEnabled() is safe
   outside ARMED/RUNNING).
 - Hold R1 (encoder 3): arm a one-shot transition from the selected
   deck to whatever is already loaded on the other deck (cancels an
   in-flight transition instead if one is ARMED/RUNNING). Loading a
   candidate stays on the existing Browse workflow; this bank never
   loads a path itself. Best-effort matches the loaded identity
   against the current suggestion list purely for a cosmetic
   libraryIndex - DjAssistEngine::armTransition() validates the real
   target via identity, not this index.

Adds DJ_COMMAND_ERROR_ASSIST_REJECTED to the accessible command-error
text table.
`maxTrackCount`/`maxPathLength`/`maxPathPayload`/`maxIndexPayload`/
`maxDirectoryDepth`/`visibleRows`/`rowHeight`/`holdTime` are in-class-
initialized `static const` integral members with no out-of-class
definition. reservePaths()/reserveSongs() odr-use two of them by
reference (min(capacity, maxPathPayload) / min(capacity,
maxTrackCount)), which requires a definition with storage whenever the
compiler doesn't fully constant-fold the reference away.

The non-wireless firmware build happened to link regardless (the
optimizer folded these away in that configuration); building the same
sketch with `-DJAYD_ENABLE_WIRELESS` (needed to compile/link this
branch's new browser/API v2 Coach wiring) changed enough surrounding
codegen to surface `undefined reference to
SongList::SongList::maxPathPayload`/`maxTrackCount` at link time. This
is a pre-existing latent bug in already-merged SongList code (not
something this branch's Coach work introduced or duplicates), only
caught because this is the first real full compile+link of this
combination. Fixed portably by providing storage for every such
member, independent of what the optimizer happens to do.

No behavior change; sizes only (definitions add negligible .rodata,
no functional difference). Confirmed both build configurations still
link clean after this fix:
  - default (no JAYD_ENABLE_WIRELESS): 1,377,270 B flash / 47,160 B RAM
  - with JAYD_ENABLE_WIRELESS: 1,966,418 B flash / 51,832 B RAM

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Wires the Coach engine (DjAssistController, approved isolated core
478667a) into the existing /api/v2/state and /api/v2/command routes,
alongside the physical Assist bank (6edd1c0) and DjSession integration
(b4c56ea). No new routes; the wireless surface stays exactly the
single-endpoint state+command pattern already in place.

handleCapabilities(): advertises assist_set_mode, assist_arm_transition,
assist_cancel_transition alongside the existing actions.

handleState(): adds a bounded "assist" object - mode, coach advice
(suggested deck, boundary type, target rate, crossfade direction,
accessible warning-code array - never color/bare-bitmask-only),
suggestions (up to 5: library_index, tempo_delta, key relationship,
rating, confidence, exclude reason, and the raw reason_flags bitmask
for richer client-side detail), and the transition plan (from/to deck,
crossfade_beats, step/steps, current action, failure). All values are
plain copies from DjAssistController::copySnapshot() (via a new
DjSession::copyAssistSnapshot() wrapper) - no re-scoring, no file
reads, no pointers. Suggestions/advice/plan are always emitted; the
browser is expected to gate which section is meaningful by "mode",
exactly like the physical Assist bank does.

handleCommand(): DjSession::assistSetMode()/armTransition()/
cancelTransition() are convenience wrappers that don't populate the
wireless dedup fields (clientId/clientCommandId/requestBootId/
requestSessionId), so validate() would reject them from HTTP with
DJ_COMMAND_ERROR_CLIENT_ID_REQUIRED. Instead this builds the DjCommand
manually, mirroring the existing set_playing/seek/etc. branches:
  - assist_set_mode: reuses the existing "value" bool field.
  - assist_cancel_transition: no extra fields.
  - assist_arm_transition: takes deck (from), to_deck, crossfade_beats,
    start_at_boundary, tempo_lock. The client never sends a raw
    DjTrackIdentity/path - the server resolves
    snapshot.decks[to_deck].identity itself (same approach as the
    physical bank's assistArmFromSelected()), so an armed transition
    can never target an arbitrary/unloaded track. libraryIndex is a
    best-effort cosmetic lookup against the current suggestion list
    (DjAssistScoring::identityMatches), never load-bearing - the
    engine's guard snapshot re-verifies the identity every tick
    regardless. validate() already fully bounds-checks deck/slot/
    crossfadeBeats server-side; this handler only additionally
    range-checks to_deck locally before using it as an array index.

commandErrorName(): adds the DJ_COMMAND_ERROR_ASSIST_REJECTED case.

Also fixes handleState()'s pre-existing "recording" JSON field, which
referenced a `DjSnapshot::recording` member that doesn't exist
(the field is `recordingInfo` - a struct with `.state`); this was
dead code until a real compile with JAYD_ENABLE_WIRELESS defined
exercised it for the first time on this branch. Mirrors the identical
active-state check already used in DjAssistController.cpp.

Deferred to a follow-up commit: the browser UI (app.js/index.html/
styles.css) - visible Coach panel, advice/confirm/cancel controls,
accessible reason text, no color-only status.

Verified: full arduino-cli firmware build (cm:esp32@1.8.3, CircuitOS
1.7.1, JayD-Library @ 78a9f3d7e52fd151dc50872812ae2ed1da308af1) links
clean in both configurations:
  - default (no JAYD_ENABLE_WIRELESS): 1,377,270 B flash (38%) /
    47,160 B RAM (14%)
  - with JAYD_ENABLE_WIRELESS: 1,966,418 B flash (54%) / 51,832 B
    RAM (15%)
All 8 host self-checks (ctest) still pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the browser-side counterpart to b80003a's /api/v2 Coach surface, so
the on-device physical Assist bank has an equivalent web UI:

- index.html: a new "Assist" section - mode status text, a Coach on/off
  toggle, an advice block (suggested deck, next phrase/downbeat boundary,
  target tempo, crossfader direction, warnings list), a ranked suggestions
  list, a one-shot-transition arm form (from/to deck, crossfade-beat count,
  start-at-boundary/tempo-lock checkboxes), and a transition-progress
  status line. All accessible text, no color-only status (mirrors the
  existing recording-toggle convention of pairing a border/text-color
  accent with a real state change in aria-pressed/textContent, never
  color alone). Also documents Coach/transition behavior in the existing
  help <details> block.
- app.js:
  - COMMAND_SHAPE entries for assist_set_mode/assist_arm_transition/
    assist_cancel_transition, matching WirelessBringup.cpp's handleCommand()
    field names exactly (deck/to_deck/crossfade_beats/start_at_boundary/
    tempo_lock).
  - Pure, unit-tested decode helpers (deckLabel, describeCrossfadeDirection,
    formatBpmDelta, formatRatePercent, describeReasonFlags) plus lookup
    tables for every mode/key-relationship/exclude-reason/transition-action/
    transition-failure/warning token the device can emit - all exported via
    `helpers` for tests/wireless_ui_self_test.mjs, matching this file's
    existing pattern of keeping decode logic DOM-free and testable.
  - renderAssist()/renderAssistAdvice()/renderAssistSuggestions()/
    renderAssistPlan(), called from applySnapshot() each poll tick, gate
    which sub-panel is shown by `mode` (advice/suggestions only in "coach",
    transition progress only while armed/running/complete/failed) - same
    "browser gates by mode" contract the wire format's doc comment
    describes.
  - onToggleCoach/onArmTransition/onCancelTransition handlers using the
    existing sendCommand() dispatcher; bound in buildShell().
  - describeError() gains an `assist_rejected` case for
    DJ_COMMAND_ERROR_ASSIST_REJECTED.
  - List rendering uses createElement/textContent + a small clearChildren()
    helper, never innerHTML, per this file's existing DOM-injection-safety
    invariant (verified by the self-test's static source scan).
- styles.css: minimal additions for the new section following existing
  tokens/conventions (no new color-only status; warnings get an explicit
  "⚠" glyph, not just color).
- tests/wireless_ui_self_test.mjs: new coverage for the 3 assist
  COMMAND_SHAPE entries, the pure decode helpers, and a completeness check
  that every mode/key/exclude/action/failure/warning token
  WirelessBringup.cpp can emit has matching display text (fails loudly if
  either side adds a token the other doesn't know about).
- src/Wireless/WirelessUiAsset.h: regenerated via
  tools/generate_wireless_ui_asset.py after the ui/ source changes (this
  file is a generated PROGMEM-embedded gzip bundle, not hand-edited).

Deferrals (unchanged from b80003a, now also reflected in the UI's help
text): no cue support and no library browsing/loading over the wireless
API yet - the arm-transition form requires the target track already loaded
via the device's own screen, consistent with req. #5's "track load remains
existing bounded library/device workflow."

Validation:
- node tests/wireless_ui_self_test.mjs: all 40 checks pass (up from 32).
- Full arduino-cli build (cm:esp32@1.8.3, Library 78a9f3d7e52f), both
  configurations, clean cache:
  - default (no JAYD_ENABLE_WIRELESS): 1,377,270 B flash (38%) / 47,160 B
    RAM (14%) - unchanged, WirelessUiAsset.h is unreachable in this config.
  - -DJAYD_ENABLE_WIRELESS: 1,970,274 B flash (54%) / 51,832 B RAM (15%)
    (+3,856 B flash vs b80003a, from the larger gzip UI bundle; RAM
    unchanged since the asset lives in PROGMEM).
- ctest: all 8 host self-checks still pass (no regression).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…le-guard gap

Review issue #1 (HIGH): DjSession applied commands, then tickAssist() read
the *previous* published snapshot, so a same-iteration deck reload/swap
could let Assist act once more against the now-stale, unconfirmed track
before the next publish caught up.

Reorder DjSession::loop() to publishSnapshot() after apply()/tickLoops()/
tickSync()/pollRecording() but before tickAssist(), so the guard/bridge
DjAssistController builds is always the authoritative post-command state
for that same tick. tickAssist() never mutates DjSession's own live state
directly (only submits DjCommands, applied on a later iteration), so no
second publish is needed this tick.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…sfade, manual-override guard, bounded fill

Addresses review issues #2, #3, #4, #5 on PR CircuitMess#44 (kept together: the
fixes share the same tickTransition()/tick() flow and DjAssistController
actuator, and were reviewed as a set).

Issue #2 (HIGH, boundary semantics): resolveBoundary() reports the NEXT
upcoming boundary every tick, so it is true almost always - WAIT_BOUNDARY
was advancing immediately instead of actually waiting. Add
DjAssistBoundaryHint::reached, set only by the new capture-once-then-
compare DjAssistController::resolveWaitBoundaryReached() (latches ONE
target frame the first tick the step is current, then just compares the
live playhead against it every tick after). DjAssistEngine::tick() now
gates WAIT_BOUNDARY strictly on `reached`.

Issue #3 (HIGH, cancel/failure rollback): a terminal cancel/failure left
plan-owned mix/sync/started-deck side effects active. Add a bounded
DjAssistController::tickRollback() state machine (MIX -> SYNC ->
STOP_DECK -> DONE, mirroring the forward plan, one actuator submit/poll
per tick, only undoing steps this plan itself submitted, waiting for
each command's real applied/failed/superseded result) plus the pure
DjAssistBridge::nextRollbackPhase()/evaluateCommandOutcome() helpers it's
built from. Never touches pre-existing user state, and deliberately never
restarts fromDeck once STOP_DECK has run.

Issue #4 (HIGH, crossfade confirmation + manual override): the crossfade
poll declared APPLIED from elapsed-time math alone, discarding the actual
setMix() result, and a manual mix override mid-ramp could be silently
overwritten by the next programmatic tick. The actuator now tracks the
exact final-value setMix() command id and only reports APPLIED once
DjSession confirms it applied (SUPERSEDED triggers exactly one resubmit).
Add DjAssistGuardSnapshot::manualMixOverride, computed by
DjAssistBridge::detectManualMixOverride() (a bounded scan of recent
command results for a non-system SET_MIX submitted after arming) and
checked unconditionally in guardOk() - not skipped once crossfadeSubmitted
like the plain mix-threshold check, closing exactly the gap the review
flagged.

Issue #5 (HIGH, hot-loop SD I/O): refreshCandidateTable()'s fill loop
reused DJ_ASSIST_DEFAULT_SCAN_BUDGET (256), a synchronous multi-record SD
read burst inside DjSession::loop(). Add a separate
DJ_ASSIST_FILL_RECORDS_PER_TICK (1) for the genuine-I/O fill path, leaving
the pure in-memory CPU scoring scan's 256 budget untouched.

Test coverage added: driveUntilCommandVisible()/
testTransitionWaitsForBoundaryStep() updated for `reached` semantics plus
a regression proving hasPhrase/hasDownbeat alone never advances the step;
a manual-mix-override-during-crossfade regression in
testTransitionManualOverride(); new dj_assist_bridge_selfcheck.cpp cases
for nextRollbackPhase() (full sequence + every skip-combination),
evaluateCommandOutcome() (exhaustive status mapping), and
detectManualMixOverride() (watermark boundary, origin/type gating,
rejected-command exclusion, null/empty).

All host self-checks (DjAssistSelfCheck, DjAssistBridgeSelfCheck, plus the
full ctest suite) pass under -Wall -Wextra -Werror + ASan/UBSan.
DjAssistController.cpp/DjSession.cpp remain validated only by the real
firmware build per existing project precedent.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…at compile time

Review issue #6 (MEDIUM): the fixed 3 KiB response buffer was smaller
than the valid worst-case /api/v2/state body (2 decks with max escaped
paths/effects + up to 8 recent results + up to 5 suggestions + a plan;
~3,438 B by exact worst-case field lengths), so a fully-loaded, valid
state could deterministically trip response_too_large.

Bump MAX_RESPONSE 3072 -> 4096 and add a constexpr STATE_*_BUDGET/
STATE_WORST_CASE formula with a static_assert(STATE_WORST_CASE <
MAX_RESPONSE, ...) so a future capacity bump (more suggestions, longer
paths, etc.) fails the build instead of silently starting to return
response_too_large. handleCapabilities() now reports the real
MAX_RESPONSE via snprintf() instead of a hardcoded "3072" literal.

The worst-case formula initially (incorrectly) multiplied the per-result
budget by DJ_RECENT_RESULT_COUNT (24 under JAYD_ENABLE_WIRELESS, the
backing array size) instead of the actual emission cap handleState()
enforces (STATE_MAX_EMITTED_RESULTS = 8, the `if(++resultCount == 8)
break` in its recent_results loop) - caught by the static_assert itself
failing during the real cm:esp32@1.8.3 -DJAYD_ENABLE_WIRELESS build (this
file is not part of any host CMake target, so only the real firmware
build exercises it). Fixed by introducing STATE_MAX_EMITTED_RESULTS as
the single source of truth, used by both the worst-case formula and
handleState()'s loop, so the two can no longer drift apart.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… fill, bounded boundary tolerance

Fourth-round fixes from focused re-review of eca7930 (4 material issues,
3 HIGH + 1 MEDIUM):

1. HIGH - rollback ownership was inferred from which steps were
   *submitted*, so a target deck already playing/synced before arming
   would have rollback incorrectly stop/release state the transition
   never introduced, and a manual mix override mid-crossfade could be
   silently overwritten by the MIX rollback phase restoring armedMix.
   DjAssistTransitionPlan now captures armedToPlaying/armedToSynced from
   the guard snapshot at arm() time (DjAssistEngine::armTransition).
   DjAssistBridge::nextRollbackPhase() is resignatured to take
   manualMixOccurred/syncOwnedByPlan/startDeckOwnedByPlan instead of raw
   submitted flags: it skips MIX whenever a manual mix occurred (even if
   crossfade was submitted), and skips SYNC/STOP_DECK whenever the
   corresponding step was already true at arm time (an idempotent no-op,
   not a plan-owned mutation). DjAssistController::tickRollback() computes
   ownership fresh every tick from the plan baseline and the durable
   mix-generation counter (see #2).

2. HIGH - recentResults is a bounded/evictable presentation ring, not a
   durable control channel; a burst of results could evict the final
   crossfade command's outcome or a manual-override marker before the
   controller polled it, stranding the transition or missing an override.
   Added DjSession::assistTrackCommand()/assistTrackedStatus(): a single
   controller-owned tracked-command slot, retained until consumed,
   updated from the same command-finish path loop() already uses (mutex-
   protected). Added DjSession::assistNonSystemMixGeneration(): a
   monotonic counter incremented only on non-system SET_MIX apply(),
   captured at arm() time and compared every guard build - replacing the
   removed DjAssistBridge::detectManualMixOverride() ring scan entirely.
   DjAssistSessionActuator (poll/pollCrossfade) and the crossfade
   final-command wait now use the durable tracked status exclusively.

3. HIGH - refreshCandidateTable() synchronously read one metadata record
   per DjSession::loop() tick, including full string/provenance decode,
   directly on the audio/session loop; resolveBoundary()'s phrase lookup
   rescanned up to 128 records every tick whenever no future phrase
   existed (a permanently-terminal track). Candidate-table hydration now
   runs entirely off the session loop: DjAssistController owns a genuine
   background Task (fillTaskTrampoline/fillWorkerStep) that reads one
   record at a time with the real (possibly SD-backed) I/O performed
   outside candidateMutex_, taking the lock only for the brief
   bookkeeping/array-write; staleness is re-checked under the lock both
   before and after the read to survive a concurrent library-generation
   reset. Main-thread scan state (scanCursor_/suggestionCount_) is
   separate from background-owned fill state and is reset only when
   candidateTableReady() reports a new generation. Added
   DjAssistPhraseCacheState/phraseCacheNeedsRescan()/updatePhraseCache()
   to cache a found-or-terminal phrase result per deck, invalidated only
   by an identity change, a different deck, a backward seek, or a
   previously-found boundary being passed - never by forward playback
   alone while terminal, closing the up-to-128-rescans-per-tick gap.

4. MEDIUM - resolveWaitBoundaryReached() fired as soon as
   currentFrame >= target with no tolerance, so a boundary could release
   arbitrarily late relative to the actual audio position. Added
   DjAssistBridge::evaluateBoundaryArrival(), reusing the established
   DJ_QUANTIZE_TOLERANCE_FRAMES policy: REACHED only at/within tolerance
   past the target, MISSED beyond it (never releases late - the caller
   re-captures the next boundary via resolveBoundary() and keeps
   waiting), NOT_YET otherwise.

Also removes DJ_ASSIST_FILL_RECORDS_PER_TICK, a leftover constant from
the old per-tick synchronous fill model that issue #3's background task
made dead code.

Tests: new/updated coverage in dj_assist_selfcheck.cpp (arm() captures
toDeck playing/sync baseline both true and false) and
dj_assist_bridge_selfcheck.cpp (nextRollbackPhase ownership-vs-submitted
distinction and manual-override-skips-mix; evaluateBoundaryArrival
not-yet/reached-at-tolerance/missed-beyond-tolerance;
phraseCacheNeedsRescan/updatePhraseCache empty/found-not-yet-passed/
terminal-not-rescanned/identity-or-deck-change/backward-seek). All 8
host CMake self-check targets (including DjAssistSelfCheck,
DjAssistBridgeSelfCheck) pass under C++11 -Wall -Wextra -Werror +
ASan/UBSan, and tests/wireless_ui_self_test.mjs (43 checks) passes
unchanged.

Firmware validated with isolated cm:esp32@1.8.3 (CircuitOS 1.7.1)
against Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1:
- default: 1,380,014 B flash (38%) / 47,160 B RAM (14%)
- wireless (-DJAYD_ENABLE_WIRELESS): 1,974,606 B flash (54%) / 52,856 B RAM (16%)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…uting, admission-time mix generation, generation-safe candidate fill, metadata-keyed phrase cache

Focused re-review round 4 fixes against b822c52 (6 issues, 4 HIGH / 2 MEDIUM):

1. Rollback ownership (HIGH): armTransition() now requires the target deck
   be stopped and sync-off at arm time (fails outright otherwise); ownership
   of the plan's own START_DECK/ENABLE_SYNC steps is set only the instant
   that exact step reaches DJ_COMMAND_APPLIED, never inferred from "step was
   submitted" + a baseline snapshot. guardOk() now also aborts with
   MANUAL_OVERRIDE if the target deck starts playing/syncing before the
   plan's own step has even been submitted. tickRollback() reads the two
   ownership flags directly instead of recomputing them.

2. Tracked command supersession (HIGH): DjCommandQueue::supersede() now
   returns a 3-state DjSupersedeOutcome (NONE/REPLACED/BLOCKED) instead of
   bool. A new admitAssistCommand() free function in DjSessionState.h
   centralizes admission: a superseded command's durable tracked slot is
   updated to SUPERSEDED immediately at admission time (not only when it
   later pops/finishes in loop()), so a normal transition step can no
   longer wait forever on a slot stuck reporting ACCEPTED for a command
   that will never run.

3. Manual mix admission race (HIGH): a still-queued non-system SET_MIX can
   no longer be silently replaced by a later system-origin SET_MIX -
   supersede() now returns BLOCKED for that case and admitAssistCommand()
   rejects the system command outright (DJ_COMMAND_ERROR_MIX_OVERRIDE_PENDING).
   nonSystemMixGeneration is latched the instant a non-system mix is itself
   admitted (replace or fresh push), not when it later applies, closing the
   window where a queued manual mix could be superseded by the next Assist
   ramp tick before the generation bump ever happened.

4. Candidate generation race (MEDIUM): fillWorkerStep()'s commit points and
   candidateTableReady() now re-read the live metadata generation counter
   fresh, immediately before taking the candidate lock, via the new pure
   predicate DjAssistBridge::candidateFillGenerationCurrent(). The previous
   check compared loadedGeneration_ against the same value it had just been
   set from earlier in the same call, so it could never detect a refresh
   that completed during the unlocked SD read/scan window.

5. Phrase cache metadata key (MEDIUM): DjAssistPhraseCacheState now also
   carries metadataGeneration/metadataState, and
   phraseCacheNeedsRescan()/updatePhraseCache() invalidate the cache when
   either changes - even for the same track identity/deck - since a
   metadata refresh can change grid/phrase data without changing which
   track is loaded.

6. Host integration test coverage: dj_session_self_test.cpp gained direct
   admitAssistCommand() coverage (durable-slot routing on supersede, both
   manual/system mix admission orderings, BLOCKED rejection, queue-full
   isolation, >ring-capacity result bursts not affecting the durable
   tracked slot). dj_assist_selfcheck.cpp gained arm-precondition,
   applied-mutation-ownership, pre-own-step divergence, and
   superseded-step-fails-transition tests. dj_assist_bridge_selfcheck.cpp
   gained candidateFillGenerationCurrent() and metadata-generation/state
   phrase-cache-invalidation tests.

Preserves the round-3 same-tick guard, boundary tolerance, background fill
worker, and 4 KiB response bound unchanged.

Validation:
- Host: ctest 8/8 passed (LibraryIndexSelfCheck, DjSessionSelfCheck,
  DjBeatEngineSelfCheck, MetadataIntegrationSelfCheck, DjAssistSelfCheck,
  DjAssistBridgeSelfCheck, DjRecordingSelfCheck, WirelessApiSelfCheck),
  DjAssist*/DjSession targets built and run under -Wall -Wextra -Werror
  plus ASan/UBSan.
- Browser: tests/wireless_ui_self_test.mjs, all checks green (unchanged
  this round).
- Firmware: cm:esp32@1.8.3 (CircuitOS 1.7.1) against JayD-Library
  78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configs link clean:
  default 1,828,386 B flash / 47,248 B RAM;
  -DJAYD_ENABLE_WIRELESS 1,975,454 B flash / 52,856 B RAM.

Auto DJ remains out of scope for this PR.
…tent generations, queue-wide origin scan

Round-4 focused re-review of 2b8f319 found 5 issues, all traced to
fragmented origin/intent handling. Replaces the prior state-based
divergence checks with monotonic non-system intent generation counters
compared unconditionally every guard tick.

1. LOCK_TEMPO and ENABLE_SYNC both call setSync(true), but only
   ENABLE_SYNC updated toDeckSyncOwnedByPlan, so the default plan
   (tempoLock=true) saw sync "active" with no recognized owner and
   self-aborted/leaked a stray sync enable on rollback. Ownership is now
   set from either step applying (single actual sync mutation is
   attributed correctly regardless of which of the two steps triggers
   it).

2. User play/sync/mix intent after arm was invisible once the plan's own
   step submitted, because guardOk() only checked raw state, which a
   system-origin step mutates to the same value. Added
   DjAssistIntentGenerations (mix/playing[]/sync[] counters), bumped in
   djBumpIntentGeneration() from admitAssistCommand() at admission time
   for non-DJ_ORIGIN_SYSTEM commands only. guardOk() compares these
   against generations captured at arm() every tick, independent of
   whether the plan's own step has submitted/applied.

3. DjCommandQueue::hasNonSystemPending()/removeSystemTargeting() now scan
   the whole bounded queue instead of only the tail slot, so a manual
   command "buried" behind an unrelated one still wins origin priority
   in both directions, and cancel purges every plan-owned queued entry
   (not just the most recent) through the durable outcome path.

4. DjSession::assistTrackEntry() now captures the metadata revision
   under the same metadataMutex acquisition as the trackByIndex() read,
   closing the previous check-then-lock gap between a generation probe
   and the actual read. DjAssistSessionBridge::candidateGenerationCurrent()
   collapses to a plain 2-arg equality check against that atomically
   captured revision.

5. Host tests now drive the real admitAssistCommand()/DjCommandQueue
   primitives shared with DjSession::submit() (pre-existing
   architecture, extended here) covering: LOCK_TEMPO-only ownership,
   post-submission intent divergence, queue-wide origin scan in both
   directions (system buried behind unrelated command purged; manual
   buried behind unrelated command still blocks a later system command),
   and manual-mix-during-crossfade abort driven through real command
   admission.

Preserves 478667a (Coach core), the boundary tolerance fix, background
candidate fill, and the 4 KiB response bound from prior rounds unchanged.

Host tests: DjSessionSelfCheck, DjAssistSelfCheck, DjAssistBridgeSelfCheck
(default + -DJAYD_ENABLE_WIRELESS), WirelessApiSelfCheck, wireless_ui_self_test.mjs
all pass under g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.

Full arduino-cli cm:esp32@1.8.3 build against JayD-Library@78a9f3d:
- default: 1,381,390 B flash (38%) / 47,160 B RAM (14%)
- wireless: 1,481,442 B flash (41%) / 52,768 B RAM (16%)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ource-deck sync, close candidate-generation race, add real controller integration tests

Round-5 review fixes against 8d387ce (4 issues: 1 HIGH, 3 MEDIUM):

1. guardOk() now relinquishes toDeckStartOwnedByPlan/toDeckSyncOwnedByPlan
   the instant it detects a user divergence on that exact property, before
   failing - a user re-asserting play/sync on the target deck after the
   plan's own step already applied no longer causes rollback to stop/
   release the user's own newer intent. guardOk() is now non-const to
   support this.

2. guardOk() also compares the SOURCE deck's (fromDeck) sync intent
   generation every tick (previously unguarded) - a non-system sync
   command on the outgoing deck is a genuine manual override and now
   aborts the transition instead of silently continuing past it. There is
   no rollback ownership for fromDeck sync (the plan never touches it), so
   this is a plain abort with no additional ownership bookkeeping.

3. Closed the remaining candidate-generation check-then-lock race:
   DjSession::libraryGeneration is now std::atomic<uint32_t> and is bumped
   BEFORE refreshLibraryMetadata()/invalidateLibraryMetadata() mutate the
   reader (still inside metadataMutex for sequencing, not required for the
   lock-free load itself). assistLibraryGeneration() is now a lock-free
   read, safe to call a second time from inside DjAssistController's own
   candidateMutex_ critical section with no lock-ordering/deadlock risk.
   assistTrackEntry() captures its revision under the SAME metadataMutex
   acquisition as the entry read (not a separate probe). fillWorkerStep()'s
   commit path re-checks both the entry's captured revision AND a fresh
   in-lock live-generation reload AND fillCursor_==cursor before writing,
   discarding (not committing) any record whose generation moved in any of
   these windows; candidateTableReady() re-loads the live generation inside
   its own lock immediately before deciding readiness.

4. Added DjAssistSessionPort (src/DjAssist/DjAssistSessionPort.h): the
   Arduino-free interface DjAssistController now depends on instead of a
   concrete DjSession - DjSession implements it unchanged in behavior.
   This lets the real, unmodified DjAssistController.cpp/DjAssistEngine.cpp
   be exercised by a new host test, DjAssistIntegrationSelfCheck
   (tests/dj_assist_integration_selfcheck.cpp), against a fake
   DjAssistSessionPort whose command admission reuses the exact real,
   shared admitAssistCommand()/DjCommandQueue/DjCommandResults/
   DjAssistTrackedCommand/DjAssistIntentGenerations primitives DjSession
   itself uses - not a parallel reimplementation. Seven scenarios, all
   passing under g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined:
   full default transition (arm->boundary->tempo-lock->sync->crossfade->
   stop->release->complete), cancel/rollback restoring only plan-owned
   state, user play-divergence relinquishing rollback ownership (issue #1),
   source-deck sync divergence aborting the transition (issue #2), manual
   mix mid-crossfade aborting with rollback skipping the mix phase,
   queue-wide supersede/removal through the real admission coordinator, and
   the candidate-fill generation race (issue #3) being caught and
   discarded. Host stubs (tests/host_stubs/{Arduino.h,Sync/Mutex.h,
   Util/Task.h,host_arduino_shim.cpp}) provide the minimal Arduino/
   CircuitOS surface DjAssistController.cpp needs (micros/delay/ps_malloc,
   Mutex, Task) without pulling in real ESP32 headers.

CMakeLists.txt: added the DjAssistIntegrationSelfCheck target (same
-Wall -Wextra -Werror + conditional ASan/UBSan treatment as the other
DjAssist self-checks), building the real DjAssistController.cpp/
DjAssistEngine.cpp/DjAssistScoring.cpp/DjAssistSessionBridge.cpp against
the new test and host stubs (BEFORE on the include path so the stubs win
over any real CircuitOS headers on a fully-configured machine).

Preserves all prior approved commits unamended (boundary tolerance,
4 KiB response bound, durable tracked-slot routing, admission-time intent
generations, queue-wide origin-priority scan, LOCK_TEMPO/ENABLE_SYNC
ownership unification, phrase cache metadata-revision keying).

Testing:
- DjAssistSelfCheck, DjAssistBridgeSelfCheck, DjSessionSelfCheck,
  DjAssistIntegrationSelfCheck, LibraryIndexSelfCheck, DjBeatEngineSelfCheck,
  MetadataIntegrationSelfCheck, WirelessApiSelfCheck: all pass via CMake +
  ctest under ASan/UBSan.
- Full isolated arduino-cli build against JayD-Library 78a9f3d
  (cm:esp32@1.8.3 / CircuitOS 1.7.1):
    default:  1,384,114 B flash / 47,160 B RAM (round-4: 1,381,390 / 47,160)
    wireless: 1,485,306 B flash / 52,768 B RAM (round-4: 1,481,442 / 52,768)

Auto DJ remains blocked/out of scope for this PR.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… TOCTOU, shutdown race

Fixes 4 material issues from focused re-review of 4292807:

1. HIGH rollback ownership: reconcileOwnershipOnDivergence() replaces the
   early-return-per-property guard chain with a single reconciliation pass
   that independently clears play/sync ownership together, so a user
   diverging both properties in one action no longer leaves stale rollback
   ownership on the un-checked property. DjAssistController::tickRollback()
   now calls this every tick using live intent generations, so a user
   re-asserting play/sync AFTER cancel/failure (once guardOk() has stopped
   running) is still caught before rollback acts.

2. HIGH candidate freshness: added a dedicated DjSession::metadataRevision
   atomic, distinct from the externally-supplied semantic libraryGeneration.
   It is bumped before every reader mutation/invalidation/shutdown-close,
   so a same-generation sidecar replacement or loss/reopen cycle that lands
   back on the same libraryGeneration is still detected. Renamed the assist
   port accessor assistLibraryGeneration() -> assistMetadataRevision()
   throughout (DjAssistSessionPort, DjSession, DjAssistController, test
   fakes) since it was the only consumer.

3. HIGH readiness/consumption TOCTOU: collapsed the separate
   candidateTableReady() precheck, the revision-keyed scan-state reset, and
   the scanTick() scan into a single candidateMutex_ critical section in
   tickSuggestions(), so a fill-worker refresh landing between the old
   independent lock acquisitions can no longer expose a stale suggestion
   list. candidateTableReady() is preserved for tests/diagnostics but no
   longer gates tickSuggestions(); as a result Coach advice now updates
   every tick rather than being incidentally skipped when the candidate
   table wasn't ready.

4. HIGH shutdown race: DjSession::shutdown() now calls assistController.end()
   (blocking, idempotent) before touching metadataReader, and marks
   metadataRevision++/metadataInitialized=false/metadataReaderStatus=Missing
   under metadataMutex before metadataReader.close(), so the background
   candidate-fill worker cannot observe a closed reader.

Added host tests to tests/dj_assist_integration_selfcheck.cpp:
- testSimultaneousPlaySyncDivergenceRelinquishesBothOwnership
- testUserIntentAfterCancelRelinquishesOwnershipDuringRollback
- testSuggestionsNeverStaleAcrossMetadataRevisionRefresh
- testControllerEndMakesFurtherWorkerCallsSafeNoOps (documents the
  achievable no-op-after-end() invariant; the host Task.h stub never
  spawns real threads, so a literal join-timing race isn't reproducible
  without introducing nondeterminism into the other deterministic cases)

All 9 host self-check targets pass under -std=c++11 -Wall -Wextra -Werror
-fsanitize=address,undefined. Full arduino-cli builds (cm:esp32@1.8.3,
CircuitOS 1.7.1) against JayD-Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1
succeed for both default (1,384,250 B flash / 47,160 B RAM) and
-DJAYD_ENABLE_WIRELESS (1,485,582 B flash / 52,768 B RAM) configurations.

DjSession.cpp is not part of any host test target (needs real
AudioLib/FS.h/SD.h/LoopManager), so the metadataRevision bump points and
shutdown reordering are validated by code review plus the full firmware
build rather than a host unit test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…close TOCTOU test gap, add threaded shutdown-race coverage

Round-7 fixes from focused re-review of ccca174:

1. HIGH shutdown deadlock on fill-task launch failure: CircuitOS's
   Task::start() sets stopped=false unconditionally before xTaskCreate(),
   but on failure only logs and never restores stopped, so
   Task::stop(true)'s wait-for-stopped loop deadlocks forever. Bundled
   CircuitOS must not be patched. Replace Util/Task usage in
   DjAssistController with a new, locally-owned DjAssistFillWorker:
     - begin() reports launch success/failure honestly (observable
       launched()/entered()/exited() state), so a failed launch leaves
       the controller disabled and end() returns immediately instead of
       blocking.
     - Firmware path uses raw FreeRTOS xTaskCreate/self-vTaskDelete
       directly (same priority/affinity as before), never touching
       CircuitOS's own Task class.
     - Host path (guarded by new JAYD_ASSIST_HOST_TASK_SEAM macro, since
       -DARDUINO is applied at directory scope to all targets including
       host tests) uses std::thread; a useManualSteppingForTest flag
       (default true) preserves exact byte-for-byte behavior of all
       pre-existing deterministic tests that drive fillWorkerStep()
       directly via friend access.
     - forceLaunchFailureForTest hook deterministically simulates a
       launch failure for the new no-hang regression test.
   New tests: launch-failure leaves controller disabled and end()
   returns immediately (no hang); real background thread enters, runs,
   and exits cleanly on end(); end() blocks until an in-flight reader
   call is released, then completes only after join (proves no
   close-before-exit race).

2. MEDIUM candidate TOCTOU test didn't exercise the real gap: it bumped
   metadataRevision before calling tick(), which even the old code
   would have handled correctly. Add a genuine second, defense-in-depth
   live-revision recheck inside tickSuggestions(), under the same
   candidateMutex_ critical section, immediately before the actual
   DjAssistScoring::scanTick() consume call. Add a test-only hook
   (testHookBeforeScanConsume_) invoked at exactly that point so a test
   can inject a revision bump precisely between "readiness decided" and
   "table consumed" -- a window a pre-tick bump cannot reach. New test
   proves suggestions are discarded when revision changes in that exact
   window, and fails if the recheck is ever removed.

3. MEDIUM shutdown test's Task stub never ran real concurrency: the
   host Util/Task.h stub was a pure no-op, so the prior shutdown test
   only proved "safe no-op after end()". DjAssistFillWorker's real
   std::thread path (via JAYD_ASSIST_HOST_TASK_SEAM) now gives host
   tests genuine concurrency to exercise.

Incidental supporting fixes required for the above to be valid/safe
under real threading (not separately requested, but necessary):
   - tests/host_stubs/Sync/Mutex.h was a no-op stub with no real
     exclusion; wrap std::mutex so candidateMutex_ provides genuine
     mutual exclusion once a background thread exists.
   - tests/host_stubs/host_arduino_shim.cpp's delay() was a no-op,
     which would have made DjAssistFillWorker::run()'s background loop
     busy-spin at 100% CPU on host; use a real sleep. Confirmed by grep
     that delay() is only reachable from DjAssistFillWorker.cpp's
     host-thread path, so this has zero effect on any pre-existing
     single-threaded test.

Build: added DjAssistFillWorker.cpp to DjAssistIntegrationSelfCheck;
added JAYD_ASSIST_HOST_TASK_SEAM + Threads::Threads; added a new
DjAssistIntegrationSelfCheckTSan CTest target (ThreadSanitizer, same
sources, Clang/GNU only) validating no data races under real
concurrency.

Verified: all 10 host CTest targets pass (LibraryIndexSelfCheck,
DjSessionSelfCheck, DjBeatEngineSelfCheck, MetadataIntegrationSelfCheck,
DjAssistSelfCheck, DjAssistBridgeSelfCheck, DjAssistIntegrationSelfCheck
[ASan/UBSan], DjAssistIntegrationSelfCheckTSan, DjRecordingSelfCheck,
WirelessApiSelfCheck). Full isolated cm:esp32@1.8.3 firmware build
against Library 78a9f3d7e52fd151dc50872812ae2ed1da308af1: default
1,384,774 B flash (38%) / 47,168 B RAM (14%); wireless 1,486,114 B
flash (41%) / 52,776 B RAM (16%) -- both about +530 B flash / +8 B RAM
vs round-6 baseline, consistent with the new fill-worker code. Auto DJ
remains out of scope.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant