Integrate deck-rate + recording-core final commits onto AAC-timing base (for JayD-Firmware) - #22
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b2cfc57-c6c7-44ce-95c9-72368d409ca8
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> (cherry picked from commit 2afe71c)
Add explicit recording status and errors, detect short/failed SD writes without blocking playback, and finalize WAV headers through the existing scheduler before closing files.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 6caffb5)
Keep live recorder setup on the audio task and retry finalization queue pressure before reporting an invalid file.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 5230446)
Drive initial WAV seek and header writes from the service state machine without blocking mixer output, and make SD scheduler enqueue genuinely nonblocking. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit a4e0fdb)
The deck-rate branch (2afe71c) replaced SpeedModifier's float remainder accumulator with a Q16.16 fixed-point sourcePosition, but the AAC-timing branch (ec46912) had already added reset(), which zeroed the old remainder field. Cherry-picking both onto the same base left reset() referencing a field that no longer exists, since the two edits touched non-overlapping hunks of the same file. Update reset() to zero sourcePosition instead, so MixSystem::_seekChannel's post-seek speed reset compiles and correctly rewinds the resampler position. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
No existing test exercised reset(), which is exactly the glue the deck-rate/AAC-timing cherry-pick conflict broke. Cover the contract MixSystem relies on: a post-seek reset() must drop the stale resampling position but must not silently change the DJ's requested or ramped rate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Independent review of bb4c945 found that SourceAAC ignored addJob()'s return value at its read-job (addReadJob) and seek (seekSourceFrame) call sites. When the queue is full, addJob() deletes the job and returns false, but SourceAAC still set readJobPending = true / mutated seek state as if the job had been queued. readResult then never arrives, so processReadJob(true)/open() spin forever in their busy-wait, and seek silently "succeeds" while the file position never moves. Queue capacity is only 8 vs. two AAC decks plus OutputWAV, so this is reachable under load. Audited every SDScheduler::addJob() caller and fixed the same unchecked-return hang/leak pattern everywhere it appeared: - SourceAAC::addReadJob: only set readJobPending on a successful enqueue; free the allocated buffer on failure instead of leaking it. - SourceAAC::seekSourceFrame: enqueue the seek job first and bail out with false (untouched elapsed/decoded state, no discarded in-flight read) if it can't be queued, instead of committing to the new position regardless. - SourceMP3::addReadJob / SourceWAV::addReadJob: same fix as SourceAAC's read path. - OutputAAC::addWriteJob: leave the buffer in freeBuffers on a failed enqueue instead of stranding it in a permanently-pending state (which could starve the encoder's free-buffer wait loops. OutputWAV's two addJob call sites already checked the return value correctly and needed no changes. Added tests/JobQueueContractSelfCheck.cpp: a structural scan proving every real Sched.addJob() call site checks the return value, plus a deterministic functional harness (mirroring the fixed addReadJob/processReadJob/seek state machine against an injectable fake scheduler) covering queue-full rejection + recovery for both read and seek jobs, and a bounded close/teardown wait. The harness is also run against a harness mirroring the pre-fix pattern to prove the check discriminates the reported defect (fails on old behavior, passes on fixed behavior). Re-ran all host self-checks (adts_timing_self_check, SpeedModifierSelfCheck, wav_header_selfcheck, and the new JobQueueContractSelfCheck) with -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined -- all pass. Rebuilt the Arduino consumer firmware (cm:esp32:jayd): 1,026,806 bytes flash / 44,456 bytes static RAM, unchanged from the prior bb4c945 build (pure logic fix, no data size change). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> EOF )
|
Fixed the critical `SDScheduler::addJob()` bool-contract gap flagged in review, in a new commit `78a9f3d` (does not amend `bb4c945`/`5aa22cc`). Bug: recording-core changed `addJob()` to return `bool` (queue-full -> delete job, return `false`), but `SourceAAC` ignored the return value at its read-job (`addReadJob`) and seek (`seekSourceFrame`) call sites. On a full queue (capacity 8, contended by two decks + recording), `readJobPending` was still set true with `readResult` never arriving -> `processReadJob(true)`/`open()` spin forever. Seek silently "succeeded" while the file position never moved. Fix: audited every `Sched.addJob()` caller and applied the same pattern everywhere:
New test: `tests/JobQueueContractSelfCheck.cpp` - a structural scan proving every real `Sched.addJob()` call site checks the return value, plus a deterministic harness (mirroring the fixed state machine against an injectable fake scheduler) covering queue-full rejection + recovery for read and seek jobs, and bounded close/teardown. Verified the check fails against the pre-fix source/pattern before passing on the fix. Validation: all 4 host self-checks (adts_timing, SpeedModifier, wav_header, JobQueueContract) pass with `-std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined`. Rebuilt the Arduino consumer firmware (`cm:esp32:jayd`): 1,026,806 bytes flash / 44,456 bytes static RAM - unchanged from `bb4c945` (pure logic fix, no size impact). No hardware available to smoke-test on-device. |
Preserve the scheduler contract used by existing decoders and encoders while keeping OutputWAV queue retries nonblocking. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit ac6ba80)
PR CircuitMess#20 advanced to ac6ba80 ("Scope nonblocking SD enqueue to recording"), reverting SDScheduler::addJob() back to the legacy blocking void contract and adding a new nonblocking bool tryAddJob() scoped exclusively to OutputWAV's real-time recording write/finalize path. That makes the earlier 78a9f3d fix (which retrofitted bool-return checking onto every addJob() caller, matching an intermediate nonblocking-everywhere revision of CircuitMess#20) obsolete and non-compiling against the restored void signature. Revert SourceAAC::addReadJob/seekSourceFrame, SourceMP3::addReadJob, SourceWAV::addReadJob, and OutputAAC::addWriteJob to plain blocking Sched.addJob(...) calls with no return-value check, matching the restored contract. OutputWAV's two call sites already correctly use the new Sched.tryAddJob(...) (renamed by ac6ba80 from its prior addJob() bool usage) with the return value checked; no changes needed there. Queue-ownership review: Sched is drained both by the main sketch loop (LoopManager::addListener(&Sched)) and, at specific synchronous wait points, directly by the calling thread (e.g. MixSystem open/openChannel busy-wait via Sched.loop(0) while awaiting isReadReady()). The audio task's blocking addJob() calls (SourceAAC/MP3/WAV read jobs, OutputAAC write jobs) are drained by a different, independent thread/task, so a full queue blocks that call only until the drain thread services it; no self-deadlock. OutputWAV's write/finalize path runs on the same real-time audio task and must not block it, which is exactly why tryAddJob()'s nonblocking, checked, retry-on-false contract remains scoped there. Rewrite tests/JobQueueContractSelfCheck.cpp for the new dual-API contract: structural checks assert SourceAAC/SourceMP3/SourceWAV/ OutputAAC call only the blocking Sched.addJob and never Sched.tryAddJob, OutputWAV calls only Sched.tryAddJob with every call site's return checked and never the blocking Sched.addJob, and SDScheduler.h declares both signatures. A functional harness (mirroring OutputWAV::addWriteJob) proves queue-full rejection under tryAddJob() still leaves the buffer retryable with no leak/false pending state, plus a "buggy" counterpart proving the check discriminates a regression of the original defect class. All four host self-checks (adts_timing_self_check, run-speed-modifier-self-check.sh, wav_header_selfcheck, JobQueueContractSelfCheck) pass with -Wall -Wextra -Werror -fsanitize=address,undefined. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Reconciled with #20's advanced head |
… firmware The wireless-bringup guarded firmware's MixScreen (remoteStatus()/ remoteUpdateMasterGain(), used by an HTTP status/control server gated behind -DJAYD_WIRELESS_BRINGUP) needs a coarse per-channel decoder status and a way to re-apply master I2S gain after an out-of-band volume change. Neither existed in PR CircuitMess#22. - SourceAAC gains an enum class Status { CLOSED, DATA, STARVED, END_OF_STREAM, FAILED } and getStatus(), tracked purely observationally at open/close/generate() transitions (construct-fail, decoder-init-fail, starved-before-first-frame, per-call DATA/END_OF_STREAM). No existing control flow (ADTS parsing, frame index, seek, EOF one-shot reset, decode-discard) is touched. - MixSystem::getChannelStatus(channel) wraps source[c]->getStatus(), following the same sourceMutex/cleanupRetiredSources() locking pattern already used by hasChannel(). - MixSystem::updateGain() extracts the existing inline i2s->setGain(0.4f*volumeLevel/255.0f) expression from the constructor into a callable method, and the constructor now calls it too. All 4 host self-checks re-run clean (-Wall -Wextra -Werror -fsanitize=address,undefined). Standard Arduino consumer build: 1,026,890 B flash / 44,456 B RAM (+56 B flash, +0 B RAM vs 35b4f80). Guarded JayD-Firmware build (-DJAYD_WIRELESS_BRINGUP) against this branch: 1,834,298 B flash / 54,968 B RAM, MixScreen.cpp compiles clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 285b0160f239f674a6b2adee858b9ce7b3cdd886)
Coordinator reported a concrete device defect: a deck paused before a
remoteLoad-triggered source replacement came back playing after the
swap, even though the request succeeded and the deck had been
deliberately paused.
Root cause: _openChannel() computed wasPaused up front but then decided
whether to resume with
if(replaceSource(channel, newSource) || !wasPaused) mixer->resumeChannel(channel);
Since replaceSource() returns true on any successful swap, the || made
every successful replacement resume the channel regardless of
wasPaused - the prior pause state was only ever consulted on failure.
Fix: perform the replacement unconditionally, then resume only if the
channel was not paused beforehand, independent of whether the
replacement succeeded or failed. This preserves pause state end to end:
a paused deck stays paused across a successful hot-swap, a playing deck
keeps playing, and a failed replacement leaves the deck exactly as it
was.
Adds OpenChannelPauseSelfCheck: a structural check on the shipped
_openChannel() text plus a functional harness mirroring its exact
pause/resume decision, covering paused/playing x success/failure, and a
negative case proving the pre-fix pattern would have been caught.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
What
Combines the three independently-reviewed library foundations JayD-Firmware needs, on one branch/SHA:
djdefi-aac-frame-timing(6750262) — already contains Fix Mixer clip() overflow bug; add per-channel hot-swap loading #19 mixer hot-swap fix (4ad5108) + Add frame-accurate AAC timing and seek #21's frame-accurate AAC timing/seek final fix.2afe71c(Q16.16 rate control, clean pick, no conflicts).6caffb5,5230446,a4e0fdb,ac6ba80; two co-authorship-only commits from that branch were empty after picking and were skipped, not applied as no-ops).All foundation commits are preserved as-is (
-xcherry-picks, unamended).Final SHA (superseded, see updates below):
35b4f80a47f5e5eef029e0c57db76699a076df4a.Conflict #1 — SpeedModifier::reset() vs. sourcePosition rewrite
Textually all cherry-picks applied clean via
git cherry-pick -x, but that hid a semantic break: deck-rate's rewrite ofSpeedModifierreplaced the old floatremainderaccumulator with a Q16.16sourcePosition, while AAC-timing'sreset()(added on a different branch, same base) still zeroedremainder. Since the two edits touched non-overlapping hunks, git merged them silently — the tree didn't build. Fixed in5aa22cc(reset()now zeroessourcePosition), plus one integration self-check inbb4c945covering the exact contract this broke: a post-seekreset()must drop stale resampling state but must not touch the requested/ramped rate.Conflict #2 — SDScheduler::addJob() bool contract vs. call sites (found by independent review after bb4c945)
Recording's rewrite of
SDScheduler::addJob()to returnbool(queue full → delete job, returnfalse, non-blockingxQueueSend(..., 0)) wasn't honored at every call site merged from the other branches.SourceAAC::addReadJobandSourceAAC::seekSourceFrameignored the return value: on a full queue (capacity 8, shared by two decks + recording), a rejected enqueue still leftreadJobPending = true(withreadResultnever arriving, soprocessReadJob(true)/open()busy-wait forever — watchdog risk) or silently committed a seek that was never actually queued.Fixed in
78a9f3d:SourceAAC/SourceMP3/SourceWAV's read-job andSourceAAC's seek call sites only committed state on a successful enqueue;OutputAAC::addWriteJobno longer stranded its buffer on a failed enqueue. Addedtests/JobQueueContractSelfCheck.cppcovering the fixed state machine (queue-full rejection + recovery, bounded close/teardown-no-hang), verified against the pre-fix regression.This revision (Conflict #2) has since been superseded by Conflict #3 below — #20 changed its own contract again upstream, and
78a9f3d's fix no longer matches what ships. It's kept here for lineage; do not treat it as the current call-site contract.Conflict #3 — #20 restored the legacy blocking addJob() contract; reconcile the #2 fix
#20 advanced past
a4e0fdbtoac6ba80("Scope nonblocking SD enqueue to recording"), which revertsSDScheduler::addJob()back to legacy blockingvoid(xQueueSend(..., portMAX_DELAY)) and adds a new, separatebool tryAddJob()that is non-blocking (xQueueSend(..., 0), delete-on-full) and used only byOutputWAV's real-time write/finalize path. This makes78a9f3d's universal bool-checking fix obsolete and non-compiling against the restoredvoidsignature.Reconciled in
35b4f80(new commit, does not amend78a9f3d/bb4c945/5aa22cc):ac6ba80cleanly onto the branch (no textual conflicts).SourceAAC::addReadJob/seekSourceFrame,SourceMP3::addReadJob,SourceWAV::addReadJob, andOutputAAC::addWriteJobback to plain blockingSched.addJob(...)calls with no return-value check, matching the restored contract.OutputWAV's two call sites already correctly use the renamedSched.tryAddJob(...)with the bool checked — untouched.Schedis drained both by the main sketch loop (LoopManager::addListener(&Sched)) and, at specific synchronous wait points, directly by the calling thread (e.g.MixSystem::open/openChannelbusy-waiting viaSched.loop(0)while awaitingisReadReady()). The audio task's blockingaddJob()calls are drained by a different, independent thread/task, so a full queue only delays that call until the drain thread services it — no self-deadlock found.OutputWAV's write/finalize path runs on that same real-time audio task and must not block it, which is exactly whytryAddJob()'s non-blocking, checked, retry-on-false contract remains scoped there.tests/JobQueueContractSelfCheck.cppfor the new dual-API contract: structural checks assert the decode/encode sources call only blockingSched.addJob()and neverSched.tryAddJob(),OutputWAVcalls onlySched.tryAddJob()(checked) and never the blockingSched.addJob(), andSDScheduler.hdeclares both signatures; a functional harness mirroringOutputWAV::addWriteJobproves queue-full rejection still leaves the buffer retryable with no leak/false-pending state, with a "buggy" counterpart proving the check discriminates a regression of the original defect class.Note on the
addJob()signature itself: relative to the transient, unmerged78a9f3dhead,SDScheduler::addJob()'s return type changed (bool→void). That is expected and correct —78a9f3d'sbool addJob()was never released or merged anywhere;35b4f80'svoid addJob()+bool tryAddJob()restores exact compatibility with upstream master/#20's real, merged-target design. No compatibility wrapper was added since no consumer of the transient78a9f3dsignature was ever found (verified against the guarded firmware and a fresh clone of upstreamCircuitMess/JayD-Firmware: neither callsSDSchedulerdirectly).No other conflicts, refactors, or new abstractions were needed —
openChannelhot-swap,MixSystem's recording state machine, andSDScheduler's job queue otherwise merged cleanly across the three branches' non-overlapping edits toMixSystem.cpp/.h.Guarded-firmware compatibility — SourceAAC::Status / MixSystem::getChannelStatus / updateGain
A separate, wireless-bringup-guarded fork of
JayD-Firmware(-DJAYD_WIRELESS_BRINGUP, an HTTP status/control server) has aMixScreenthat callsMixSystem::getChannelStatus()/updateGain()and switches onSourceAAC::Status, none of which existed on this branch.getVolume()/getMix()(also referenced there) already existed from #19's integrated hot-swap work.Added in
ad85f45(new commit):SourceAAC::Status(CLOSED/DATA/STARVED/END_OF_STREAM/FAILED) +getStatus()— purely observational, set at existing open/construct-fail/decoder-init-fail/generate() decision points. No ADTS parsing, frame-index, seek, EOF one-shot reset, or decode-discard logic was touched or replaced.MixSystem::getChannelStatus(channel), following the samesourceMutex/cleanupRetiredSources()lockinghasChannel()already uses.MixSystem::updateGain()— extracted from the constructor's existing inlinei2s->setGain(0.4f*volumeLevel/255.0f)expression (now called from both the constructor and externally).Verified by compiling the actual guarded firmware (
-DJAYD_WIRELESS_BRINGUP) against this branch:MixScreen.cppbuilds clean.Three-band isolator EQ
Cherry-picked
285b0160("Add per-deck three-band isolator EQ") fromdjdefi-three-band-isolator-eq— a clean pick (21d90a7, no conflicts; git auto-merged itsMixSystem.cpp/.hhunks against the status/gain addition above). AddsThreeBandEQ(low/mid/high band levels) wired per-channel viaMixSystem::setEQ(), plustests/ThreeBandEQSelfCheck.cpp.Validation
-std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined):adts_timing_self_check,SpeedModifierSelfCheck(incl. reset-after-seek case),wav_header_selfcheck(asserting the dual addJob/tryAddJob contract),JobQueueContractSelfCheck(dual-API contract),ThreeBandEQSelfCheck.arduino-cli compile --fqbn cm:esp32:jayd(library's own example sketch): 1,026,978 B flash / 44,464 B RAM (+144 B flash / +8 B RAM vs35b4f80's 1,026,834 B / 44,456 B — the status/gain + EQ additions; +996 B / -8 B vs Fix Mixer clip() overflow bug; add per-channel hot-swap loading #19 baseline4ad5108's 1,025,982 B / 44,464 B).JayD-Firmwarebuild (-DJAYD_WIRELESS_BRINGUP, full screens/wireless server) against this branch: 1,840,826 B flash / 54,976 B RAM, compiles clean.SourceAACdeck uses 104 KiB core buffers (64 KiB read + 8 KiB decode + 32 KiB PCM out, matching the 4×8192B raw-block/32 KiB-output bound) plus a bounded frame index capped at 128 KiB (PSRAM-first with internal-RAM fallback). Two decks + the 32 KiBOutputWAVrecording ring buffer ≈ 496 KiB steady-state; transiently up to ~732 KiB while a channel hot-swap's retired source hasn't been reaped yet.Hot-swap pause-state regression (concrete device defect)
Physical hardware validation of
21d90a7found: Deck B paused at elapsed=2s,remoteLoadreplaced its source, the request was accepted, but status then readelapsed=2, paused=false— the pause was silently dropped by a successful hot-swap.Root cause in
MixSystem::_openChannel():replaceSource()returnstrueon any successful swap, so the||resumed the channel on every successful replacement regardless ofwasPaused— the prior pause state was only ever consulted on failure.Fixed in
1d5feb8(new commit, no amend):Replacement now always runs; resume is decided solely by the pre-swap pause state, independent of success/failure — a paused deck stays paused across a successful swap, a playing deck keeps playing, and a failed replacement leaves the deck exactly as it was (matches
replaceSource()'s existing no-op-on-failure semantics).Added
tests/OpenChannelPauseSelfCheck.cpp: a structural scan of the shipped_openChannel()proving the fixed pattern is present and the buggy||pattern is gone, plus a functional harness mirroring the exact resume decision across paused/playing × success/failure, including a negative case proving the pre-fix pattern would have been caught.Also fixed the coupled firmware-side UI bug this defect exposed, in the guarded firmware's
MixScreen::loadChannel(): it unconditionally calledbar->setPlaying(true)after queueing any replacement. Now captureswasPaused = system->isChannelPaused(channel)before queueing and setsbar->setPlaying(!wasPaused), matching the Library's restored semantics. (This lives in the guarded firmware checkout, not this repo — reported to the coordinator alongside this PR update.)Validation (re-run after the pause/resume fix)
-std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined): the 5 above plusOpenChannelPauseSelfCheck.arduino-cli compile --fqbn cm:esp32:jayd: 1,026,978 B flash / 44,464 B RAM (unchanged — the fix only changes a boolean condition, no size delta).JayD-Firmwarebuild (-DJAYD_WIRELESS_BRINGUP): 1,840,854 B flash / 54,976 B RAM (+28 B flash from theMixScreen.cpppause-capture fix, RAM unchanged).Not done
No physical hardware was available in this environment, so I did not upload/boot or smoke-test two-deck playback/rate/seek/recording on-device. A hardware-validation coordinator session is running the physical matrix separately against this branch; this PR remains explicitly validation-only pending that outcome.
Merge order
This branch is a combination for firmware consumption, not a replacement for #19/#20/#21. Once those merge upstream in dependency order (#19 → #21 → #20/deck-rate), this branch's diff against master should shrink to nothing and it can be closed.
Current final SHA:
1d5feb8df9a5d2faa4bfeb263bc0ecaaa93cb367— see branchdjdefi-integration-aac-deckrate-recording(1d5feb8pause/resume fix on top of21d90a7three-band EQ +ad85f45status/gain APIs +35b4f80).Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com