Skip to content

fix: make WebAudio opt-in, add AudioBindingsWatchdog#2171

Merged
oliverlaz merged 6 commits intomainfrom
web-audio
Mar 26, 2026
Merged

fix: make WebAudio opt-in, add AudioBindingsWatchdog#2171
oliverlaz merged 6 commits intomainfrom
web-audio

Conversation

@oliverlaz
Copy link
Copy Markdown
Member

@oliverlaz oliverlaz commented Mar 24, 2026

💡 Overview

  • Disables WebAudio for Safari. It was introduced some time ago to work around the aggressive auto-play policies of Safari, but it seems it causes random audio issues on macOS. Customers who have opted out of WebAudio see a reduction of audio-related issues; we are flipping the switch. We'll keep the feature available as one may benefit from it.
  • Adds AudioBindingsWatchdog — a helper that periodically checks for remote participants with active audio/screenShareAudio streams but no bound <audio> element, logging a warning to help integrators catch missing audio bindings early.

📝 Implementation notes

  • Extracted watchdog logic from DynascaleManager into a standalone AudioBindingsWatchdog class with register/unregister/setEnabled/dispose API.
  • The watchdog starts automatically when callingState becomes JOINED and stops on leave.
  • Skips local participants (they never have bound audio elements).
  • Checks both audioStream and screenShareAudioStream.
  • Not instantiated in React Native (no audio elements in that context).
  • DynascaleManager.audioBindingsWatchdog is public and nullable for direct access.

🎫 Ticket: https://linear.app/stream/issue/REACT-933/add-visibility-into-audio-element-registration

@oliverlaz oliverlaz requested a review from jdimovska March 24, 2026 14:38
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 24, 2026

⚠️ No Changeset found

Latest commit: 5c14ade

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

Added AudioBindingsWatchdog to track/register remote audio element bindings and emit warnings for missing bindings; removed a Safari-specific audio-output detection fallback; changed DynascaleManager to disable WebAudio by default and integrate the watchdog; updated bindAudio/bindVideo logic and added tests for watchdog and binding behaviors. (37 words)

Changes

Cohort / File(s) Summary
Audio output support
packages/client/src/devices/devices.ts
Removed Safari-specific code path; support now detected solely via 'setSinkId' in audioElement with existing document guard.
Dynascale manager
packages/client/src/helpers/DynascaleManager.ts
Default useWebAudio set to false; added AudioBindingsWatchdog member (created when not React Native) with lifecycle/dispose integration; refactored bindAudioElement/bindVideoElement to compute is*Track and trackKey and use distinctUntilKeyChanged(trackKey); ensure watchdog entries are unregistered during cleanup.
New watchdog
packages/client/src/helpers/AudioBindingsWatchdog.ts
New exported class that registers/unregisters HTMLAudioElement bindings keyed by ${sessionId}/${trackType}, monitors call state to run a 3s periodic scan for dangling remote audio bindings, emits tracer events and logger warnings, supports enable/disable and dispose.
Tests — watchdog & dynascale binding
packages/client/src/helpers/__tests__/AudioBindingsWatchdog.test.ts, packages/client/src/helpers/__tests__/DynascaleManager.test.ts
Added Vitest tests validating watchdog detection, warning content, lifecycle (start/stop on JOINED), enable/disable, register/unregister behavior, duplicate-binding warnings, and DynascaleManager interactions with the watchdog.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Dyn as rgba(66,133,244,0.5) DynascaleManager
  participant WB as rgba(219,68,55,0.5) AudioBindingsWatchdog
  participant State as rgba(15,157,88,0.5) CallState
  participant Log as rgba(244,180,0,0.5) Tracer/Logger

  State->>WB: callingState$ emits JOINED → start interval
  Dyn->>WB: register(audioElement, sessionId, trackType)
  WB->>WB: store binding in Map[sessionId/trackType] = element
  WB-->>State: periodic scan (every 3s) reads participants & streams
  alt remote participant has stream but no binding
    WB->>Log: emit tracer + logger.warn with affected userIds
  end
  Dyn->>WB: unregister(sessionId, trackType)
  State->>WB: callingState$ leaves JOINED → stop interval
  WB->>WB: dispose() stops interval and unsubscribes
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through bindings, checked each line,

