fix(sounds): de-duplicate notification sounds across browser tabs - #831
Open
garrappachc wants to merge 2 commits into
Open
garrappachc wants to merge 2 commits into
garrappachc wants to merge 2 commits into
Conversation
Every open tab held its own websocket and independently played notification sounds, so a player with N tabs heard N simultaneous beeps. Coordinate playback over a BroadcastChannel: each sound is claimed by the tabs, and only the highest-priority audio-capable tab (focused > visible > hidden) actually plays. The mention clear now also stops the sound cross-tab, since that clip is ~6s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
garrappachc
force-pushed
the
feat/dedupe-notification-sounds
branch
from
August 31, 2026 14:42
5a2f715 to
550dba8
Compare
Contributor
Playwright test resultsDetails
|
The BroadcastChannel election worked but leaned on a 60ms setTimeout window, and hidden pages clamp timers to ~1s -- exactly the case this fixes, since the point is that the player is alt-tabbed away. navigator.locks does the same job natively, has the same browser baseline, and puts no timer on the latency path. Resuming the audio context before racing replaces the isAudioBlocked() heuristic with the empirical answer: a tab that cannot unlock its audio drops out instead of winning and swallowing the notification. That also makes the focused tab win most races for free, since its context is usually already running. Clearing a mention now clears the star and the badge in the other tabs too, not just the sound -- both halves of "the user read the chat". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CBup4reCuf3c49KUkprEF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A player with several tabs open heard the same notification sound from every tab at once — each tab holds its own websocket and independently reached
playSound(). This de-duplicates playback so exactly one tab beeps.How
Tabs race for a Web Lock keyed by the sound id (
navigator.locks.request(soundId, { ifAvailable: true }, …)). The winner plays; everyone else getsnullback and skips. The lock is held ~250ms after playing so that tabs reacting to the same websocket message lose the race, while a notification arriving later still gets through.playSound()resumes the audio context before racing and drops out if it isn'trunning. That means a tab which can't unlock its audio never wins the lock and swallows the notification — and, for free, the focused tab usually wins, since its context is already running while a backgrounded tab has to spend a resume first.If
navigator.locksis unavailable (older Safari), it degrades to the previous "all tabs play" behaviour — the same baseline asBroadcastChannel, which the mention path still uses.The mention clear path broadcasts over a
BroadcastChannel, so reading chat in one tab clears the sound, the★title prefix and the tab badge in the others. The sound half matters most because that clip is ~6s where the rest are ~1.5s.Scope / notes
playSound()now requires the element to carry anid— it is the lock key. All four sound elements have one and every call path resolves viagetElementById, but an id-less[data-sound-src]element would now stay silent.audioReady→audio_blocked_players.count,ready_up.notified.count{audioBlocked}) stay valid; there is no client→server actuation signal and this adds none.vitestruns on node, no DOM env), and Playwright cannot observe real audio output. The lock mechanism itself was checked out-of-tree by driving three tabs of one browser profile — simultaneous burst plays exactly once, a later notification still plays, distinct sounds don't block each other. Audio verified manually: two tabs, one beep; clearing in either tab stops the mention and drops the star in both.new Notification()with notag) is a separate, trivial follow-up.History
The first pass coordinated over a
BroadcastChannelelection — each tab announced a claim, waited a 60ms window to collect competitors, and the highest-ranked (focused > visible > hidden) played. That was replaced because hidden pages clampsetTimeoutto ~1s, which is exactly the scenario the feature targets: the player is alt-tabbed into TF2 waiting on ready-up, so every tab's claim window stretches and the beep lands late. Web Locks put no timer on the latency path, release automatically if a tab crashes, and the priority ordering they give up was inaudible anyway — the tabs share the same speakers.