Skip to content

fix(sound): play concurrently, add back play_in_tab - #874

Merged
martin-henz merged 8 commits into
masterfrom
sound-concurrent-play-and-play-in-tab
Aug 4, 2026
Merged

fix(sound): play concurrently, add back play_in_tab#874
martin-henz merged 8 commits into
masterfrom
sound-concurrent-play-and-play-in-tab

Conversation

@martin-henz

Copy link
Copy Markdown
Member

Summary

Fixes #841. The Conductor migration of the sound module regressed two behaviours from before the migration:

  • play()/play_wave() calls used to overlap (genuine concurrent playback); after the migration they silently serialized instead (each call waited for the previous one to finish before starting).
  • play_in_tab() (and its tab UI, a play bar per call) was dropped entirely.

This PR restores both:

  • Concurrent playback: removed the host-side (SoundTabPlugin) playback queue, so repeated/looped play()/play_wave() calls now start as soon as their own sampling finishes and are mixed together by the shared AudioContext, instead of queueing behind whatever's already playing.
  • play_in_tab(): brought back as a new exported function. It samples the given Sound and encodes it as a data:audio/wav;base64,... URI (pure computation — no AudioContext needed, so this happens module-side rather than via a host round trip), which the sound tab renders as a native <audio controls> play bar. Per the clarification on the issue, each call adds a new bar to the existing sound tab, stacked vertically below any earlier ones (using the browser's own play/pause/scrub controls, rather than a custom widget).

Test plan

  • yarn test passes for both src/bundles/sound (69 tests, including new play_in_tab coverage) and src/tabs/Sound (22 tests, including new concurrency + addPlayerToTab coverage)
  • yarn tsc and yarn lint pass for both packages (no new lint errors; one new eslint-disable-next-line warning matches the existing pattern already present for every other validated function in the file)
  • yarn build succeeds for both packages

🤖 Generated with Claude Code

https://claude.ai/code/session_01B3LRoXRMQ5ADeYMdVTChCZ

The Conductor migration accidentally serialized play()/play_wave()
(each call now waited for the previous one to finish) and dropped
play_in_tab() (and its tab UI) entirely, regressing two behaviours
the sound module used to have: genuinely overlapping playback, and a
tab showing per-sound play bars.

- Removed the host-side playback queue in SoundTabPlugin so repeated/
  looped play()/play_wave() calls start immediately and overlap,
  mixed by the shared AudioContext, instead of playing one after
  another.
- Brought back play_in_tab(): it samples the Sound and encodes it as
  a WAV data URI (pure computation, done module-side rather than via
  a host round trip), which the sound tab renders as a native
  <audio controls> play bar. Each call adds a new bar, stacked
  vertically below any earlier ones, per the clarification on #841.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3LRoXRMQ5ADeYMdVTChCZ
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@martin-henz

Copy link
Copy Markdown
Member Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The sound module now supports concurrent play() calls and adds play_in_tab(). The Sound tab tracks overlapping sources, delays cleanup until playback ends, and renders independent native audio controls for WAV data URI players.

Changes

Sound playback and tab players

Layer / File(s) Summary
Concurrent playback lifecycle
src/tabs/Sound/src/index.tsx, src/tabs/Sound/src/__tests__/index.test.ts
Repeated play() calls start concurrently. Pending sources control status and AudioContext cleanup. Stop actions affect all active sources.
Tab player storage and rendering
src/bundles/sound/src/protocol.ts, src/tabs/Sound/src/index.tsx, src/tabs/Sound/src/__tests__/index.test.ts
addPlayerToTab() stores WAV data URI entries, notifies subscribers, and renders one native audio control per entry.
play_in_tab API and WAV encoding
src/bundles/sound/src/functions.ts, src/bundles/sound/src/index.ts, src/bundles/sound/src/__tests__/*
play_in_tab() validates and samples sounds, creates stereo PCM WAV data, and adds separate tab players without immediate playback.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SoundModulePlugin
  participant play_in_tab
  participant SoundTabRpc
  participant SoundTabPlugin
  SoundModulePlugin->>play_in_tab: validate and convert Sound
  play_in_tab->>play_in_tab: sample channels and encode WAV data URI
  play_in_tab->>SoundTabRpc: addPlayerToTab(wavDataUri)
  SoundTabRpc->>SoundTabPlugin: insert player entry
  SoundTabPlugin-->>SoundTabRpc: resolve after insertion
  SoundTabRpc-->>play_in_tab: resolve
  play_in_tab-->>SoundModulePlugin: return Sound
Loading

Possibly related PRs

Suggested reviewers: leeyi45

Poem

A rabbit hears the sources play,
Together now, without delay.
WAV bars bloom within the tab,
Each one gets its own control slab.
play_in_tab hops into view,
With players made for me and you.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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
Title check ✅ Passed The title clearly summarizes the two main changes: concurrent sound playback and restoring play_in_tab.
Description check ✅ Passed The description explains the issue, motivation, implementation, and test results, but omits some template sections.
Linked Issues check ✅ Passed The changes satisfy issue #841 by restoring concurrent playback and adding per-call in-tab audio players.
Out of Scope Changes check ✅ Passed The code and tests remain focused on the sound playback and play_in_tab requirements from issue #841.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sound-concurrent-play-and-play-in-tab

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/tabs/Sound/src/index.tsx (1)

201-213: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prevent __ensureAudioContext() from reusing a closed AudioContext.

destroy() closes this.__audioContext when __destroyed is true and __pendingPlaybackCount is zero, but it does not reset this.__audioContext. __ensureAudioContext() only creates a new context when !this.__audioContext, so any later playSamples()/stopRecording() call uses the closed context and fails or hangs. Treat a closed AudioContext as no longer usable and create a new one.

🔧 Proposed fix
   private __ensureAudioContext(): AudioContext {
-    if (!this.__audioContext) {
+    if (!this.__audioContext || this.__audioContext.state === 'closed') {
       this.__audioContext = new AudioContext();
     }
     return this.__audioContext;
   }
🤖 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 `@src/tabs/Sound/src/index.tsx` around lines 201 - 213, Update
__ensureAudioContext() to detect when the existing AudioContext has been closed,
treating it as unusable and creating a new context just as it does when
__audioContext is absent. Preserve reuse of open contexts while ensuring later
playSamples() or stopRecording() calls never operate on the closed context
finalized by __maybeFinalizeDestroy().
🧹 Nitpick comments (2)
src/tabs/Sound/src/index.tsx (2)

61-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Associate the "Sound N" label with its <audio> control for assistive tech.

Currently the label is only a <p> placed visually above the control; screen readers announce the <audio> element without the "Sound N" context since there is no aria-label/aria-labelledby/<label> association. Add aria-label (or id/aria-labelledby) so the control's accessible name matches its visible label.

♿ Proposed fix
-          <audio src={player.dataUri} controls style={{ width: '100%' }} />
+          <audio src={player.dataUri} controls style={{ width: '100%' }} aria-label={`Sound ${index + 1}`} />
🤖 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 `@src/tabs/Sound/src/index.tsx` around lines 61 - 88, Update PlayerBarsView so
each audio control is programmatically associated with its visible “Sound N”
label. Add a unique label identifier per player and reference it from the
corresponding audio element via aria-labelledby, or provide the same visible
text through aria-label while preserving the existing presentation.

116-134: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the __players list to avoid unbounded growth per Run.

__players only ever grows via addPlayerToTab() (Line 254) and is never trimmed until the whole SoundTabPlugin instance is replaced by the next Run. Each entry retains a full base64 WAV data URI plus a rendered <audio> element. A loop that calls play_in_tab() many times within one Run (a plausible pattern, e.g. adding a bar per note in a sequence) accumulates memory and DOM nodes with no upper bound for the life of that Run, which can noticeably degrade or freeze the tab for longer/larger sounds. Consider capping the list (e.g. keep the most recent N entries) or otherwise bounding growth.

♻️ Proposed fix
+  private static readonly MAX_PLAYERS = 50;
+
   async addPlayerToTab(wavDataUri: string): Promise<void> {
-    this.__players = [...this.__players, { id: this.__nextPlayerId, dataUri: wavDataUri }];
+    const nextPlayers = [...this.__players, { id: this.__nextPlayerId, dataUri: wavDataUri }];
+    this.__players =
+      nextPlayers.length > SoundTabPlugin.MAX_PLAYERS
+        ? nextPlayers.slice(nextPlayers.length - SoundTabPlugin.MAX_PLAYERS)
+        : nextPlayers;
     this.__nextPlayerId += 1;
     this.__emit();
   }

Also applies to: 253-257

🤖 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 `@src/tabs/Sound/src/index.tsx` around lines 116 - 134, Bound the per-run
__players collection in addPlayerToTab so repeated play_in_tab() calls cannot
grow it indefinitely. Keep only the most recent entries up to a defined maximum,
removing older entries and their associated rendered audio resources as needed
while preserving newest-entry playback behavior.
🤖 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.

Outside diff comments:
In `@src/tabs/Sound/src/index.tsx`:
- Around line 201-213: Update __ensureAudioContext() to detect when the existing
AudioContext has been closed, treating it as unusable and creating a new context
just as it does when __audioContext is absent. Preserve reuse of open contexts
while ensuring later playSamples() or stopRecording() calls never operate on the
closed context finalized by __maybeFinalizeDestroy().

---

Nitpick comments:
In `@src/tabs/Sound/src/index.tsx`:
- Around line 61-88: Update PlayerBarsView so each audio control is
programmatically associated with its visible “Sound N” label. Add a unique label
identifier per player and reference it from the corresponding audio element via
aria-labelledby, or provide the same visible text through aria-label while
preserving the existing presentation.
- Around line 116-134: Bound the per-run __players collection in addPlayerToTab
so repeated play_in_tab() calls cannot grow it indefinitely. Keep only the most
recent entries up to a defined maximum, removing older entries and their
associated rendered audio resources as needed while preserving newest-entry
playback behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f210742f-f22a-40e4-986f-da9b8fab0c5b

📥 Commits

Reviewing files that changed from the base of the PR and between 21123a0 and b5692f1.

📒 Files selected for processing (7)
  • src/bundles/sound/src/__tests__/sound.test.ts
  • src/bundles/sound/src/__tests__/utils.ts
  • src/bundles/sound/src/functions.ts
  • src/bundles/sound/src/index.ts
  • src/bundles/sound/src/protocol.ts
  • src/tabs/Sound/src/__tests__/index.test.ts
  • src/tabs/Sound/src/index.tsx

@Akshay-2007-1
Akshay-2007-1 self-requested a review August 2, 2026 11:06
martin-henz and others added 2 commits August 3, 2026 15:00
…ay-and-play-in-tab

# Conflicts:
#	src/bundles/sound/src/functions.ts
- __ensureAudioContext() now treats a closed AudioContext as absent
  rather than reusing it: destroy() closes the context but never
  resets the field, so a playSamples() call still in flight when a
  Run ends (e.g. sampling was still running) could otherwise be
  handed back an already-closed, unusable context.
- Cap __players to the most recent MAX_PLAYER_BARS entries so a loop
  calling play_in_tab() many times in one Run can't grow the list
  (each holding a full WAV data URI plus a rendered <audio> element)
  without bound for the tab's lifetime.
- Associate each play bar's "Sound N" label with its <audio> control
  via aria-labelledby, so the control's accessible name matches what
  is shown on screen.

Also updates play_in_tab() to use the shared sampleSound() helper
(added by the just-merged #869) instead of directly sampling
leftWave/rightWave, so it gets the same "sample the source once"
fix play() already has for panned sounds - this one only surfaced
once resolving this PR's conflict with master pulled sampleSound()
in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Dv6WCgTvMVbCJmyRUY9pL
@martin-henz

Copy link
Copy Markdown
Member Author

Resolved the merge conflict with master and addressed CodeRabbit's review.

Conflict resolution (src/bundles/sound/src/functions.ts): both branches added a new helper function in the same spot after sampleWave - this PR's encodeWavDataUri() (for play_in_tab()) and master's smoothSample()/sampleSound() (from #869, merged since this PR was opened). Kept both. While resolving, updated play_in_tab() to sample through the new sampleSound() helper instead of the pre-#869 direct double-sampleWave() pattern it was written against - so it gets the same "sample the source once" fix play() already has for panned sounds, rather than reintroducing that class of bug for the new code path.

CodeRabbit's 3 findings, all still valid against current code, now fixed:

  • Major: __ensureAudioContext() only checked !this.__audioContext, so after destroy() closes the context (without resetting the field), a playSamples() call still in flight (e.g. sampling was still running when the Run ended) would get handed back an already-closed, unusable context. Now also checks .state === 'closed' and creates a fresh one, per CodeRabbit's suggested fix.
  • Nitpick: __players grew without bound across a Run - capped to the most recent 50 entries (MAX_PLAYER_BARS).
  • Nitpick: the <audio> play bar wasn't associated with its visible "Sound N" label for assistive tech - added aria-labelledby pointing at the label's id (rather than a duplicated aria-label string, so the accessible name always matches what's rendered).

Added 2 regression tests covering the AudioContext-reuse fix and the player-list cap, plus a getPlayers() accessor on SoundTabPlugin (mirroring the existing getStatus()) so the cap is actually testable from outside.

Verified locally:

  • yarn workspace @sourceacademy/bundle-sound test - 80/80 passing
  • yarn workspace @sourceacademy/tab-Sound test - 24/24 passing (22 existing + 2 new)
  • tsc/lint/build clean for both packages

Pushed as two commits: the merge itself, then a separate fix(sound): address CodeRabbit findings on play_in_tab tab UI commit.

@Akshay-2007-1 Akshay-2007-1 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.

I have live tested with the following code snippets :

1. play_in_tab — basic: adds a play bar without playing immediately

from sound import sine_sound, play_in_tab

play_in_tab(sine_sound(440, 2))

A "Sound 1" bar appears with native controls.

2. play_in_tab — repeated calls stack vertically

from sound import sine_sound, play_in_tab

play_in_tab(sine_sound(440, 1))
play_in_tab(sine_sound(660, 1))
play_in_tab(sine_sound(880, 1))

Three bars, "Sound 1"/"Sound 2"/"Sound 3", stacked top to bottom.

3. play_in_tab — zero duration is a silent no-op

from sound import sine_sound, play_in_tab

play_in_tab(sine_sound(440, 0))

No error, no new bar. Silently no-ops.

4. play_in_tab — error cases

from sound import sine_sound, play_in_tab

play_in_tab(sine_sound(440, -1))  # "Sound duration must be a finite number greater than or equal to 0"
from sound import play_in_tab

play_in_tab(42)  # "Expected a Sound for sound, got 42."

5. Concurrent play() — the main regression this PR fixes

from sound import sine_sound, play

play(sine_sound(440, 3))
play(sine_sound(660, 3))
play(sine_sound(880, 3))

All three tones overlap as a chord for ~3s, instead of playing one after another.


The Feedback I received from live-testing (MAJOR point no 2) along with AI review

  1. play_in_tab() has no cap on accumulated bars : memory/DOM growth risk
    SoundTabPlugin.addPlayerToTab() (index.tsx:253-257) just appends forever: this.__players = [...this.__players, {...}]. Each entry holds a full base64 WAV string, rendered as its own element. A student loop like for i in range(1000): play_in_tab(sine_sound(440, 1)) — easy to write by accident, e.g. off-by-one in a range, will keep growing that array and the DOM with no limit, unlike play()/stop() which have bounded state. Worth deciding: cap it, evict oldest, or is unbounded genuinely fine for the expected use case?

  2. No "constructing…" status while play_in_tab() samples a long sound
    play() explicitly calls io().notifyConstructing() before sampling starts, specifically so the tab shows "Constructing…" instead of looking stalled during a long sample (there's a comment saying exactly this). play_in_tab() skips that call entirely, so play_in_tab(sine_sound(440, 30)) will show nothing happening for however long sampling takes, then a bar just appears.

  3. No test coverage for the actual rendered UI (PlayerBarsView)All the new tests hit addPlayerToTab/__players (the plugin's data layer), but nothing renders PlayerBarsView and asserts N bars appear in the right order — the part of the feature the issue actually asked for ("multiple bars arranged vertically") is untested at the rendering layer.

  4. (minor/optional) play_in_tab() duplicates play()'s validation block almost verbatim
    Same is_sound check + duration < 0/=== 0 handling copy-pasted. More a maintainability nit than a bug — not really "postable" as an issue on its own, but worth mentioning if you're filing a cleanup-oriented comment.

martin-henz and others added 2 commits August 3, 2026 15:56
- play_in_tab() now calls notifyConstructing() before sampling, like
  play() already does, so a long Sound shows "Constructing…" instead
  of looking stalled until the bar appears. addPlayerToTab() is the
  corresponding "done" signal (decrementing __constructingCount and
  recomputing status), matching how playSamples() does it for play().
- Deduplicated play()/play_in_tab()'s near-identical Sound argument
  validation into a shared assertPlayableSound() helper.
- Added browser-mode rendering tests for PlayerBarsView (the actual
  "multiple bars stacked vertically" UI the issue asked for), which
  previously only had coverage at the addPlayerToTab() data layer.
  Exported PlayerBarsView so it's directly renderable in tests, and
  brought in the same vitest-browser-react/@vitest/browser-playwright
  setup already used by the Rune/Matrix/Curve tabs.

The player-list cap Akshay's review also flagged (point 1) was
already fixed in the previous commit before the review landed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Dv6WCgTvMVbCJmyRUY9pL
The previous commit's git add silently dropped these files (one bad
pathspec in a multi-path invocation aborts the whole add) - this is
the rest of that change:
- assertPlayableSound() dedup in functions.ts, and play_in_tab()'s
  new notifyConstructing() call
- addPlayerToTab()'s matching __constructingCount decrement
- the PlayerBarsView rendering tests and their vitest-browser-react/
  browser-playwright devDependencies
- 2 new bundle-level tests (notifyConstructing ordering) and 1 new
  tab-level test (constructing status closes out via addPlayerToTab)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Dv6WCgTvMVbCJmyRUY9pL
…tion play_in_tab() calls

A zero-duration Sound is a valid neutral element (for consecutively()/
simultaneously(), e.g. as a reduce() starting value), not an error -
but play_in_tab() previously just silently added nothing to the tab
for one, which could read as the call having been dropped.

Adds a new addZeroDurationPlayerToTab() RPC method (deliberately
separate from addPlayerToTab(), not a variant of it): no sampling
happens for a zero-duration Sound, so play_in_tab() never calls
notifyConstructing() for one either, and folding this into
addPlayerToTab() would incorrectly decrement __constructingCount on
behalf of a call that never incremented it - which could cancel out
an unrelated, genuinely concurrent play_in_tab() call's still-in-
flight notifyConstructing().

PlayerBarEntry becomes a discriminated union so PlayerBarsView can
render a plain "zero duration sound" placeholder line in the entry's
call-order position, instead of a native <audio> control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Dv6WCgTvMVbCJmyRUY9pL
@martin-henz

Copy link
Copy Markdown
Member Author

Per the discussion above: play_in_tab() on a zero-duration Sound now adds a "zero duration sound" placeholder entry to the tab, in the same call-order position, rather than silently doing nothing.

Implementation: added addZeroDurationPlayerToTab() as a separate SoundTabRpc method (not a variant of addPlayerToTab()) - a zero-duration Sound is never sampled, so play_in_tab() never calls notifyConstructing() for one either. Folding this into addPlayerToTab() would have meant it always decrements __constructingCount, which would be wrong here (nothing incremented it for this call) and could incorrectly cancel out an unrelated, genuinely concurrent play_in_tab() call's still-in-flight notifyConstructing().

PlayerBarEntry is now a discriminated union ({ kind: 'audio', dataUri } | { kind: 'zero-duration' }), and PlayerBarsView renders a plain text line instead of an <audio> control for the zero-duration case, keeping the same "Sound N" numbering as everything else.

Added tests on both sides: bundle-level (addZeroDurationPlayerToTab called instead of addPlayerToTab, notifyConstructing not called), and tab-level (entry ordering alongside audio entries, rendering, and that it correctly leaves an unrelated in-flight constructing status alone).

Verified locally: bundle-sound 82/82, tab-Sound 32/32 (browser mode), tsc/lint/build clean for both.

@martin-henz
martin-henz merged commit 4dc2bba into master Aug 4, 2026
14 checks passed
@martin-henz
martin-henz deleted the sound-concurrent-play-and-play-in-tab branch August 4, 2026 05:48
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.

sound: play concurrently, sequentially, in-tab?

2 participants