Skip to content

feat(sdk): report when a listener can hear every speaker who was live at join - #795

Open
realhasanshoaib wants to merge 4 commits into
masterfrom
feat/audio-ready
Open

realhasanshoaib wants to merge 4 commits into
masterfrom
feat/audio-ready

Conversation

@realhasanshoaib

@realhasanshoaib realhasanshoaib commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

A listener can sit in a call and hear nothing, with no sign that audio is still starting. The app has no way to tell "silent room" from "audio not connected yet".

Fix

  • The space client now exposes snapshot.media.audioReadiness: connecting, ready or degraded.
  • ready means inbound audio packets are increasing for every remote microphone that was on when this person joined. If nobody was speaking, it means the media connection is up.
  • A speaker who unmutes after the join never moves a ready listener back to connecting. A speaker who mutes or leaves before ready stops being waited for. A speaker who republishes before ready must be heard again on the new publication.
  • degraded means a ceiling passed first (15 s, or audioReadyCeilingMs in the client options). It keeps updating, so it can still become ready, and recovery keeps running underneath.
  • No new stats poller: it reads the advanced result of the existing receive-stall sampler (media/receive-stall.ts), so "ready" is at least two consecutive samples after the first packet.
  • The app can hold the user on "Connecting audio…" until ready. Total time is unchanged; silence becomes a visible wait.

What changed

Changed: SDK media client, media.audioReadiness on the space snapshot, client options, SDK README.

Untouched: Server, signalling, receive-stall recovery behavior, React components (only the preview fixture gets the new field).

Proof

  • Unit and client tests on the real SFU client with a fake peer connection: not ready while any speaker live at join has no packets; ready once all have them; ready on a live connection with no remote microphone; degraded after the ceiling and ready later; a late speaker neither blocks nor un-readies. A mutation that lets late speakers join the cohort fails the late-speaker test. Plus a slice test for the public state. Client media and space-client suites pass (916 tests), types and format pass.
  • Real network, dev Cloudflare SFU: Chromium listeners with fake microphones joined rooms where three speakers were already talking, plus listeners who joined an empty room (rig on branch diag/audio-ready-rig, SDK at 99cf5ffc9; the later commits move a type export and a helper, and make a republished microphone count as unheard until its new publication delivers packets). "Audible" is the first sample with a bound, playing audio element and audio level above zero.
Profile Rooms Cohort joins ready before first audible ready after last speaker audible, p50 / p95 Ended degraded Empty-room joins ready
open 8 16 0 1355 / 1806 ms 0 8 of 8
home (75 ms and 2% loss each way) 15 30 0 1305 / 1867 ms 0 15 of 15
mobile (150 ms, 8% loss, 1 Mbit up) 15 30 0 1556 / 1806 ms 0 15 of 15

An earlier run of 20 open rooms (40 cohort joins) on the commit before the last fix also had 0 early ready and 0 degraded. Each cohort join needs two consecutive packet samples, so ready trails first audio by about 1.3 to 1.6 s.

  • Not checked: the degraded path on a real network. No join reached the ceiling, so it is covered by the fake-timer tests only.
  • Not checked: the home and mobile impairments are a userspace stand-in (audio-ready-shaper.py delays, jitters, drops and rate-limits UDP between a browser network namespace and the host). The Instabox kernel has no netem, so the usual tc rig could not run. The shaper counters show the loss rates applied (about 2% and about 8%). Open ran inside the same namespace for 8 rooms and on the host for the first 20.
  • Not checked: real browsers and devices (Safari, phones, Bluetooth output), and a second SFU region.

Created with Claude Sonnet 5.5 in Claude Code.

🤖 Generated with Claude Code

realhasanshoaib and others added 2 commits November 2, 2026 08:16
… at join

media.audioReadiness is connecting, ready or degraded. Ready needs increasing inbound packets for each
remote microphone live at join (reusing the receive-stall sampler), or a live media connection when
nobody speaks. Later speakers never move it back; the ceiling (default 15 s) yields degraded, which
can still become ready.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ation id yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-02T05:30:58.271020Z 6ed55d8 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@realhasanshoaib realhasanshoaib added risk:medium Medium risk: Hasan reads the diff before it merges. size:L 100-499 changed lines, not counting tests, lockfiles or generated files. labels Nov 2, 2026

@devin-ai-integration devin-ai-integration 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.

Devin Review found 3 potential issues.

Devin Review

return [...this.#remoteTracks.values()].map(({ participantId, source, publicationId }) => ({ participantId, source, publicationId, enabled: true }));
}
#publishSnapshot(phase = this.#snapshot.connection.phase, failure = this.#snapshot.failure): void {
this.#audioReadiness.connection(phase === "live");

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.

🔴 Empty-room readiness precedes media connection