Found lonely streams without a sign.
I logged a warning, traced the gap,
Paused and watched the audio map,
A rabbit guards your ears in time.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main changes: disabling WebAudio (making it opt-in) and introducing AudioBindingsWatchdog.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The pull request description covers the main objectives with a clear overview and implementation notes, though the documentation link is incomplete.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 web-audio

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 and usage tips.

…lements

Extracts audio binding tracking from DynascaleManager into a dedicated
AudioBindingsWatchdog class that periodically warns about remote
participants with audio/screenShareAudio streams but no bound element.
Skipped in React Native where audio elements don't apply.
@oliverlaz oliverlaz changed the title fix: make WebAudio opt-in feature fix: make WebAudio opt-in, add AudioBindingsWatchdog Mar 26, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/client/src/helpers/AudioBindingsWatchdog.ts`:
- Around line 91-107: The warning currently collects userId for dangling audio
bindings but should report the actual binding identity used by
AudioBindingsWatchdog; update the loop that builds danglingUserIds to collect
the binding key(s) produced by toBindingKey(sessionId) and
toBindingKey(sessionId, 'screenShareAudioTrack') (or collect an object/strings
containing sessionId and trackType) instead of userId, and then change the
this.logger.warn call to include those binding keys (or sessionId+trackType) so
the log shows the exact binding identity that is unbound.
- Around line 44-64: unregister currently deletes bindings by
sessionId/trackType only, which causes bind(el1)->bind(el2)->unregister(el1) to
remove el2 and leave a dangling state; make unregister element-aware by
accepting the audioElement (signature: unregister(sessionId: string, trackType:
AudioTrackType, audioElement?: HTMLAudioElement)) and only remove the map entry
if the stored element equals the passed audioElement (or if no element passed,
fallback to deleting like today), or alternatively change register to store a
Set per key and remove only the given element from that Set; update the logic in
AudioBindingsWatchdog.register/unregister and add a regression test covering
bind(el1)->bind(el2)->unregister(el1) to ensure the active binding remains.

In `@packages/client/src/helpers/DynascaleManager.ts`:
- Line 76: Add a regression test to assert the new default-off behavior for web
audio: when a DynascaleManager instance is created without calling
setUseWebAudio(), its playback should remain in the direct element path (i.e.,
useWebAudio stays false) and Safari-specific behavior should continue to use
element playback; add a unit/test case that constructs DynascaleManager, does
not call setUseWebAudio(), simulates or stubs the Safari environment if needed,
and verifies the manager's behavior/state (inspect the private/use-exposed flag
or resulting playback method) to prevent regressions of the previous default-on
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3aa15114-84fa-4aba-a211-c6f199b13376

📥 Commits

Reviewing files that changed from the base of the PR and between 8d2ab80 and 0bde64e.

📒 Files selected for processing (4)
  • packages/client/src/helpers/AudioBindingsWatchdog.ts
  • packages/client/src/helpers/DynascaleManager.ts
  • packages/client/src/helpers/__tests__/AudioBindingsWatchdog.test.ts
  • packages/client/src/helpers/__tests__/DynascaleManager.test.ts

Comment on lines +44 to +64
register = (
audioElement: HTMLAudioElement,
sessionId: string,
trackType: AudioTrackType,
) => {
const key = toBindingKey(sessionId, trackType);
const existing = this.bindings.get(key);
if (existing && existing !== audioElement) {
this.logger.warn(
`Audio element already bound to ${sessionId} and ${trackType}`,
);
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
}
this.bindings.set(key, audioElement);
};

/**
* Removes the audio element binding for the given session and track type.
*/
unregister = (sessionId: string, trackType: AudioTrackType) => {
this.bindings.delete(toBindingKey(sessionId, trackType));
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.

⚠️ Potential issue | 🟡 Minor

Make unregister() element-aware.

register() overwrites the tracked element for a key, but unregister() deletes by sessionId/trackType only. In a bind(el1) -> bind(el2) -> cleanup(el1) sequence, the old cleanup removes the active el2 entry and the watchdog starts reporting a dangling binding even though audio is still bound. Please either track a Set per key or pass the element into unregister(), and add a regression test for that cleanup order.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/helpers/AudioBindingsWatchdog.ts` around lines 44 - 64,
unregister currently deletes bindings by sessionId/trackType only, which causes
bind(el1)->bind(el2)->unregister(el1) to remove el2 and leave a dangling state;
make unregister element-aware by accepting the audioElement (signature:
unregister(sessionId: string, trackType: AudioTrackType, audioElement?:
HTMLAudioElement)) and only remove the map entry if the stored element equals
the passed audioElement (or if no element passed, fallback to deleting like
today), or alternatively change register to store a Set per key and remove only
the given element from that Set; update the logic in
AudioBindingsWatchdog.register/unregister and add a regression test covering
bind(el1)->bind(el2)->unregister(el1) to ensure the active binding remains.

