fix(core): stop the running audio before rescheduling it on a mute toggle

Muting or unmuting a track mid-playback laid a SECOND buffer source over
every clip still sounding, and left both playing until the next pause: the
whole mix doubled, slightly out of phase. Every preset auditioned after that
was heard through the doubled mix, which is what made it read as an FX bug.

Scheduling does not replace the active set. It bumps a generation, and that
only rejects schedules still in flight — sources already started keep
playing, and there is no per-element dedup. `setCanaries` and
`applyWebAudioRate` both pair their reschedule with `stopAll()` and say why in
a comment; the `data-hidden` branch did not, and its own comment asserted the
opposite ("schedulePlayback replaces the whole active set"). Fixed the call
and the comment.

Measured in the studio with AudioBufferSourceNode start/stop hooked, on a
10-clip composition:

  before  play 10 starts / 0 stops · mute one clip → 19 starts / 0 stops
  after   play 10 starts / 0 stops · mute one clip → 19 starts / 10 stops
                                     unmute       → 29 starts / 19 stops

so the live source count goes 10 → 9 → 10 instead of 10 → 19 → 29. A hover
audition now schedules one set (9 starts, 0 stops), not two.

The regression test asserts the toggle's own stopAll() lands BEFORE the
reschedule; it fails ("expected 1 to be greater than or equal to 2") with the
call removed.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD is clean.
This commit is contained in:
Vance Ingalls
2026-08-20 02:16:29 -07:00
parent 06096d348a
commit 66b9ea7df4
2 changed files with 57 additions and 1 deletions
+11 -1
View File
@@ -1961,7 +1961,16 @@ export function initSandboxRuntimeModular(): void {
// A data-hidden toggle on (or affecting) an audio element must re-schedule
// WebAudio playback so the hidden clip's source is dropped/restored mid-
// playback. Batched to one call per syncTimedElementVisibility pass, not
// one per toggled node (schedulePlayback replaces the whole active set).
// one per toggled node.
//
// The reschedule is paired with `stopAll()` below, for the reason
// `setCanaries` and `applyWebAudioRate` already spell out: scheduling does
// NOT replace the active set. It bumps a generation, which only rejects
// stale schedules still in flight — every source already started keeps
// playing, and there is no per-element dedup. This comment used to claim the
// opposite and the call site trusted it, so muting a track mid-playback
// started a second buffer source for every in-window clip on top of the ones
// still sounding: the whole mix audibly doubled, slightly out of phase.
let hiddenAudioDirty = false;
const nodeAffectsAudio = (node: HTMLElement): boolean =>
node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null;
@@ -2042,6 +2051,7 @@ export function initSandboxRuntimeModular(): void {
}
}
if (hiddenAudioDirty && clock.isPlaying()) {
webAudio.stopAll();
scheduleWebAudioForActiveClips();
}
hiddenAudioDirty = false;