On a receive-only join to an empty room, audioReadiness becomes ready without an established peer connection. #activatePreparedTracks sets phase live even when no tracks are negotiated, so the empty cohort passes the live check.

Learn more

An empty cohort can report ready only after the media peer has actually connected. With microphone and camera off, #publishPreparedTracks returns without negotiating a local track. #activatePreparedTracks nevertheless marks the media phase live. The new readiness bridge treats that phase as proof of a connected peer, so an empty Sync publication list completes readiness while the peer connection is still new.

Example: A listener joins an empty Space with { microphone: false, camera: false }. Sync sends []; media startup marks phase live; readiness changes to ready before any peer connection is established.

Recommended fix: Gate empty-cohort readiness on connectionIsLive(this.#connection), and publish the resulting transition from peer connection state changes. Preserve the existing behavior for cohorts with actual packet evidence.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +259 to +261
this.#audioReadyCeilingTimer ??= globalThis.setTimeout(() => {
if (this.#audioReadiness.ceilingPassed()) this.#publishSnapshot();
}, this.#audioReadyCeilingMs);

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.

🔍 Readiness ceiling includes pre-entry work

start begins the ceiling before capture finishes or Sync confirms entry. A slow entrance can consume the readiness budget before the listener joins; confirm whether that timing is intended.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99cf5ffc91

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
this.#audioReadiness.advanced(advanced);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow positive audio-readiness observations in hidden tabs

If a listener switches tabs before readiness is established, audio can be flowing normally but this update is never reached: #canObserveReceiveProgress rejects hidden documents and #observeReceiveProgress returns before sampling. The ceiling timer still runs, so the snapshot eventually reports degraded and cannot become ready until the tab is foregrounded, even when every expected microphone is audible. Keep the hidden-tab safeguard for stall detection/recovery, but allow positive packet-progress evidence to establish readiness independently.

Useful? React with 👍 / 👎.

Comment on lines +32 to +35
this.#publicationOwners = new Map(microphones.flatMap((publication) => (publication.publicationId === null ? [] : [[publication.publicationId, publication.participantId] as const])));
const present = new Set(microphones.map((publication) => publication.participantId));
if (this.#cohort === null) this.#cohort = new Map([...present].map((participantId) => [participantId, false]));
else for (const participantId of this.#cohort.keys()) if (!present.has(participantId)) this.#cohort.delete(participantId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate pre-ready evidence when a speaker republishes

If Alice's audio advances while Bob is still pending, then Alice republishes under a new publication ID, her cohort entry remains true even though #publicationOwners now references the replacement. When Bob's packets advance, the tracker reports ready without receiving any packets from Alice's replacement. This is reproducible directly with the tracker, and remote-track reconciliation retires Alice's old track when its publication is replaced, so the previous evidence no longer establishes that she can be heard. While readiness is still pending, invalidate a member's heard flag when their publication changes; preserve the intended sticky behavior after ready.

Useful? React with 👍 / 👎.

realhasanshoaib and others added 2 commits November 2, 2026 10:07
…o readiness

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…udio is ready

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@realhasanshoaib

Copy link
Copy Markdown
Contributor Author

Review findings, answered:

  • Republished microphone kept stale proof (Devin, Codex): fixed in the latest commit. Each cohort member now remembers which publication was heard, and a replaced publication counts as unheard until it delivers packets. A tracker test covers it.
  • Empty room becomes ready when the media phase is live (Devin): intended for now. A receive-only listener with microphone and camera off has no transceiver, so the peer connection stays new until the first remote pull. Requiring connected would hold every empty-room listener until the ceiling. Follow-up: use the first pull's connection state once someone speaks.
  • Hidden tab never reaches ready (Codex): follow-up. Receive sampling pauses in a hidden tab, so the listener turns degraded at the ceiling and ready about two samples after the tab is visible. Nobody is looking at the app in that state.
  • Ceiling starts at start() (Devin): intended. The ceiling measures how long the listener waits from the moment media starts, including capture.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ed55d842f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +50 to +52
connection(live: boolean): boolean {
this.#connectionLive = live;
return this.#evaluate();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate pending audio evidence when replacing the local peer

If Alice has been heard while Bob is still pending and the listener's peer connection is replaced, Alice's evidence survives even though her receiver has been destroyed. Both restart() and #replaceMediaConnectionForRecovery() dispose the old peer and clear remote tracks, but only the receive-stall tracker is reset. Because remote publication IDs can remain unchanged, receiving Bob on the replacement peer then reports ready even if Alice's replacement receiver never receives packets. Invalidate pre-ready evidence on peer replacement while preserving the cohort and the intended sticky behavior after ready.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:medium Medium risk: Hasan reads the diff before it merges. size:L 100-499 changed lines, not counting tests, lockfiles or generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant