Skip to content

refactor(app): lift the shared player state onto a container (R3a)#889

Merged
byrongamatos merged 1 commit into
mainfrom
feat/r3-carve-countin
Jul 11, 2026
Merged

refactor(app): lift the shared player state onto a container (R3a)#889
byrongamatos merged 1 commit into
mainfrom
feat/r3-carve-countin

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #888. static/js/player-state.jsone exported object, two fields. app.js's 70 reference sites rewritten. Provably a no-op; nothing shrinks.

Why now

Every slice carved out of app.js so far only ever read the state it shared (loopA/loopB, _audioSeekGen, currentFilename), so a read-only getter hook was enough and no container was needed. Twice I checked, and twice I got away with it.

That runs out at count-in: it genuinely writes isPlaying (it starts and stops playback — 4 sites) and lastAudioTime (2 sites).

import { isPlaying } from '…';
isPlaying = true;   // ✗ THROWS — an imported binding cannot be assigned to

So the state has to live on an object: S.isPlaying = true is a property write, which works from any module holding the same S. Same shape the stems, studio, and editor migrations all converged on.

Deliberately small. app.js has ~104 top-level let scalars; lifting all of them is a ~977-site rewrite for no benefit, since most are private to one cluster and travel with it. Only what a carved module must write goes here. Add on demand.

The rewrite is AST-driven, not textual — and that isn't fussiness

Of 100 textual occurrences of these two names, only 70 resolve to the module binding:

count what a blind replace does
member accesses (someObj.isPlaying, window.feedBack.isPlaying) 22 corrupts unrelated objects
the local parameter of function setPlayButtonState(isPlaying) 4 yields function setPlayButtonState(S.isPlaying)
object key 1 renames a key
shorthand { isPlaying } 2 must become { isPlaying: S.isPlaying }

A find-and-replace corrupts all 29. The rewrite walks the AST, skips shadows / member properties / keys, and replaces identifier ranges.

One trap worth recording: acorn gives a shorthand property's key and value the same range, so rewriting both produced isPlaying: S.isPlaying: S.isPlaying until I deduped by range.

window.feedBack.isPlaying — the public mirror — is a different thing and is untouched. Two test sandboxes stub it; those were deliberately left alone.

Verified with real playback

A/B against origin/main in two browsers, real song loaded:

main container
feedBack.isPlaying initial true identical
after togglePlay() false identical
after 2nd togglePlay() true identical
audio element paused-state follows identical
seekBy(5) ok identical
page errors none none

Harnesses

8 vm-sandbox suites slice playback code out of app.js and now see S.isPlayingjuce_engine_reroute, loop_restart, play_button_reroute_guard, playback_app_adapter, song_restart, song_seek, speed_reset, plus the Python source-assert. Each gets the same container in its sandbox; every assertion is unchanged.

pytest 2396 · node 1040/1040 · ESLint 0 · tailwind clean · Codex 0.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved playback state consistency across play, pause, resume, stop, seeking, looping, and track transitions.
    • Prevented incorrect playback UI states after audio route changes, rejected playback attempts, and unexpected time jumps.
    • Improved count-in, autoplay, restart, and end-of-track behavior.
  • Refactor
    • Centralized playback status and audio position tracking for more reliable player behavior.
  • Tests
    • Updated playback, routing, looping, seeking, restart, and speed-reset coverage.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6687e76c-072c-41e3-b892-bcb15b1060a9

📥 Commits

Reviewing files that changed from the base of the PR and between 61c9950 and 212afca.

📒 Files selected for processing (10)
  • static/app.js
  • static/js/player-state.js
  • tests/js/juce_engine_reroute.test.js
  • tests/js/loop_restart.test.js
  • tests/js/play_button_reroute_guard.test.js
  • tests/js/playback_app_adapter.test.js
  • tests/js/song_restart.test.js
  • tests/js/song_seek.test.js
  • tests/js/speed_reset.test.js
  • tests/test_plugin_runtime_idempotence.py
🚧 Files skipped from review as they are similar to previous changes (9)
  • tests/js/juce_engine_reroute.test.js
  • tests/test_plugin_runtime_idempotence.py
  • tests/js/loop_restart.test.js
  • tests/js/speed_reset.test.js
  • tests/js/playback_app_adapter.test.js
  • static/js/player-state.js
  • tests/js/song_restart.test.js
  • tests/js/song_seek.test.js
  • tests/js/play_button_reroute_guard.test.js

📝 Walkthrough

Walkthrough

The PR introduces shared S.isPlaying and S.lastAudioTime state, then routes playback lifecycle, JUCE/HTML5 switching, transport adapters, count-in, seeking, HUD synchronization, and tests through that state.

Changes

Playback state centralization

Layer / File(s) Summary
Shared state contract
static/js/player-state.js, static/app.js
Adds the shared mutable state object and replaces the local playback-state binding with the imported state.
Routing and transport integration
static/app.js, tests/js/juce_engine_reroute.test.js, tests/js/playback_app_adapter.test.js
Routes JUCE/HTML5 switching, audio shims, transport operations, event guards, and adapter tests through S.isPlaying.
Playback lifecycle controls
static/app.js, tests/js/play_button_reroute_guard.test.js, tests/js/song_restart.test.js, tests/js/speed_reset.test.js, tests/test_plugin_runtime_idempotence.py
Updates teardown, autoplay, arrangement changes, song loading, toggling, restart, exit confirmation, and related test harnesses to use shared playback state.
Count-in and timing synchronization
static/app.js, tests/js/loop_restart.test.js, tests/js/song_seek.test.js
Stores and reads S.lastAudioTime during count-in, loop wrapping, seeking, HUD synchronization, and jump correction.
Estimated code review effort: 4 (Complex) ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlaybackControls
  participant JUCEWatcher
  participant HTML5Audio
  participant PlayerState
  PlaybackControls->>PlayerState: update S.isPlaying
  JUCEWatcher->>PlayerState: snapshot S.isPlaying
  JUCEWatcher->>HTML5Audio: switch or resume route
  HTML5Audio-->>JUCEWatcher: play or pause result
  JUCEWatcher->>PlayerState: commit playback state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.93% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: refactoring shared player state into a container for app playback state.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/r3-carve-countin

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
static/js/host.js (1)

48-99: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a direct runtime test for static/js/host.js. tests/js/host_contract.test.js covers static wiring, but this seam’s runtime guarantees still deserve a small unit test for unconfigured reads, double configureHost() calls, and writes throwing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/host.js` around lines 48 - 99, Add a focused runtime unit test for
the host seam exported by host.js, covering that reading a hook before
configureHost() throws, calling configureHost() twice throws, and assigning to
host properties throws. Keep the test isolated so module state is reset between
cases and use the existing public configureHost and host symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@static/js/host.js`:
- Around line 48-99: Add a focused runtime unit test for the host seam exported
by host.js, covering that reading a hook before configureHost() throws, calling
configureHost() twice throws, and assigning to host properties throws. Keep the
test isolated so module state is reset between cases and use the existing public
configureHost and host symbols.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3036a570-1a39-4761-b1f8-f349250f8d94

📥 Commits

Reviewing files that changed from the base of the PR and between 64f0456 and 61c9950.

📒 Files selected for processing (16)
  • static/app.js
  • static/js/host.js
  • static/js/loops.js
  • static/js/player-state.js
  • static/js/section-practice.js
  • tests/js/host_contract.test.js
  • tests/js/juce_engine_reroute.test.js
  • tests/js/loop_api.test.js
  • tests/js/loop_restart.test.js
  • tests/js/play_button_reroute_guard.test.js
  • tests/js/playback_app_adapter.test.js
  • tests/js/section_practice_dismiss.test.js
  • tests/js/song_restart.test.js
  • tests/js/song_seek.test.js
  • tests/js/speed_reset.test.js
  • tests/test_plugin_runtime_idempotence.py

static/js/player-state.js — one exported object, two fields. app.js's 70 reference
sites rewritten. Provably a no-op; nothing shrinks.

WHY NOW. Every slice carved out of app.js so far only ever READ the state it shared
(loopA/loopB, _audioSeekGen, currentFilename), so a read-only getter hook was enough
and no container was needed — twice I checked and twice I got away with it. That
runs out at count-in: it genuinely WRITES `isPlaying` (it starts and stops playback,
4 sites) and `lastAudioTime` (2). `import { isPlaying }` then `isPlaying = true`
THROWS — an imported binding cannot be assigned to. So the state has to live on an
object: `S.isPlaying = true` is a property write, and works from any module holding
the same S. Same shape stems, studio, and editor all converged on.

DELIBERATELY SMALL. app.js has ~104 top-level `let` scalars; lifting all of them is
a ~977-site rewrite for no benefit, because most are private to one cluster and
travel with it. Only what a carved module must WRITE goes here. Add on demand.

THE REWRITE IS AST-DRIVEN, NOT TEXTUAL — and that is not fussiness. Of 100 textual
occurrences of these two names, only 70 resolve to the module binding:
  * 22 are member accesses (`someObj.isPlaying`, `window.feedBack.isPlaying`)
  * 4 are the LOCAL PARAMETER of `function setPlayButtonState(isPlaying)` — a blind
    replace yields `function setPlayButtonState(S.isPlaying)`
  * 1 is an object key
  * 2 are shorthand properties `{ isPlaying }`, which must become
    `{ isPlaying: S.isPlaying }` — and acorn gives a shorthand's key and value the
    SAME range, so rewriting both produced `isPlaying: S.isPlaying: S.isPlaying`
    until I deduped by range
A find-and-replace corrupts all 29. The rewrite walks the AST, skips shadows, member
properties and keys, and replaces identifier RANGES.

`window.feedBack.isPlaying` — the PUBLIC mirror — is a different thing and is
untouched. Two test sandboxes stub it; those were left alone deliberately.

VERIFIED WITH REAL PLAYBACK. A/B against origin/main in two browsers, real song:
togglePlay -> the public mirror goes true -> false -> true across two toggles, the
audio element's paused state follows, seekBy works — IDENTICAL, zero page errors.

Harnesses: 8 vm-sandbox suites slice playback code out of app.js and now see
S.isPlaying — juce_engine_reroute, loop_restart, play_button_reroute_guard,
playback_app_adapter, song_restart, song_seek, speed_reset, and the python
idempotence source-assert. Each gets the same container in its sandbox; every
assertion is unchanged.

pytest 2396, node 1040/1040, ESLint 0, tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos
byrongamatos force-pushed the feat/r3-carve-countin branch from 61c9950 to 212afca Compare July 11, 2026 19:32
@byrongamatos
byrongamatos merged commit 5fb28d5 into main Jul 11, 2026
5 checks passed
byrongamatos added a commit that referenced this pull request Jul 11, 2026
…pp.js (R3a)

static/js/count-in.js (389) — bodies VERBATIM. app.js 8,223 → 7,913.
The third slice out of the strongly-connected core, and the first that WRITES
shared state rather than only reading it. #889's container is what makes it possible.

  imports: loops (setLoop/loopA/loopB — a count-in inside an A-B loop must begin at
           A), audio-el, player-state, host
  hooks  : _audioSeek, setPlayButtonState, _songEventPayload, togglePlay + a
           jucePlayer getter
  Nothing imports count-in back — app.js and section-practice both reach it through
  the seam — so the graph stays acyclic.

app.js's autoplay path used to reach IN and set this module's credits timers itself
(_creditsTimer, _creditsHideOnPlay) and read _countingIn. It cannot now, and should
not have to, so the module exports the OPERATIONS instead — armCreditsHideOnPlay(),
scheduleCreditsHide(), holdCreditsThen(start), isCountingIn() — and owns its own
timer invariants. Third time this has happened (section-practice's resetSelection,
loops' state) and each time the constraint produced better code than was there
before: the module keeps its own promises instead of trusting a caller 6,000 lines
away to zero the right fields.

THE no-undef GATE FOUND FIVE MISSED MEMBERS, one at a time: showSongCreditsOverlay
and startSongCountIn (my name regex matched startCountIn, not startSongCountIn),
then _creditLineLabel, _CREDITS_MAX_MS, and _CREDIT_ROLE_VERBS. A call-graph closure
does not see a const table; only the undefined-symbol pass does.

AND A REAL TRAP: I computed _CREDIT_ROLE_VERBS's span against the ALREADY-MODIFIED
app.js and applied it to the clean one — the line numbers had drifted, so the slice
would have cut somewhere else entirely. Recomputed every span from the clean file
with acorn. Never carry line numbers across an edit.

VERIFIED. A/B against origin/main in two browsers with a real song: playback state,
the public feedBack.isPlaying mirror, audio position, cancel-count-in — IDENTICAL,
zero page errors. Unit coverage moved with the code: loop_restart's count-in
cancellation-token test and the 5 song_credits_overlay tests now read count-in.js;
loop_restart's sandbox gains a `host` object routed at its EXISTING stubs, so every
assertion is unchanged.

HONEST LIMIT: I could not make the count-in OVERLAY actually render headlessly —
its autoplay path needs a fresh-load _pendingAutostart that a scripted playSong()
never arms. Behaviour is identical to main on every probe and the unit tests cover
the logic, but the on-screen 1-2-3-4 and the credits card want a human look.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos added a commit that referenced this pull request Jul 11, 2026
…pp.js (R3a) (#890)

static/js/count-in.js (389) — bodies VERBATIM. app.js 8,223 → 7,913.
The third slice out of the strongly-connected core, and the first that WRITES
shared state rather than only reading it. #889's container is what makes it possible.

  imports: loops (setLoop/loopA/loopB — a count-in inside an A-B loop must begin at
           A), audio-el, player-state, host
  hooks  : _audioSeek, setPlayButtonState, _songEventPayload, togglePlay + a
           jucePlayer getter
  Nothing imports count-in back — app.js and section-practice both reach it through
  the seam — so the graph stays acyclic.

app.js's autoplay path used to reach IN and set this module's credits timers itself
(_creditsTimer, _creditsHideOnPlay) and read _countingIn. It cannot now, and should
not have to, so the module exports the OPERATIONS instead — armCreditsHideOnPlay(),
scheduleCreditsHide(), holdCreditsThen(start), isCountingIn() — and owns its own
timer invariants. Third time this has happened (section-practice's resetSelection,
loops' state) and each time the constraint produced better code than was there
before: the module keeps its own promises instead of trusting a caller 6,000 lines
away to zero the right fields.

THE no-undef GATE FOUND FIVE MISSED MEMBERS, one at a time: showSongCreditsOverlay
and startSongCountIn (my name regex matched startCountIn, not startSongCountIn),
then _creditLineLabel, _CREDITS_MAX_MS, and _CREDIT_ROLE_VERBS. A call-graph closure
does not see a const table; only the undefined-symbol pass does.

AND A REAL TRAP: I computed _CREDIT_ROLE_VERBS's span against the ALREADY-MODIFIED
app.js and applied it to the clean one — the line numbers had drifted, so the slice
would have cut somewhere else entirely. Recomputed every span from the clean file
with acorn. Never carry line numbers across an edit.

VERIFIED. A/B against origin/main in two browsers with a real song: playback state,
the public feedBack.isPlaying mirror, audio position, cancel-count-in — IDENTICAL,
zero page errors. Unit coverage moved with the code: loop_restart's count-in
cancellation-token test and the 5 song_credits_overlay tests now read count-in.js;
loop_restart's sandbox gains a `host` object routed at its EXISTING stubs, so every
assertion is unchanged.

HONEST LIMIT: I could not make the count-in OVERLAY actually render headlessly —
its autoplay path needs a fresh-load _pendingAutostart that a scripted playSong()
never arms. Behaviour is identical to main on every probe and the unit tests cover
the logic, but the on-screen 1-2-3-4 and the credits card want a human look.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.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