Comment on lines +91 to +107
const danglingUserIds: string[] = [];
for (const p of this.state.participants) {
if (p.isLocalParticipant) continue;
const { audioStream, screenShareAudioStream, sessionId, userId } = p;
if (audioStream && !this.bindings.has(toBindingKey(sessionId))) {
danglingUserIds.push(userId);
}
if (
screenShareAudioStream &&
!this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))
) {
danglingUserIds.push(userId);
}
}
if (danglingUserIds.length > 0) {
this.logger.warn(
`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`,
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.

⚠️ Potential issue | 🟡 Minor

Log the binding identity, not userId.

The watchdog tracks bindings by sessionId/trackType, but the warning only emits userId. That gets ambiguous as soon as one user has multiple sessions, and it doesn't tell you which track type is actually unbound. Logging the binding key (or at least sessionId) will make this warning actionable.

As per coding guidelines, "Participants should use sessionId as the unique identifier, not userId".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/helpers/AudioBindingsWatchdog.ts` around lines 91 - 107,
The warning currently collects userId for dangling audio bindings but should
report the actual binding identity used by AudioBindingsWatchdog; update the
loop that builds danglingUserIds to collect the binding key(s) produced by
toBindingKey(sessionId) and toBindingKey(sessionId, 'screenShareAudioTrack') (or
collect an object/strings containing sessionId and trackType) instead of userId,
and then change the this.logger.warn call to include those binding keys (or
sessionId+trackType) so the log shows the exact binding identity that is
unbound.

private speaker: SpeakerManager;
private tracer: Tracer;
private useWebAudio = isSafari();
private useWebAudio = false;
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.

⚠️ Potential issue | 🟡 Minor

Add a regression test for the new default-off path.

This line is the bug fix, but the provided Safari test only covers the opt-in case via setUseWebAudio(true). Please add a regression that verifies Safari stays on direct element playback when setUseWebAudio() is never called, otherwise the old default can creep back unnoticed.

As per coding guidelines, "Add regression tests for bug fixes".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/helpers/DynascaleManager.ts` at line 76, Add a regression
test to assert the new default-off behavior for web audio: when a
DynascaleManager instance is created without calling setUseWebAudio(), its
playback should remain in the direct element path (i.e., useWebAudio stays
false) and Safari-specific behavior should continue to use element playback; add
a unit/test case that constructs DynascaleManager, does not call
setUseWebAudio(), simulates or stubs the Safari environment if needed, and
verifies the manager's behavior/state (inspect the private/use-exposed flag or
resulting playback method) to prevent regressions of the previous default-on
behavior.

@oliverlaz oliverlaz merged commit 8d00f48 into main Mar 26, 2026
18 of 19 checks passed
@oliverlaz oliverlaz deleted the web-audio branch March 26, 2026 13:42
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.

2 participants