Skip to content

Integrate deck-rate + recording-core final commits onto AAC-timing base (for JayD-Firmware) - #22

Open
djdefi wants to merge 15 commits into
CircuitMess:masterfrom
djdefi:djdefi-integration-aac-deckrate-recording
Open

Integrate deck-rate + recording-core final commits onto AAC-timing base (for JayD-Firmware)#22
djdefi wants to merge 15 commits into
CircuitMess:masterfrom
djdefi:djdefi-integration-aac-deckrate-recording

Conversation

@djdefi

@djdefi djdefi commented Aug 22, 2026

Copy link
Copy Markdown

What

Combines the three independently-reviewed library foundations JayD-Firmware needs, on one branch/SHA:

All foundation commits are preserved as-is (-x cherry-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 of SpeedModifier replaced the old float remainder accumulator with a Q16.16 sourcePosition, while AAC-timing's reset() (added on a different branch, same base) still zeroed remainder. Since the two edits touched non-overlapping hunks, git merged them silently — the tree didn't build. Fixed in 5aa22cc (reset() now zeroes sourcePosition), plus one integration self-check in bb4c945 covering the exact contract this broke: a post-seek reset() 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 return bool (queue full → delete job, return false, non-blocking xQueueSend(..., 0)) wasn't honored at every call site merged from the other branches. SourceAAC::addReadJob and SourceAAC::seekSourceFrame ignored the return value: on a full queue (capacity 8, shared by two decks + recording), a rejected enqueue still left readJobPending = true (with readResult never arriving, so processReadJob(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 and SourceAAC's seek call sites only committed state on a successful enqueue; OutputAAC::addWriteJob no longer stranded its buffer on a failed enqueue. Added tests/JobQueueContractSelfCheck.cpp covering 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 a4e0fdb to ac6ba80 ("Scope nonblocking SD enqueue to recording"), which reverts SDScheduler::addJob() back to legacy blocking void (xQueueSend(..., portMAX_DELAY)) and adds a new, separate bool tryAddJob() that is non-blocking (xQueueSend(..., 0), delete-on-full) and used only by OutputWAV's real-time write/finalize path. This makes 78a9f3d's universal bool-checking fix obsolete and non-compiling against the restored void signature.

Reconciled in 35b4f80 (new commit, does not amend 78a9f3d/bb4c945/5aa22cc):

  • Cherry-picked ac6ba80 cleanly onto the branch (no textual conflicts).
  • Reverted SourceAAC::addReadJob/seekSourceFrame, SourceMP3::addReadJob, SourceWAV::addReadJob, and OutputAAC::addWriteJob back to plain blocking Sched.addJob(...) calls with no return-value check, matching the restored contract. OutputWAV's two call sites already correctly use the renamed Sched.tryAddJob(...) with the bool checked — untouched.
  • Independently reviewed queue-ownership/hang risk under the restored blocking contract: 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-waiting via Sched.loop(0) while awaiting isReadReady()). The audio task's blocking addJob() 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 why tryAddJob()'s non-blocking, checked, retry-on-false contract remains scoped there.
  • Rewrote tests/JobQueueContractSelfCheck.cpp for the new dual-API contract: structural checks assert the decode/encode sources call only blocking Sched.addJob() and never Sched.tryAddJob(), OutputWAV calls only Sched.tryAddJob() (checked) and never the blocking Sched.addJob(), and SDScheduler.h declares both signatures; a functional harness mirroring OutputWAV::addWriteJob proves 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, unmerged 78a9f3d head, SDScheduler::addJob()'s return type changed (boolvoid). That is expected and correct — 78a9f3d's bool addJob() was never released or merged anywhere; 35b4f80's void 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 transient 78a9f3d signature was ever found (verified against the guarded firmware and a fresh clone of upstream CircuitMess/JayD-Firmware: neither calls SDScheduler directly).

No other conflicts, refactors, or new abstractions were needed — openChannel hot-swap, MixSystem's recording state machine, and SDScheduler's job queue otherwise merged cleanly across the three branches' non-overlapping edits to MixSystem.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 a MixScreen that calls MixSystem::getChannelStatus()/updateGain() and switches on SourceAAC::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 same sourceMutex/cleanupRetiredSources() locking hasChannel() already uses.
  • MixSystem::updateGain() — extracted from the constructor's existing inline i2s->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.cpp builds clean.

Three-band isolator EQ

Cherry-picked 285b0160 ("Add per-deck three-band isolator EQ") from djdefi-three-band-isolator-eq — a clean pick (21d90a7, no conflicts; git auto-merged its MixSystem.cpp/.h hunks against the status/gain addition above). Adds ThreeBandEQ (low/mid/high band levels) wired per-channel via MixSystem::setEQ(), plus tests/ThreeBandEQSelfCheck.cpp.

Validation

  • All 5 host self-checks pass (-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.
  • Standalone 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 vs 35b4f80'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 baseline 4ad5108's 1,025,982 B / 44,464 B).
  • Guarded JayD-Firmware build (-DJAYD_WIRELESS_BRINGUP, full screens/wireless server) against this branch: 1,840,826 B flash / 54,976 B RAM, compiles clean.
  • Dynamic PSRAM (computed from source, unchanged by these updates): each SourceAAC deck 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 KiB OutputWAV recording 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 21d90a7 found: Deck B paused at elapsed=2s, remoteLoad replaced its source, the request was accepted, but status then read elapsed=2, paused=false — the pause was silently dropped by a successful hot-swap.

Root cause in MixSystem::_openChannel():

if(replaceSource(channel, newSource) || !wasPaused) mixer->resumeChannel(channel);

replaceSource() returns true on any successful swap, so the || resumed the channel on every successful replacement regardless of wasPaused — the prior pause state was only ever consulted on failure.

Fixed in 1d5feb8 (new commit, no amend):

replaceSource(channel, newSource);
if(!wasPaused) mixer->resumeChannel(channel);

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 called bar->setPlaying(true) after queueing any replacement. Now captures wasPaused = system->isChannelPaused(channel) before queueing and sets bar->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)

  • All 6 host self-checks pass (-std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined): the 5 above plus OpenChannelPauseSelfCheck.
  • Standalone 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).
  • Guarded JayD-Firmware build (-DJAYD_WIRELESS_BRINGUP): 1,840,854 B flash / 54,976 B RAM (+28 B flash from the MixScreen.cpp pause-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 branch djdefi-integration-aac-deckrate-recording (1d5feb8 pause/resume fix on top of 21d90a7 three-band EQ + ad85f45 status/gain APIs + 35b4f80).

Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com

djdefi and others added 10 commits August 21, 2026 14:28
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
)
@djdefi

djdefi commented Aug 22, 2026

Copy link
Copy Markdown
Author

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:

  • `SourceAAC::addReadJob`: only set `readJobPending` on a successful enqueue; free the buffer instead of leaking it on failure.
  • `SourceAAC::seekSourceFrame`: enqueue the seek first; return `false` with elapsed/decoded state untouched if it can't be queued.
  • `SourceMP3::addReadJob` / `SourceWAV::addReadJob`: same read-job fix.
  • `OutputAAC::addWriteJob`: leave the buffer in `freeBuffers` on a failed enqueue instead of stranding it pending forever.
  • `OutputWAV`'s two call sites already checked the return value correctly - no changes needed there.

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.

djdefi and others added 2 commits August 25, 2026 15:53
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>
@djdefi

djdefi commented Aug 25, 2026

Copy link
Copy Markdown
Author

Reconciled with #20's advanced head ac6ba80 ("Scope nonblocking SD enqueue to recording"), which restores legacy blocking SDScheduler::addJob() and scopes the new nonblocking tryAddJob() to OutputWAV only. Superseded the prior 78a9f3d universal-bool-check fix (now obsolete/non-compiling) by reverting SourceAAC/SourceMP3/SourceWAV/OutputAAC to plain blocking addJob() calls and rewriting JobQueueContractSelfCheck for the new dual-API contract. New final SHA: 35b4f80a47f5e5eef029e0c57db76699a076df4a. All 4 host self-checks pass, full firmware build green (1,026,834 B flash / 44,456 B RAM). See updated PR body (Conflict #3) for the full reconciliation writeup and queue-ownership review.

djdefi and others added 3 commits August 25, 2026 17:28
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant