Coach / one-shot-transition assist layer (Coach advice, bounded track suggestions, physical + browser Assist bank) - #44
Open
djdefi wants to merge 52 commits into
Open
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Deferrals
reason_flagsbitmask 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/c03ecc4and earlier history untouched:2b544b4— Mix screen #1 stale guard (HIGH):DjSession::loop()applied commands, thentickAssist()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 beforetickAssist().c863d79— Playback #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:resolveBoundary()'s "next boundary" hint is true almost every tick, soWAIT_BOUNDARYwas advancing immediately instead of waiting. AddedDjAssistBoundaryHint::reached, set only by a new capture-once-then-compareresolveWaitBoundaryReached(); the engine now gates strictly onreached.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).APPLIEDonce confirmed (not from elapsed-time math); addedDjAssistGuardSnapshot::manualMixOverride(bounded scan for a non-systemSET_MIXsince arming), checked unconditionally inguardOk()so a manual override mid-ramp aborts instead of being silently overwritten.refreshCandidateTable()'s fill loop was reusing the 256-record scoring-scan budget for genuine (possibly SD-backed) metadata reads. Split out a separateDJ_ASSIST_FILL_RECORDS_PER_TICK = 1.eca7930— Intro screen #6 response buffer (MEDIUM): bumpedMAX_RESPONSE3072→4096 and added aconstexpr/static_assert-checked worst-case formula for/api/v2/stateso a future capacity change fails the build instead of silently returningresponse_too_large. (The formula's first draft used the wrong bound —DJ_RECENT_RESULT_COUNT, the backing array size — instead ofhandleState()'s actual 8-result emission cap; caught by thestatic_assertitself 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 provinghasPhrase/hasDownbeatalone must not advance), a manual-override-during-crossfade regression, and fulldj_assist_bridge_selfcheck.cppcoverage 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
eca7930found 4 more material issues (3 HIGH, 1 MEDIUM). Fixed inb822c52:DjAssistTransitionPlannow capturesarmedToPlaying/armedToSyncedfrom the guard snapshot atarm()time;nextRollbackPhase()takesmanualMixOccurred/syncOwnedByPlan/startDeckOwnedByPlaninstead 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).recentResultsis 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. AddedDjSession::assistTrackCommand()/assistTrackedStatus()(a single controller-owned tracked-command slot, retained until consumed) andDjSession::assistNonSystemMixGeneration()(a monotonic counter incremented only on non-systemSET_MIXapply, captured at arm time). Replaces the removeddetectManualMixOverride()ring scan entirely.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 backgroundTask, 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.resolveWaitBoundaryReached()fired as soon ascurrentFrame >= targetwith no tolerance, so a boundary could release arbitrarily late. AddedevaluateBoundaryArrival(), reusing the establishedDJ_QUANTIZE_TOLERANCE_FRAMESpolicy: 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;nextRollbackPhaseownership-vs-submitted distinction and manual-override-skips-mix;evaluateBoundaryArrivalnot-yet/reached-at-tolerance/missed-beyond-tolerance; phrase-cache empty/found-not-yet-passed/terminal-not-rescanned/identity-or-deck-change/backward-seek. Also removedDJ_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.arduino-clibuild,cm:esp32@1.8.3, Library78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHAb822c52:-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_WIRELESSenabled (round 1): aDjSnapshot::recordingfield that doesn't exist (real field isrecordingInfo.state), and an odr-use link failure onSongList'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
b822c52found 6 more issues (4 HIGH, 2 MEDIUM), all fixed in2b8f319;478667a/c03ecc4/c863d79/eca7930/b822c52untouched: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/toDeckSyncOwnedByPlanreplace the old baseline fields and are set only the instant the plan's own START_DECK/ENABLE_SYNC step reachesDJ_COMMAND_APPLIED— never inferred from submission.guardOk()also aborts withMANUAL_OVERRIDEif the target starts playing/syncing before the plan's own step is even submitted.ACCEPTEDforever.DjCommandQueue::supersede()now returns a 3-stateDjSupersedeOutcome; a newadmitAssistCommand()free function routes every superseded id through the tracked slot at admission time, immediately.SET_MIXcould be superseded by the next Assist ramp command before it ever applied, silently erasing user intent.supersede()now returnsBLOCKED(rejecting the system command outright) whenever a system-origin mix would replace a still-queued non-system mix;nonSystemMixGenerationis latched the instant a non-system mix is admitted (replace or fresh push), not when it applies.fillWorkerStep()'s commit-time check comparedloadedGeneration_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 andcandidateTableReady()now re-read the live generation fresh, immediately before the lock, viaDjAssistBridge::candidateFillGenerationCurrent().DjAssistPhraseCacheStatenow also keys onmetadataGeneration/metadataState.admitAssistCommand()coverage indj_session_self_test.cppagainst the realDjCommandQueue/DjCommandResults/DjAssistTrackedCommandtypes (supersede→durable routing, both manual/system mix admission orderings,BLOCKEDrejection, queue-full isolation from tracked slot, >ring-capacity result bursts not affecting the durable slot), plus newdj_assist_selfcheck.cpp/dj_assist_bridge_selfcheck.cppcoverage 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-clibuild,cm:esp32@1.8.3, Library78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA2b8f319:-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-cliinvocation, not the firmware — passing--build-property build.extra_flags=-DJAYD_ENABLE_WIRELESS=1silently replaced the board's defaultbuild.extra_flags=-O2instead 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=1together; verified via-vthat-O2reaches 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
2b8f319found 5 more issues (3 HIGH, 2 MEDIUM), all traced to fragmented origin/intent handling rather than another tail-only guard. Fixed together in8d387ce;478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319untouched:LOCK_TEMPOandENABLE_SYNCboth callsetSync(true), but onlyENABLE_SYNCupdatedtoDeckSyncOwnedByPlan, 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 reachingDJ_COMMAND_APPLIED(both resolve to the same idempotent mutation, so attribution is correct regardless of which one fires first).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 withDjAssistIntentGenerations(mix/playing[]/sync[]monotonic counters), bumped in a newdjBumpIntentGeneration()called fromadmitAssistCommand()at admission, only for non-DJ_ORIGIN_SYSTEMcommands.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.hasNonSystemPending()/removeSystemTargeting()previously only reached the slotsupersede()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.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 samemetadataMutexacquisition as thetrackByIndex()read itself;DjAssistBridge::candidateGenerationCurrent()collapses to a plain 2-arg equality check against that atomically-captured revision — no separate probe.admitAssistCommand()is the same free functionDjSession::submit()calls under its realcommandMutex, 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 (noENABLE_SYNCin 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
DjSessionSelfCheck,DjAssistSelfCheck,DjAssistBridgeSelfCheck,WirelessApiSelfCheck, default +-DJAYD_ENABLE_WIRELESS) all pass underg++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.node tests/wireless_ui_self_test.mjs: all checks pass (unchanged this round).arduino-clibuild,cm:esp32@1.8.3, Library78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA8d387ce:-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
8d387cefound 4 more issues (1 HIGH, 3 MEDIUM). Fixed in4292807;478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319/8d387ceuntouched:START_DECK/LOCK_TEMPO/ENABLE_SYNCstep applied, a later userSET_PLAYING(true)/SET_SYNC(true)on the target deck correctly failed the transition (intent-generation divergence), but ownership stayedtrue, so rollback would then stop/release the user's own newer intent.guardOk()now relinquishestoDeckStartOwnedByPlan/toDeckSyncOwnedByPlanfor the exact property that diverged, immediately before failing — rollback can then never touch it.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 forfromDecksync (the plan never touches it), so this is a plain abort with no additional bookkeeping.libraryGenerationis nowstd::atomic<uint32_t>, bumped beforerefreshLibraryMetadata()/invalidateLibraryMetadata()mutate the reader;assistLibraryGeneration()is now a lock-free read, safe to call from insideDjAssistController's owncandidateMutex_critical section with no lock-ordering risk againstmetadataMutex.assistTrackEntry()still captures its revision under the samemetadataMutexacquisition as the read.fillWorkerStep()'s commit path now re-checks the entry's captured revision and a fresh in-lock live-generation reload andfillCursor_ == cursorimmediately 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.DjAssistSessionPort(src/DjAssist/DjAssistSessionPort.h), the Arduino-free interfaceDjAssistControllernow depends on instead of a concreteDjSession(DjSessionimplements it, unchanged behavior). This lets the real, unmodifiedDjAssistController.cpp/DjAssistEngine.cppbe driven by a new host test,DjAssistIntegrationSelfCheck(tests/dj_assist_integration_selfcheck.cpp), against a fake port whose command admission reuses the exact real, sharedadmitAssistCommand()/DjCommandQueue/DjCommandResults/DjAssistTrackedCommand/DjAssistIntentGenerationsprimitivesDjSessionitself 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 primitivesDjAssistController.cppneeds (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 underg++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.arduino-clibuild,cm:esp32@1.8.3, Library78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHA4292807:-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
4292807found 4 more issues (3 HIGH, 1 MEDIUM). Fixed inccca174;478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319/8d387ce/4292807untouched: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, sinceguardOk()only runs whilemode_isARMED/RUNNING. Replaced withreconcileOwnershipOnDivergence(), a single pass that independently ANDs each ownership flag against its own generation match with no early return, called unconditionally at the top ofguardOk()and at the start of everyDjAssistController::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.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 dedicatedstd::atomic<uint32_t> metadataRevisiononDjSession, bumped before every reader mutation/invalidation/shutdown-close regardless of the external generation value; renamed the assist port accessorassistLibraryGeneration()->assistMetadataRevision()throughout (it wasDjAssistController's only consumer) so the candidate worker/table/cache key off this new revision instead.tick()calledcandidateTableReady()(its own lock/unlock), decided the revision-based reset outside any lock, thentickSuggestions()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 thescanTick()scan into onecandidateMutex_critical section insidetickSuggestions(); 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.DjSession::shutdown()closedmetadataReaderwhile the background candidate-fill task could still be running, racing a use-after-close.shutdown()now calls the already-idempotent, blockingassistController.end()before touchingmetadataReader, and marksmetadataRevision++/metadataInitialized=false/metadataReaderStatus=MissingundermetadataMutexbeforemetadataReader.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
DjAssistIntegrationSelfCheckcoverage: simultaneous play+sync divergence relinquishing both ownership flags together (#1), a user re-asserting play/sync aftercancelTransition()still being caught bytickRollback()'s fresh reconciliation (#1), suggestions never going stale across a metadata-revision refresh landing between readiness and scan (#2/#3), andDjAssistController::end()making further worker calls safe no-ops (#4's achievable half — the hostTask.hstub 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.cppis not part of any host test target (needs realAudioLib/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 underg++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined.arduino-clibuild,cm:esp32@1.8.3(CircuitOS 1.7.1), Library78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHAccca174:-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
ccca174found a real production bug plus two test gaps that let it slip through. Fixed inebdd3a9;478667a/c03ecc4/c863d79/eca7930/b822c52/2b8f319/8d387ce/4292807/ccca174untouched:Task::start()setsstopped=falseunconditionally before callingxTaskCreate(); on launch failure it only logs and never restoresstopped, and only the task's own trampoline (which never runs if creation failed) ever setsstopped=true.Task::stop(true)'swhile(!stopped) delay(1);therefore deadlocks forever, andDjAssistController::end()calls exactly that. Patching bundled CircuitOS was out of scope. ReplacedUtil/Taskusage with a new, locally-ownedDjAssistFillWorker:begin()honestly reports launch success/failure via observablelaunched()/entered()/exited()state, so a failed launch leaves the controller disabled andend()returns immediately instead of blocking; the firmware path uses raw FreeRTOSxTaskCreate/self-vTaskDeletedirectly (same priority/affinity as before, never touching CircuitOS's ownTaskclass); the host path (newJAYD_ASSIST_HOST_TASK_SEAMmacro, needed because-DARDUINOis applied at directory scope to all targets including host tests) usesstd::thread, with auseManualSteppingForTestflag (defaulttrue) preserving byte-for-byte behavior of every pre-existing deterministic test that drivesfillWorkerStep()directly via friend access.metadataRevisionbefore callingtick(), 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 insidetickSuggestions(), under the samecandidateMutex_critical section, immediately before the realDjAssistScoring::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.Taskstub never ran real concurrency (MEDIUM): the hostUtil/Task.hstub was a pure no-op, so the round-6 shutdown test only proved "safe no-op afterend()" rather than any genuine ordering guarantee.DjAssistFillWorker's realstd::threadpath on host now gives tests actual concurrency to exercise.New
DjAssistIntegrationSelfChecktests: launch-failure leaves the controller disabled andend()returns immediately (no hang); a real background thread enters, runs, and exits cleanly onend();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.hwas a no-op stub with no real exclusion — now wrapsstd::mutexsocandidateMutex_provides genuine mutual exclusion once a background thread exists;tests/host_stubs/host_arduino_shim.cpp'sdelay()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 thatdelay()is reachable only fromDjAssistFillWorker.cpp's host-thread path, so this has zero effect on any pre-existing single-threaded test).Added a new
DjAssistIntegrationSelfCheckTSanCTest 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 CircuitOSTask.Validation
ctest: all 10 targets pass —LibraryIndexSelfCheck,DjSessionSelfCheck,DjBeatEngineSelfCheck,MetadataIntegrationSelfCheck,DjAssistSelfCheck,DjAssistBridgeSelfCheck,DjAssistIntegrationSelfCheck(ASan/UBSan),DjAssistIntegrationSelfCheckTSan(new, ThreadSanitizer),DjRecordingSelfCheck,WirelessApiSelfCheck— underg++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined(or-fsanitize=threadfor the TSan target).arduino-clibuild,cm:esp32@1.8.3(CircuitOS 1.7.1), Library78a9f3d7e52fd151dc50872812ae2ed1da308af1, both configurations, at final SHAebdd3a9:-DJAYD_ENABLE_WIRELESS: 1,486,114 B flash (41%) / 52,776 B RAM (16%)Auto DJ remains out of scope for this PR.
Dependency revalidation: Library pin updated to
35b4f80Coach
ebdd3a9(round-7 head, unchanged, no source edits needed) revalidated against the canonicalJayD-LibraryPR #22 head moving78a9f3d→35b4f80a47f5e5eef029e0c57db76699a076df4a(confirmed viagit rev-parse HEADon the pinned checkout).Library diff in this range (
OutputAAC.cpp/OutputWAV.cpp/SourceAAC.cpp/SourceMP3.cpp/SourceWAV.cpp/SDScheduler.cpp/.h+ tests) reworksSDScheduler::addJobfrom a transient unmergedbool addJob(SDJob*)(only ever present at78a9f3d) back to the master-compatiblevoid addJob(SDJob*), adding a newbool tryAddJob(SDJob*).grepconfirms no Coach/DjAssistsource referencesSDScheduler/addJobat all — zero surface overlap, no source change required.DjRecordingSelfCheckonly includesWavHeader.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.arduino-clibuild,cm:esp32@1.8.3(CircuitOS 1.7.1), Library35b4f80a47f5e5eef029e0c57db76699a076df4a, both configurations, still at SHAebdd3a9:-DJAYD_ENABLE_WIRELESS: 1,486,290 B flash (41%) / 52,776 B RAM (16%)78a9f3dbaseline of 1,384,774 B / 1,486,114 B, matching the smalltryAddJobaddition)These figures are cross-confirmed by an independent isolated rebuild (clean environment, singular
--librarycheckout) 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 unrelatedarduino-clicompile 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.