oficina / meet Public

Say who can't hear you yet: hearing on the wire, and the web's notice

Add §15.24's optional hearing list to the media report: the peers whose
audio a client is actually receiving and playing. Validated like
noSoundFrom, kept with its emptiness ([] is "nobody yet", absent is a
client that does not say), and carried after the other lists. The core
always sends it and drops it on an older server's 400, then the stalled
screens, so a mute is never lost.

The browser reports it and, on each new person's tile, says "<name> is
connecting and can't hear you yet" past a one-second grace until their
report lists us, with a fallback for clients that do not say and a
twenty-second cap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 01a29256cd3c0f439fc4e0c81e5d15b4e1dd5b3d parent f86d4da
15 files changed, +1068 −52
  • Makefile +1 −0
  • callscore/media_test.go +112 −3
  • callscore/room.go +69 −22
  • internal/calls/hub/media.go +6 −1
  • internal/calls/hub/media_test.go +71 −0
  • internal/calls/signal.go +18 −8
  • internal/calls/static/calls.css +17 −0
  • internal/calls/static/room.js +277 −15
  • internal/callstest/browser_test.go +1 −0
  • internal/callstest/media_test.go +65 −1
  • native/bridge/bridge.go +15 −0
  • protocol/messages.go +17 −1
  • test/browser/call.mjs +169 −0
  • test/browser/room-connecting.test.mjs +186 −0
  • test/browser/room-mute.test.mjs +44 −1
diff --git a/Makefile b/Makefile
index 0075e0f..4b39b17 100644
--- a/Makefile
+++ b/Makefile
@@ -152,6 +152,7 @@ browser:
node --test test/browser/room-hearing.test.mjs
node --test test/browser/room-screen.test.mjs
node --test test/browser/room-reconnect.test.mjs
+ node --test test/browser/room-connecting.test.mjs
node --test test/browser/room-glow.test.mjs
node --test test/browser/room-layout.test.mjs
node --test test/browser/room-window.test.mjs
diff --git a/callscore/media_test.go b/callscore/media_test.go
index 6c3010e..28956d5 100644
--- a/callscore/media_test.go
+++ b/callscore/media_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"time"
@@ -85,7 +86,7 @@ func TestOwnMediaIsReportedOnAdmissionAndOnChange(t *testing.T) {
}
// A later roster that already says what we say: nothing to send.
- if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil {
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true, Hearing: []protocol.PeerID{}}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil {
t.Fatal(err)
}
nothingPosted(t, posted, "a roster that agrees needs no report")
@@ -263,7 +264,8 @@ func TestScreenStalledFallsBackOnAnOldServer(t *testing.T) {
var m protocol.MediaState
_ = json.Unmarshal(s.Payload, &m)
posted <- s
- if len(m.ScreenStalled) > 0 {
+ // A server from before §15.21 knows neither key (nor §15.24's).
+ if len(m.ScreenStalled) > 0 || m.Hearing != nil {
w.WriteHeader(http.StatusBadRequest)
}
}))
@@ -279,7 +281,8 @@ func TestScreenStalledFallsBackOnAnOldServer(t *testing.T) {
if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
t.Fatal(err)
}
- nextMedia(t, posted)
+ nextMedia(t, posted) // with hearing, refused
+ nextMedia(t, posted) // without it
if err := r.SetMedia(true, false); err != nil {
t.Fatal(err)
}
@@ -300,3 +303,109 @@ func TestScreenStalledFallsBackOnAnOldServer(t *testing.T) {
t.Fatalf("later report %+v, want it plain", m)
}
}
+
+// §15.24: this core always says who it hears — [] until it hears anyone,
+// so it is never mistaken for a client that does not say — and the list
+// is kept like noSoundFrom's: roster peers only, in roster order, a
+// repeat not a change.
+func TestHearingIsAlwaysReportedFromTheRoster(t *testing.T) {
+ r, posted := mediaRoom(t)
+ a, b := protocol.PeerID("aaaa"), protocol.PeerID("bbbb")
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: a, Gen: 1}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case s := <-posted:
+ if !strings.Contains(string(s.Payload), `"hearing":[]`) {
+ t.Fatalf("admission report %s, want hearing: [] on the wire", s.Payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("no admission report")
+ }
+ if err := r.SetHearing([]protocol.PeerID{b, "self", "stranger", a}); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); len(m.Hearing) != 2 || m.Hearing[0] != a || m.Hearing[1] != b {
+ t.Fatalf("hearing %v, want [aaaa bbbb] in roster order", m.Hearing)
+ }
+ if err := r.SetHearing([]protocol.PeerID{a, b}); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "the same list is not a change")
+ if err := r.SetHearing(nil); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing == nil || len(m.Hearing) != 0 {
+ t.Fatalf("cleared %+v, want hearing: [] and not absent", m)
+ }
+ // A roster that carries the same [] needs nothing; one that lost the
+ // key (a restarted hub) is told again.
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Hearing: []protocol.PeerID{}}}, protocol.RosterPeer{Peer: a, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "a roster that agrees needs no report")
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{}}, protocol.RosterPeer{Peer: a, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing == nil {
+ t.Fatalf("a roster without our hearing must be corrected, got %+v", m)
+ }
+}
+
+// A server from before §15.24 refuses every report from this core, which
+// always carries hearing. The core sends the same report again without
+// it and leaves it out from then on, so a mute is never lost; a server
+// from before §15.21 as well refuses the stalled screens next, and they
+// go too.
+func TestHearingFallsBackOnAnOldServer(t *testing.T) {
+ posted := make(chan protocol.Signal, 16)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ var s protocol.Signal
+ _ = json.NewDecoder(req.Body).Decode(&s)
+ var m protocol.MediaState
+ _ = json.Unmarshal(s.Payload, &m)
+ posted <- s
+ if m.Hearing != nil || len(m.ScreenStalled) > 0 {
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ }))
+ t.Cleanup(srv.Close)
+ r, err := New(srv.URL, "room", srv.Client(), func(Event) {})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.started = true
+ r.state.Peer = "self"
+ t.Cleanup(r.Close)
+ b := protocol.PeerID("bbbb")
+ _ = r.SetMedia(true, false)
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing == nil {
+ t.Fatalf("first report %+v, want hearing", m)
+ }
+ if m := nextMedia(t, posted); m.Hearing != nil || !m.Muted {
+ t.Fatalf("resent %+v, want the same report without hearing", m)
+ }
+ if err := r.SetScreenStalled([]protocol.PeerID{b}); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); len(m.ScreenStalled) != 1 || m.Hearing != nil {
+ t.Fatalf("report %+v, want stalled screens and no hearing", m)
+ }
+ if m := nextMedia(t, posted); len(m.ScreenStalled) != 0 || m.Hearing != nil || !m.Muted {
+ t.Fatalf("resent %+v, want it plain and still muted", m)
+ }
+ if err := r.SetHearing([]protocol.PeerID{b}); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); m.Hearing != nil {
+ t.Fatalf("later report %+v, want no hearing", m)
+ }
+ // The roster agrees with what this server can hold: nothing to send.
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "a roster that agrees with the plain report needs nothing")
+}
diff --git a/callscore/room.go b/callscore/room.go
index 25a56ee..e9950fb 100644
--- a/callscore/room.go
+++ b/callscore/room.go
@@ -63,6 +63,10 @@ type Room struct {
// plainScreen: the server refused a report naming stalled screens
// (it predates §15.21), so reports go without them.
plainScreen bool
+ // plainHearing: the server refused a report saying who this client
+ // hears (it predates §15.24), so reports go without the key, and the
+ // people on new clients fall back to their own evidence about us.
+ plainHearing bool
}
func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) {
@@ -74,7 +78,7 @@ func New(origin, room string, authenticated *http.Client, callback func(Event))
return nil, errors.New("a room event handler is required")
}
ctx, cancel := context.WithCancel(context.Background())
- return &Room{t: t, callback: callback, ctx: ctx, cancel: cancel, state: Snapshot{Phase: "ready", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}}, last: map[protocol.PeerID]uint64{}, seq: map[protocol.PeerID]uint64{}, lanes: map[protocol.PeerID]*lane{}, blocked: map[protocol.PeerID]bool{}, iceWake: make(chan struct{}, 1)}, nil
+ return &Room{t: t, callback: callback, ctx: ctx, cancel: cancel, state: Snapshot{Phase: "ready", Peers: []protocol.RosterPeer{}, Waiting: []protocol.RosterPeer{}}, media: protocol.MediaState{Hearing: []protocol.PeerID{}}, last: map[protocol.PeerID]uint64{}, seq: map[protocol.PeerID]uint64{}, lanes: map[protocol.PeerID]*lane{}, blocked: map[protocol.PeerID]bool{}, iceWake: make(chan struct{}, 1)}, nil
}
func (r *Room) emit(e Event) { r.emitMu.Lock(); defer r.emitMu.Unlock(); r.callback(e) }
func (r *Room) snapshotLocked() Snapshot {
@@ -316,6 +320,34 @@ func (r *Room) SetScreenStalled(peers []protocol.PeerID) error {
return r.reportMedia()
}
+// SetHearing records which peers this client is actually receiving and
+// playing audio from (spec §15.24) and reports it with the rest of its
+// media state. Roster peers only, in roster order, bounded, exactly as
+// SetNoSoundFrom keeps its list — except that empty is not absent here:
+// this client always says who it hears, and "nobody yet" is [] on the
+// wire, so the people it has not yet heard know to wait for it rather
+// than fall back to guessing.
+func (r *Room) SetHearing(peers []protocol.PeerID) error {
+ want := make(map[protocol.PeerID]bool, len(peers))
+ for _, p := range peers {
+ want[p] = true
+ }
+ r.mu.Lock()
+ next := []protocol.PeerID{}
+ for _, p := range r.state.Peers {
+ if p.Peer != r.state.Peer && want[p.Peer] && len(next) < protocol.MaxHearing {
+ next = append(next, p.Peer)
+ }
+ }
+ if (protocol.MediaState{Hearing: next}).Equal(protocol.MediaState{Hearing: r.media.Hearing}) {
+ r.mu.Unlock()
+ return nil
+ }
+ r.media.Hearing = next
+ r.mu.Unlock()
+ return r.reportMedia()
+}
+
func (r *Room) reportMedia() error {
r.mu.Lock()
report := r.state.Phase == "admitted" && !r.stopped
@@ -328,37 +360,56 @@ func (r *Room) reportMedia() error {
func (r *Room) sendMedia() error {
r.mu.Lock()
+ b, err := json.Marshal(r.wireMediaLocked())
+ r.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ return r.Send(protocol.TypeMedia, "", 0, string(b))
+}
+
+// wireMediaLocked is the report as this server will take it: the host's
+// state without the keys an older server has refused.
+func (r *Room) wireMediaLocked() protocol.MediaState {
report := r.media
if r.plainScreen {
// A server from before §15.21 refuses the key; everything else
// in the report still matters.
report.ScreenStalled = nil
}
- b, err := json.Marshal(report)
- r.mu.Unlock()
- if err != nil {
- return err
+ if r.plainHearing {
+ report.Hearing = nil
}
- return r.Send(protocol.TypeMedia, "", 0, string(b))
+ return report
}
-// refusedScreenStalled is a server from before §15.21 refusing a media
-// report because it named stalled screens: from then on the report goes
-// without them, and this one is sent again that way. The sharer never
-// hears, and recovers on its own watchdog alone.
-func (r *Room) refusedScreenStalled(s protocol.Signal) bool {
+// refusedExtras is a server from before §15.24 or §15.21 refusing a media
+// report because of a key it does not know: the newest key goes first —
+// hearing, which every report from this core carries — and the report is
+// sent again without it; a second refusal, of a report naming stalled
+// screens, takes those off too. From then on reports go without them, so
+// a mute is never lost to an old server. Without hearing, the people on
+// new clients fall back to their own evidence that they can be heard
+// (§15.24); without screenStalled the sharer recovers on its own watchdog.
+func (r *Room) refusedExtras(s protocol.Signal) bool {
var sent protocol.MediaState
- if json.Unmarshal(s.Payload, &sent) != nil || len(sent.ScreenStalled) == 0 {
+ if json.Unmarshal(s.Payload, &sent) != nil {
return false
}
r.mu.Lock()
- already := r.plainScreen
- r.plainScreen = true
+ dropped := false
+ if sent.Hearing != nil && !r.plainHearing {
+ r.plainHearing = true
+ dropped = true
+ } else if len(sent.ScreenStalled) > 0 && !r.plainScreen {
+ r.plainScreen = true
+ dropped = true
+ }
r.mu.Unlock()
- if !already {
+ if dropped {
go func() { _ = r.sendMedia() }()
}
- return true
+ return dropped
}
// mediaStaleLocked says whether the hub's record of this client — its
@@ -368,11 +419,7 @@ func (r *Room) refusedScreenStalled(s protocol.Signal) bool {
func (r *Room) mediaStaleLocked() bool {
for _, p := range r.state.Peers {
if p.Peer == r.state.Peer {
- want := r.media
- if r.plainScreen {
- want.ScreenStalled = nil
- }
- return p.Media == nil || !p.Media.Equal(want)
+ return p.Media == nil || !p.Media.Equal(r.wireMediaLocked())
}
}
return false
@@ -648,7 +695,7 @@ func (r *Room) sendLoop(l *lane) {
if refusal.Status == 429 {
delay = max(delay, refusal.RetryAfter)
} else if refusal.Status < 500 {
- if refusal.Status == 400 && s.Type == protocol.TypeMedia && r.refusedScreenStalled(s) {
+ if refusal.Status == 400 && s.Type == protocol.TypeMedia && r.refusedExtras(s) {
break
}
if refusal.Status == 401 || refusal.Status == 403 || refusal.Status == 302 || refusal.Status == 303 {
diff --git a/internal/calls/hub/media.go b/internal/calls/hub/media.go
index 5c85873..63a7d7f 100644
--- a/internal/calls/hub/media.go
+++ b/internal/calls/hub/media.go
@@ -14,7 +14,7 @@ import (
//
// §15.20 adds the receiving half to the same report — audioBlocked and
// noSoundFrom, each present only when it has bad news, and 2026-09-28's
-// screenStalled beside them — and nothing
+// screenStalled beside them, and §15.24's hearing after those — and nothing
// about the rules: it is still the sender's own claim, recorded and
// carried in rosters, and fanned out only when it changes.
//
@@ -61,6 +61,11 @@ func (h *Hub) SetMedia(ctx context.Context, sqid, session string, from PeerID, m
next := m
next.NoSoundFrom = append([]PeerID(nil), m.NoSoundFrom...)
next.ScreenStalled = append([]PeerID(nil), m.ScreenStalled...)
+ // §15.24's hearing keeps its emptiness: [] says "nobody yet", where
+ // nil says nothing at all.
+ if m.Hearing != nil {
+ next.Hearing = append(make([]PeerID, 0, len(m.Hearing)), m.Hearing...)
+ }
p.Media = &next
payload, err := json.Marshal(next)
if err != nil {
diff --git a/internal/calls/hub/media_test.go b/internal/calls/hub/media_test.go
index a1889f0..480b845 100644
--- a/internal/calls/hub/media_test.go
+++ b/internal/calls/hub/media_test.go
@@ -417,3 +417,74 @@ func TestSetMediaCarriesTheReceivingHalf(t *testing.T) {
t.Fatalf("clearing the bad news sent %v", got)
}
}
+
+// §15.24: hearing is kept with its emptiness — [] is "nobody yet", nil is
+// a client that does not say — carried after the other lists, copied so
+// nothing the caller holds is shared with a roster, and a change between
+// nil and [] is a change.
+func TestSetMediaCarriesHearing(t *testing.T) {
+ now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer)
+ drain(a.Sub)
+ drain(b.Sub)
+
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil {
+ t.Fatal(err)
+ }
+ got := drain(b.Sub)
+ if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false,"hearing":[]}` {
+ t.Fatalf("empty hearing sent %v", got)
+ }
+ list := []PeerID{b.Peer}
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{ScreenStalled: list, Hearing: list}); err != nil {
+ t.Fatal(err)
+ }
+ got = drain(b.Sub)
+ want := `{"muted":false,"cameraOff":false,"screenStalled":["` + string(b.Peer) + `"],"hearing":["` + string(b.Peer) + `"]}`
+ if len(got) != 1 || string(got[0].Payload) != want {
+ t.Fatalf("payload %v, want %s", got, want)
+ }
+ list[0] = "rewritten"
+ c, _ := h.Open("r1", "sc", 3, "Cy", KindMember)
+ h.Join(ctx, "r1", "sc", c.Peer)
+ var ro *Roster
+ for _, ev := range drain(c.Sub) {
+ if ev.Type == TypeRoster {
+ ro = ev.Roster
+ }
+ }
+ drain(b.Sub)
+ m, _ := rosterMedia(ro, a.Peer)
+ if m == nil || len(m.Hearing) != 1 || m.Hearing[0] != b.Peer {
+ t.Fatalf("roster media %+v, want the hub's own copy of the list", m)
+ }
+ // Back to []: a change. Then nil: a change again, and the two-key report.
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil {
+ t.Fatal(err)
+ }
+ if got := drain(b.Sub); len(got) != 1 {
+ t.Fatalf("back to empty sent %d", len(got))
+ }
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil {
+ t.Fatal(err)
+ }
+ got = drain(b.Sub)
+ if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false}` {
+ t.Fatalf("nil after [] sent %v", got)
+ }
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil {
+ t.Fatal(err)
+ }
+ if got := drain(b.Sub); len(got) != 0 {
+ t.Fatalf("the same report again reached Bob: %v", types(got))
+ }
+ var round MediaState
+ if err := json.Unmarshal([]byte(`{"muted":false,"cameraOff":false,"hearing":[]}`), &round); err != nil || round.Hearing == nil {
+ t.Fatalf("[] must decode as an empty, non-nil list: %+v %v", round, err)
+ }
+}
diff --git a/internal/calls/signal.go b/internal/calls/signal.go
index 22db7f5..1edfc73 100644
--- a/internal/calls/signal.go
+++ b/internal/calls/signal.go
@@ -186,17 +186,20 @@ func exactKeys(raw json.RawMessage, want ...string) bool {
// maxMediaBytes bounds §15.19's payload before it is decoded. The
// longest honest one — both booleans false, audioBlocked, and full
-// noSoundFrom and screenStalled lists of fifteen 32-character peer ids
-// each (§15.20, §15.21) — is a little over 1,150 bytes; the rest is room
+// noSoundFrom, screenStalled and hearing lists of fifteen 32-character
+// peer ids each (§15.20, §15.21, §15.24) — is a little over 1,700 bytes;
+// the rest is room
// for whitespace a client's encoder might add, and nothing a peer can use
// to put more on anyone's stream: what goes out is re-encoded from the
// validated values.
-const maxMediaBytes = 2048
+const maxMediaBytes = 3072
-// validateMedia is §15.19's shape as §15.20 and §15.21 extend it: "muted"
-// and "cameraOff", both booleans and both required; "audioBlocked", a
-// boolean, and "noSoundFrom" and "screenStalled", each an array of at
-// most fifteen distinct peer ids, all optional. Keys byte-exact and each
+// validateMedia is §15.19's shape as §15.20, §15.21 and §15.24 extend it:
+// "muted" and "cameraOff", both booleans and both required; "audioBlocked",
+// a boolean, and "noSoundFrom", "screenStalled" and "hearing", each an
+// array of at most fifteen distinct peer ids, all optional. An empty
+// "hearing" is kept as empty rather than absent: it is a client saying it
+// hears nobody yet, where an absent one says nothing (§15.24). Keys byte-exact and each
// once, no other member, judged on the raw bytes the same way
// validateFanout judges chat and share (Codex S4-1). It returns the
// decoded state, or a status and an outcome word for the refusal log.
@@ -209,7 +212,7 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
if len(raw) > maxMediaBytes {
return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size"
}
- if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom", "screenStalled"}) {
+ if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom", "screenStalled", "hearing"}) {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
}
var p struct {
@@ -218,6 +221,7 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
AudioBlocked json.RawMessage `json:"audioBlocked"`
NoSoundFrom json.RawMessage `json:"noSoundFrom"`
ScreenStalled json.RawMessage `json:"screenStalled"`
+ Hearing json.RawMessage `json:"hearing"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
@@ -237,6 +241,12 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
if m.ScreenStalled, ok = peerList(p.ScreenStalled, protocol.MaxScreenStalled); !ok {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
}
+ if m.Hearing, ok = peerList(p.Hearing, protocol.MaxHearing); !ok {
+ return protocol.MediaState{}, http.StatusBadRequest, "shape"
+ }
+ if p.Hearing != nil && m.Hearing == nil {
+ m.Hearing = []hub.PeerID{}
+ }
return m, 0, ""
}
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index a3a16b1..aa85ff6 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -868,6 +868,23 @@ body:has(> .calls-window) { background: var(--rst-bg); }
flex-basis: 100%;
font-weight: 600;
}
+/* §15.24: "Samuel is connecting and can't hear you yet", the words
+ steady and three dots after them that brighten in turn — waiting, not
+ wrong. One steady ellipsis under reduced motion. The dots are
+ aria-hidden: a screen reader hears the sentence once. */
+.calls-ellipsis {
+ display: inline-flex;
+ letter-spacing: 0.04em;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .calls-ellipsis > span { animation: calls-ellipsis 1.4s ease-in-out infinite both; }
+ .calls-ellipsis > span:nth-child(2) { animation-delay: 0.2s; }
+ .calls-ellipsis > span:nth-child(3) { animation-delay: 0.4s; }
+}
+@keyframes calls-ellipsis {
+ 0%, 80%, 100% { opacity: 0.35; }
+ 40% { opacity: 1; }
+}
/* A filmstrip tile, or any small one (0.4.1). A tile too small for the
camera-off words keeps the symbol alone, then a smaller one. The
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index ad248c5..c99871d 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -315,6 +315,13 @@
const RECONNECT_AUDIO_AFTER = 2000;
const RECONNECT_HOLD = 1000;
const RECONNECT_EVERY = 200;
+ // §15.24 (2026-10-01), "<name> is connecting and can't hear you yet":
+ // said on a person's tile from the moment they appear until their own
+ // report says they hear us — but only once CONNECTING_GRACE has passed
+ // without that, so an ordinary join says nothing, and never past
+ // CONNECTING_CAP, after which §15.20's and §15.23's words take over.
+ const CONNECTING_GRACE = 1000;
+ const CONNECTING_CAP = 20000;
// §5.4, sending: "Optimise shared screen for". What a screen gives up
// first when the uplink or the encoder cannot carry all of it. The
// first is the default; sharePlan says what each one means.
@@ -393,6 +400,10 @@
// §15.23: when the stream went down (0 while it is up), and the watch.
let streamDownSince = 0;
const reconnect = reconnectRecord();
+ // §15.24: who can hear us yet (connectingStep's record), and the
+ // `hearing` list as last reported, joined, so a change is seen.
+ const connecting = connectingRecord();
+ let hearingKey = '';
// §15.20: peer id -> what this tab is hearing from that peer, as
// pollLevels reads it (hearingRecord). selfHearing is the same record
// for our own microphone, read off the sender.
@@ -4230,6 +4241,16 @@
}
if (ids.length) out.screenStalled = ids;
}
+ // §15.24: kept even when empty — [] is "I hear nobody yet", where an
+ // absent list is a client that does not say.
+ if (Array.isArray(value.hearing)) {
+ const ids = [];
+ for (const id of value.hearing) {
+ if (ids.length >= 15) break;
+ if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id);
+ }
+ out.hearing = ids;
+ }
return out;
}
@@ -4246,6 +4267,7 @@
if (m.audioBlocked) key += 'b';
if (m.noSoundFrom && m.noSoundFrom.length) key += ':' + m.noSoundFrom.slice().sort().join(',');
if (m.screenStalled && m.screenStalled.length) key += '/' + m.screenStalled.slice().sort().join(',');
+ if (Array.isArray(m.hearing)) key += '|' + m.hearing.slice().sort().join(',');
return key;
}
@@ -4259,17 +4281,19 @@
}
// mediaPayload is the report itself: §15.19's two booleans, and
- // §15.20's two keys only when they carry bad news — so the ordinary
- // report is still exactly the two-key object every server takes.
- // `plain` is a server that has refused the extras once. Pure, and
- // lifted by name in the node test.
- function mediaPayload(mine, blocked, silentFrom, plain, stalled) {
+ // §15.20's two keys only when they carry bad news. §15.24's `hearing`
+ // is the exception, and travels whenever it is given, [] included: it
+ // is good news, and its presence tells the others this tab says who it
+ // hears. `plain` is a server that has refused the extras once. Pure,
+ // and lifted by name in the node test.
+ function mediaPayload(mine, blocked, silentFrom, plain, stalled, heard) {
const out = { muted: !!mine.muted, cameraOff: !!mine.cameraOff };
if (plain) return out;
if (blocked) out.audioBlocked = true;
if (silentFrom && silentFrom.length) out.noSoundFrom = silentFrom.slice(0, 15);
// §15.21: whose screens have stalled here, bad news only, like the rest.
if (stalled && stalled.length) out.screenStalled = stalled.slice(0, 15);
+ if (Array.isArray(heard)) out.hearing = heard.slice(0, 15);
return out;
}
@@ -4285,7 +4309,7 @@
// as muted or not matters more than being heard as unable to hear.
function reportMedia() {
if (!state.admitted || state.stopped || !state.peer) return;
- const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled);
+ const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled, hearingList());
const key = mediaKey(now);
if (key === mediaSent) return;
mediaSent = key;
@@ -4313,7 +4337,7 @@
learnMedia(rp.peer, rp.media);
continue;
}
- const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled);
+ const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled, hearingList());
mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : '';
}
reportMedia();
@@ -4721,13 +4745,179 @@
rec.peers = shown;
}
+ // ---------------------------------------------------------------
+ // "… is connecting and can't hear you yet" (§15.24, 2026-10-01)
+ // ---------------------------------------------------------------
+ //
+ // Paul asked for Zoom's "… is connecting to audio and cannot hear you
+ // yet": the gap after someone joins, before their side is receiving and
+ // playing our audio, said rather than talked into. Their own report's
+ // `hearing` is the authority; a client that does not send one is judged
+ // on what this tab can see of its own audio reaching them.
+
+ function connectingRecord() {
+ return { peers: new Map() };
+ }
+
+ // hearsYou is a peer's report on us: true when its `hearing` lists us,
+ // false when it has a list and we are not on it, and null when it has
+ // no list — no report yet, or a client from before §15.24. Pure, and
+ // lifted by name in the node test.
+ function hearsYou(media, me) {
+ if (!media || !Array.isArray(media.hearing)) return null;
+ return !!me && media.hearing.includes(me);
+ }
+
+ // connectingStep is one look at everyone else: `people` is peer id ->
+ // {epoch, hearsMe, reported, connected, acked}, where epoch is the pair
+ // generation (a new one is a new connection, and they cannot hear us on
+ // it yet), hearsMe is hearsYou's answer, reported whether they have
+ // made any media report, connected whether the connection to them is
+ // up, and acked whether their receiver reports say our audio reaches
+ // them. rec.peers holds {epoch, since, done, shown} per person. Returns
+ // the people whose words have just gone because they can hear us, for
+ // "<name> can hear you now". The native app's ConnectingWatch is its
+ // twin. Pure, and lifted by name in the node test.
+ //
+ // Clears when they say they hear us; or, for a client that does not
+ // say (hearsMe null), once the connection is up and either our audio is
+ // acknowledged or they have reported without a list. Said only past
+ // CONNECTING_GRACE, and given up at CONNECTING_CAP whatever happens.
+ function connectingStep(rec, now, people) {
+ const cleared = [];
+ for (const id of Array.from(rec.peers.keys())) if (!people.has(id)) rec.peers.delete(id);
+ for (const [id, p] of people) {
+ let r = rec.peers.get(id);
+ if (!r || r.epoch !== p.epoch) {
+ r = { epoch: p.epoch, since: now, done: false, shown: false };
+ rec.peers.set(id, r);
+ }
+ if (r.done) continue;
+ const fallback = p.hearsMe === null && p.connected && (p.acked || p.reported);
+ if (p.hearsMe === true || fallback) {
+ if (r.shown) cleared.push(id);
+ r.done = true;
+ r.shown = false;
+ } else if (now - r.since >= CONNECTING_CAP) {
+ r.done = true;
+ r.shown = false;
+ } else {
+ r.shown = now - r.since >= CONNECTING_GRACE;
+ }
+ }
+ return cleared;
+ }
+
+ // connectingNow: whether a person is still in their first moments, as
+ // far as we can tell not hearing us — the words may not show yet.
+ function connectingNow(id) {
+ const r = connecting.peers.get(id);
+ return !!r && !r.done;
+ }
+
+ // The words. The ellipsis is drawn apart from them (calls-ellipsis), so
+ // a screen reader hears the sentence and never the dots.
+ function connectingWords(name) {
+ return name + ' is connecting and can\u2019t hear you yet';
+ }
+ function canHearWords(name) {
+ return name + ' can hear you now';
+ }
+
+ // peerNotice is what a person's tile says about them and us, in order:
+ // in their first moments (`joining`), a more specific report about us
+ // (their browser blocking sound, or no sound from us) and otherwise
+ // "connecting" once the grace has passed (`shown`) — "Reconnecting"
+ // would be wrong of a connection that has never been up; after that,
+ // §15.23's "Reconnecting to …" first and then the report. Returns
+ // {kind, text}: kind '' for nothing, 'connecting' for the words with
+ // the ellipsis. Pure, and lifted by name in the node test.
+ function peerNotice(name, media, me, muted, joining, shown, reconnecting) {
+ const report = remoteNotice(name, media, me, muted);
+ if (joining) {
+ if (report) return { kind: 'report', text: report };
+ if (shown) return { kind: 'connecting', text: connectingWords(name) };
+ return { kind: '', text: '' };
+ }
+ if (reconnecting) return { kind: 'reconnecting', text: 'Reconnecting to ' + name + '\u2026' };
+ if (report) return { kind: 'report', text: report };
+ return { kind: '', text: '' };
+ }
+
+ // judgeHeard is this tab's half of §15.24 for one connection: whether
+ // the audio path from that person is established. Once it is, it stays
+ // so until the connection fails or closes. Established is: the
+ // connection up, and either their audio arriving — packets received and
+ // decoded samples coming out — on a live receiver, or, for someone who
+ // says they are muted, nothing more than that (a muted sender may send
+ // only silence, or no audio at all, and must not look unheard: the
+ // moment they unmute, it plays). `f` is {connected, bad, hasReceiver,
+ // receiverLive, packets, samples, senderMuted}. Pure, and lifted by
+ // name in the node test.
+ function judgeHeard(was, f) {
+ if (f.bad) return false;
+ if (was) return true;
+ if (!f.connected) return false;
+ if (f.senderMuted && (!f.hasReceiver || f.receiverLive)) return true;
+ return !!(f.receiverLive && f.packets > 0 && f.samples > 0);
+ }
+
+ // hearingList is the `hearing` this tab would report now: everyone
+ // whose audio path is established and whose tile is playing — not
+ // refused by an autoplay policy — sorted, at most fifteen.
+ function hearingList() {
+ const out = [];
+ for (const [id, entry] of pcs) {
+ if (!entry.heard) continue;
+ const t = tiles.get(id);
+ if (!t || refusedTiles.has(id)) continue;
+ if (t.video && t.video.paused && entry.audioLive) continue;
+ out.push(id);
+ }
+ return out.sort().slice(0, 15);
+ }
+
+ // watchConnecting feeds connectingStep what this tab knows, and says
+ // "<name> can hear you now" for anyone whose words have just gone.
+ function watchConnecting(now) {
+ const people = new Map();
+ for (const [id, entry] of pcs) {
+ const media = remoteMedia.get(id) || null;
+ const ice = entry.pc.iceConnectionState;
+ people.set(id, {
+ epoch: gen.get(id) || 0,
+ hearsMe: hearsYou(media, state.peer),
+ reported: !!media,
+ connected: entry.pc.connectionState === 'connected' || ice === 'connected' || ice === 'completed',
+ acked: !!entry.acked,
+ });
+ }
+ const was = Array.from(connecting.peers).filter(([, r]) => !r.done).map(([id]) => id).sort().join();
+ const wasShown = Array.from(connecting.peers).filter(([, r]) => r.shown).map(([id]) => id).sort().join();
+ const cleared = connectingStep(connecting, now, people);
+ for (const id of cleared) {
+ console.info('calls: ' + id.slice(0, 6) + ' can hear us');
+ announceMedia(canHearWords(names.get(id) || 'Someone'));
+ }
+ const is = Array.from(connecting.peers).filter(([, r]) => !r.done).map(([id]) => id).sort().join();
+ const isShown = Array.from(connecting.peers).filter(([, r]) => r.shown).map(([id]) => id).sort().join();
+ return is !== was || isShown !== wasShown;
+ }
+
// watchReconnect feeds the watch what this tab can see, five times a
// second, and redraws what changed.
function watchReconnect() {
if (state.stopped) return;
const now = Date.now();
+ const joiningMoved = watchConnecting(now);
const people = new Map();
for (const [id, entry] of pcs) {
+ // Someone in their first moments has not lost anything yet: §15.24
+ // speaks for them until it gives up.
+ if (connectingNow(id)) {
+ people.set(id, { connectionBad: false, audioMissing: false });
+ continue;
+ }
const ice = entry.pc.iceConnectionState;
const rec = hearing.get(id);
const media = remoteMedia.get(id);
@@ -4744,7 +4934,7 @@
console.info('calls: reconnecting ' + (reconnect.call ? 'shown (a problem that lasted)' : 'cleared'));
renderStatus();
}
- if (Array.from(reconnect.peers).sort().join() !== wasPeers) {
+ if (Array.from(reconnect.peers).sort().join() !== wasPeers || joiningMoved) {
for (const id of pcs.keys()) drawHearing(id);
}
}
@@ -5226,7 +5416,7 @@
const notice = document.createElement('span');
notice.className = 'calls-tile-notice';
notice.hidden = true;
- return { box, text, why, notice, kind: '', glow: '0.00', on: '0' };
+ return { box, text, why, notice, noticeText: '', noticeKind: '', kind: '', glow: '0.00', on: '0' };
}
// drawHearing brings one tile's hearing parts up to date. Called from
@@ -5277,11 +5467,17 @@
h.box.hidden = !kind;
}
if (self) return;
- const notice = reconnect.peers.has(key)
- ? 'Reconnecting to ' + name + '\u2026'
- : remoteNotice(name, media, state.peer, localMedia().muted);
- if (h.notice.textContent !== notice) {
+ const joining = connecting.peers.get(key);
+ const said = peerNotice(name, media, state.peer, localMedia().muted, !!joining && !joining.done,
+ !!joining && joining.shown, reconnect.peers.has(key));
+ const notice = said.text;
+ if (h.noticeText !== notice || h.noticeKind !== said.kind) {
+ h.noticeText = notice;
+ h.noticeKind = said.kind;
h.notice.textContent = notice;
+ if (said.kind === 'connecting') h.notice.append(ellipsisEl());
+ if (said.kind) h.notice.dataset.kind = said.kind;
+ else delete h.notice.dataset.kind;
h.notice.hidden = !notice;
}
if (rec && rec.notice !== notice) {
@@ -5290,6 +5486,21 @@
}
}
+ // ellipsisEl is three dots that pulse in turn (calls.css; one steady
+ // ellipsis under reduced motion), hidden from screen readers: the
+ // sentence is said once, the dots never.
+ function ellipsisEl() {
+ const dots = document.createElement('span');
+ dots.className = 'calls-ellipsis';
+ dots.setAttribute('aria-hidden', 'true');
+ for (let i = 0; i < 3; i++) {
+ const dot = document.createElement('span');
+ dot.textContent = '.';
+ dots.append(dot);
+ }
+ return dots;
+ }
+
// audioPart finds a connection's microphone sender or receiver — the
// camera m-line's audio, which is the only audio a connection carries
// (§5.4's screen has none).
@@ -5313,6 +5524,22 @@
return found;
}
+ // audioAcked reads our microphone's sender: packets have left, and the
+ // far side's receiver reports (remote-inbound-rtp, which exists only
+ // once one has arrived) say they are reaching it.
+ async function audioAcked(sender) {
+ const stats = await sender.getStats();
+ let sent = 0;
+ let acked = false;
+ stats.forEach((r) => {
+ if (r.kind !== 'audio' && r.mediaType !== 'audio') return;
+ if (r.type === 'outbound-rtp') sent = Math.max(sent, r.packetsSent || 0);
+ if (r.type === 'remote-inbound-rtp' && ((r.packetsReceived || 0) > 0 ||
+ (r.roundTripTimeMeasurements || 0) > 0 || typeof r.roundTripTime === 'number')) acked = true;
+ });
+ return sent > 0 && acked;
+ }
+
// speakerRecord is who has the floor, for Speaker view's stage.
function speakerRecord() {
return { current: '', since: -Infinity, candidate: '', candidateSince: 0 };
@@ -5391,6 +5618,31 @@
} catch {
// A closing connection: nothing heard.
}
+ // §15.24: whether the path from them is established, for `hearing`.
+ const pcState = entry.pc.connectionState;
+ const iceState = entry.pc.iceConnectionState;
+ const remote = remoteMedia.get(id) || null;
+ entry.audioLive = !!(receiver && receiver.track && receiver.track.readyState === 'live');
+ entry.heard = judgeHeard(!!entry.heard, {
+ connected: pcState === 'connected' || iceState === 'connected' || iceState === 'completed',
+ bad: pcState === 'failed' || pcState === 'closed',
+ hasReceiver: !!receiver,
+ receiverLive: entry.audioLive,
+ packets: report && typeof report.packetsReceived === 'number' ? report.packetsReceived : 0,
+ samples: report && typeof report.totalSamplesReceived === 'number' ? report.totalSamplesReceived : 0,
+ senderMuted: !!(remote && remote.muted),
+ });
+ // And for someone who does not say who they hear: whether their
+ // receiver reports show our audio reaching them. Asked only while
+ // it could still matter.
+ if (!entry.acked && connectingNow(id) && hearsYou(remote, state.peer) === null) {
+ const sender = audioPart(entry.pc, 'getSenders');
+ try {
+ entry.acked = sender ? await audioAcked(sender) : false;
+ } catch {
+ entry.acked = false;
+ }
+ }
if (!report) {
rec.level = 0;
rec.speaking = false;
@@ -5439,8 +5691,10 @@
else delete tilesEl.dataset.speaker;
}
const key = noSoundList().join(',');
- if (key !== noSoundKey) {
+ const heardKey = hearingList().join(',');
+ if (key !== noSoundKey || heardKey !== hearingKey) {
noSoundKey = key;
+ hearingKey = heardKey;
reportMedia();
}
for (const k of tiles.keys()) drawHearing(k);
@@ -6755,7 +7009,9 @@
speaking: t.label.dataset.glow === 'on',
glow: Number(t.hear.glow) || 0,
text: t.hear.box.hidden ? '' : t.hear.text.textContent,
- notice: t.hear.notice.hidden ? '' : t.hear.notice.textContent,
+ // The words alone, without the connecting notice's dots.
+ notice: t.hear.notice.hidden ? '' : t.hear.noticeText,
+ noticeKind: t.hear.notice.hidden ? '' : t.hear.noticeKind,
};
}
return out;
@@ -6978,6 +7234,12 @@
audioBlocked,
soundBanner: !!(soundBanner && !soundBanner.box.hidden),
noSoundFrom: noSoundList(),
+ // §15.24: who this tab reports it hears, and, per peer, whether
+ // they are still in their first moments (`joining`) and whether
+ // "… can't hear you yet" is showing for them.
+ hearingList: hearingList(),
+ connecting: Object.fromEntries(Array.from(connecting.peers,
+ ([id, r]) => [id, { joining: !r.done, shown: r.shown }])),
// Speaker view: who has the floor (speakerStep), by peer id.
speaker: stageSpeaker.current,
mediaExtras,
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index e3ca30c..e8356ac 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -122,6 +122,7 @@ var scenarios = []string{
"nowebrtc",
"playfallback",
"autoplay",
+ "connecting",
"scriptless",
}
diff --git a/internal/callstest/media_test.go b/internal/callstest/media_test.go
index f62dc18..229607f 100644
--- a/internal/callstest/media_test.go
+++ b/internal/callstest/media_test.go
@@ -121,6 +121,18 @@ func TestMediaShapes(t *testing.T) {
{"screenStalled sixteen ids", `{"muted":true,"cameraOff":false,"screenStalled":[` + sixteenIDs() + `]}`},
{"screenStalled twice", `{"muted":true,"cameraOff":false,"screenStalled":[],"screenStalled":[]}`},
{"screenStalled snake case", `{"muted":true,"cameraOff":false,"screen_stalled":[]}`},
+ // §15.24's hearing, held exactly as noSoundFrom is.
+ {"hearing null", `{"muted":true,"cameraOff":false,"hearing":null}`},
+ {"hearing a bool", `{"muted":true,"cameraOff":false,"hearing":true}`},
+ {"hearing an object", `{"muted":true,"cameraOff":false,"hearing":{"` + strings.Repeat("a", 32) + `":true}}`},
+ {"hearing a short id", `{"muted":true,"cameraOff":false,"hearing":["abc"]}`},
+ {"hearing an upper-case id", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("A", 32) + `"]}`},
+ {"hearing a number", `{"muted":true,"cameraOff":false,"hearing":[1]}`},
+ {"hearing a repeat", `{"muted":true,"cameraOff":false,"hearing":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`},
+ {"hearing sixteen ids", `{"muted":true,"cameraOff":false,"hearing":[` + sixteenIDs() + `]}`},
+ {"hearing twice", `{"muted":true,"cameraOff":false,"hearing":[],"hearing":[]}`},
+ {"hearing capitalised", `{"muted":true,"cameraOff":false,"Hearing":[]}`},
+ {"hearing alone", `{"hearing":[]}`},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
@@ -150,7 +162,7 @@ func TestMediaShapes(t *testing.T) {
}
})
t.Run("padded past the cap", func(t *testing.T) {
- payload := `{"muted":true,` + strings.Repeat(" ", 2048) + `"cameraOff":false}`
+ payload := `{"muted":true,` + strings.Repeat(" ", 3072) + `"cameraOff":false}`
res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":`+payload+`}`)
if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(rb), `"error":"too-large"`) {
t.Fatalf("%d %s, want 413 too-large", res.StatusCode, rb)
@@ -250,6 +262,58 @@ func TestMediaCarriesStalledScreens(t *testing.T) {
}
}
+// Spec §15.24: who this client can actually hear. Good news, unlike the
+// lists before it, and its presence is news too: an empty list ("nobody
+// yet") travels as [] and is a different report from one without the
+// key, which is a client that does not say. The longest honest report —
+// all three lists full — fits under the cap.
+func TestMediaCarriesWhoTheReceiverHears(t *testing.T) {
+ a, sqid, pa, sb := chatPair(t)
+ res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("empty hearing: %d %s", res.StatusCode, rb)
+ }
+ _, data, ok := sb.nextOf("media", 2*time.Second)
+ if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false,"hearing":[]}`) {
+ t.Fatalf("empty hearing must travel as []: %s", data)
+ }
+ heard := strings.Repeat("0", 31) + "3"
+ res, rb = a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":{"hearing":["`+heard+`"],"muted":true,"cameraOff":false,"noSoundFrom":["`+heard+`"]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("hearing: %d %s", res.StatusCode, rb)
+ }
+ _, data, ok = sb.nextOf("media", 2*time.Second)
+ want := `"payload":{"muted":true,"cameraOff":false,"noSoundFrom":["` + heard + `"],"hearing":["` + heard + `"]}`
+ if !ok || !strings.Contains(string(data), want) {
+ t.Fatalf("envelope %s, want %s", data, want)
+ }
+ ids := strings.Join(strings.Split(sixteenIDs(), ",")[:15], ",")
+ full := `{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":[` + ids + `],"screenStalled":[` + ids + `],"hearing":[` + ids + `]}`
+ res, rb = a.signal(sqid, pa, `{"type":"media","payload":`+full+`}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("all three lists full: %d %s", res.StatusCode, rb)
+ }
+ _, data, ok = sb.nextOf("media", 2*time.Second)
+ if !ok || !strings.Contains(string(data), `"payload":`+full) {
+ t.Fatalf("full report %s", data)
+ }
+ // Without the key: a different report from the empty list, so it is
+ // a change and goes out as the two-key object.
+ res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"hearing":[]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("back to empty: %d", res.StatusCode)
+ }
+ sb.nextOf("media", 2*time.Second)
+ res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("no key: %d", res.StatusCode)
+ }
+ _, data, ok = sb.nextOf("media", 2*time.Second)
+ if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) {
+ t.Fatalf("absent hearing must be a change from [], got %s", data)
+ }
+}
+
// A waiting guest has nobody to tell: its report is the usual 404, and
// it hears nothing of the room's.
func TestMediaFromAWaitingGuestIsTheUsual404(t *testing.T) {
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index 0c1acb2..1c84c10 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -140,6 +140,21 @@ func (c *Client) SetScreenStalled(peersJSON string) error {
return c.room.SetScreenStalled(ids)
}
+// SetHearing is spec §15.24: the peers whose audio this app is actually
+// receiving and playing, as a JSON array of peer ids. Reported with the
+// rest of this app's media state; "[]" or "" is "nobody yet", which is
+// still said — the key's presence is what tells the others this app
+// reports it.
+func (c *Client) SetHearing(peersJSON string) error {
+ ids := []protocol.PeerID{}
+ if peersJSON != "" {
+ if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil {
+ return errors.New("invalid peer list")
+ }
+ }
+ return c.room.SetHearing(ids)
+}
+
// RegisterDevice hands the server an Apple device token so a knock can
// reach this device when the app is closed. Best effort: the caller
// ignores the error beyond logging, because a device that did not
diff --git a/protocol/messages.go b/protocol/messages.go
index df779e6..ea7f11c 100644
--- a/protocol/messages.go
+++ b/protocol/messages.go
@@ -139,6 +139,15 @@ type MediaState struct {
// recover (a keyframe first). Bad news only, omitted when there is
// none, at most MaxScreenStalled, each a peer id, none repeated.
ScreenStalled []PeerID `json:"screenStalled,omitempty"`
+ // Hearing (spec §15.24, 2026-10-01): the peers whose audio this client
+ // is actually receiving and playing — the path from them is
+ // established, so they can be heard the moment they speak. Unlike the
+ // members above it is good news, and its presence is itself news: nil
+ // (the key absent) is a client that does not say, an old one, and an
+ // empty list is a client that says it hears nobody yet. So it is
+ // omitted only when nil (omitzero), and an empty list travels as [].
+ // At most MaxHearing, each a peer id, none repeated.
+ Hearing []PeerID `json:"hearing,omitzero"`
}
// MaxNoSoundFrom bounds MediaState.NoSoundFrom: a room holds at most
@@ -149,6 +158,9 @@ const MaxNoSoundFrom = 15
// MaxScreenStalled bounds MediaState.ScreenStalled for the same reason.
const MaxScreenStalled = 15
+// MaxHearing bounds MediaState.Hearing for the same reason.
+const MaxHearing = 15
+
// Equal compares two reports field by field; the peer lists are compared
// in order, which is fine because the hub only asks whether a report
// changed, and a reordered list is a report worth passing on.
@@ -156,7 +168,11 @@ func (m MediaState) Equal(o MediaState) bool {
if m.Muted != o.Muted || m.CameraOff != o.CameraOff || m.AudioBlocked != o.AudioBlocked {
return false
}
- return samePeers(m.NoSoundFrom, o.NoSoundFrom) && samePeers(m.ScreenStalled, o.ScreenStalled)
+ if (m.Hearing == nil) != (o.Hearing == nil) {
+ return false
+ }
+ return samePeers(m.NoSoundFrom, o.NoSoundFrom) && samePeers(m.ScreenStalled, o.ScreenStalled) &&
+ samePeers(m.Hearing, o.Hearing)
}
func samePeers(a, b []PeerID) bool {
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 00fe3c8..15a63f9 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -415,6 +415,10 @@ function state(page) {
remoteMedia: s.remoteMedia,
// Speaker view: who has the floor, by peer id ('' before anyone).
speaker: s.speaker,
+ // §15.24: who this tab says it hears, and per peer whether they
+ // are still in their first moments and the words are showing.
+ hearingList: s.hearingList,
+ connecting: s.connecting,
};
});
}
@@ -4548,6 +4552,11 @@ const run = {
JSON.stringify(notice || ((await state(pageB)).hearing || {})[a]) + ')');
check(notice && notice.notice === nameA + '’s browser is blocking sound — they need to click to hear you.',
'naming A (' + (notice && notice.notice) + ')');
+ // §15.24: A is new to B and cannot hear B yet, but its own report
+ // is more specific than "connecting", and replaces it.
+ const first = (await state(pageB)).connecting[a];
+ check(notice && notice.noticeKind === 'report' && first && first.joining,
+ 'in place of "' + nameA + ' is connecting and can’t hear you yet" (' + JSON.stringify({ notice, first }) + ')');
// A click on nothing in particular: the page's own heading.
const heading = await pageA.locator('h1').first().boundingBox();
@@ -4582,6 +4591,166 @@ const run = {
}
},
+ // Spec §15.24 (2026-10-01): "<name> is connecting and can't hear you
+ // yet". A fast join says nothing; a join whose connection is held back
+ // says it on the joiner's tile, with dots a screen reader never hears,
+ // and clears with "… can hear you now" the moment the joiner's own
+ // report lists us in `hearing`; and a joiner that does not send
+ // `hearing` (a client from before it, played by stripping the key off
+ // its reports) is let go on its first report once the connection is up.
+ // CALLS_CONNECTING_SNAPSHOTS=<dir> keeps pictures of the notice.
+ async connecting() {
+ const out = process.env.CALLS_CONNECTING_SNAPSHOTS;
+ if (out) mkdirSync(out, { recursive: true });
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const admitted = await until(async () => ((await state(pageA)).admitted ? true : false), 20000);
+ check(admitted, 'A is in the call on its own — ' + (await diagnose(pageA, 'member A')));
+ const a = (await state(pageA)).peer;
+ // A watches itself, ten times a second, from inside the page: whether
+ // the words ever showed for anyone, when each person appeared, and
+ // when their first moments ended; and everything its live region said.
+ const watchA = () => pageA.evaluate(() => {
+ window.__joins = {};
+ window.__said = [];
+ const live = document.getElementById('media-live');
+ if (live) new MutationObserver(() => window.__said.push(live.textContent)).observe(live, { childList: true, characterData: true, subtree: true });
+ setInterval(() => {
+ const s = window.meet.state();
+ for (const [id, c] of Object.entries(s.connecting || {})) {
+ const j = window.__joins[id] || (window.__joins[id] = { from: Date.now(), done: 0, shown: false });
+ if (c.shown) j.shown = true;
+ if (!c.joining && !j.done) j.done = Date.now();
+ }
+ }, 100);
+ });
+ await watchA();
+ const joins = () => pageA.evaluate(() => window.__joins);
+ const said = () => pageA.evaluate(() => window.__said);
+
+ // 1. A fast join says nothing.
+ let pageB = await openRoom(ctxB);
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ let b = pair.b.peer;
+ const nameB = await selfName(pageB);
+ const heardA = await until(async () => ((await state(pageB)).hearingList || []).includes(a), 15000);
+ check(heardA, 'B reports that it hears A (' + JSON.stringify((await state(pageB)).hearingList) + ')');
+ const fast = await until(async () => {
+ const j = (await joins())[b];
+ return j && j.done ? j : false;
+ }, 15000);
+ check(fast && !fast.shown, 'a fast join never says "can’t hear you yet" (' + JSON.stringify(fast) + ', ' +
+ (fast ? fast.done - fast.from : '?') + ' ms from appearing to being heard)');
+ check(!(await said()).some((t) => /hear you/.test(t)), 'and nothing about it is announced (' + JSON.stringify(await said()) + ')');
+ await leaveRoom(pageB);
+ await pageB.close();
+
+ // 2. B again, with everything that would connect it held back for a
+ // few seconds: its offers, answers and candidates.
+ const HOLD = 6000;
+ let releaseAt = 0;
+ const holdSignals = async (route) => {
+ const body = route.request().postData() || '';
+ if (/"type":"(offer|answer|candidate)"/.test(body)) {
+ if (!releaseAt) releaseAt = Date.now() + HOLD;
+ await sleep(Math.max(0, releaseAt - Date.now()));
+ }
+ await route.continue();
+ };
+ await ctxB.route('**/signal', holdSignals);
+ await pageA.evaluate(() => { window.__said = []; });
+ pageB = await openRoom(ctxB);
+ const joined = await until(async () => {
+ const s = await state(pageB);
+ return s.admitted && s.peer ? s.peer : false;
+ }, 20000);
+ check(joined, 'B is back, connection held — ' + (await diagnose(pageB, 'member B')));
+ b = joined;
+ const words = nameB + ' is connecting and can’t hear you yet';
+ const showing = await until(async () => {
+ const s = await state(pageA);
+ const h = s.hearing[b];
+ return h && h.noticeKind === 'connecting' ? h : false;
+ }, 10000);
+ check(showing && showing.notice === words, 'A says it on B’s tile (' + JSON.stringify(showing) + ')');
+ const drawn = await pageA.evaluate((id) => {
+ const n = document.querySelector('figure[data-tile="' + id + '"] .calls-tile-notice');
+ const dots = n && n.querySelector('.calls-ellipsis');
+ return n && { visible: !n.hidden && n.offsetWidth > 0, dots: !!dots, hidden: dots && dots.getAttribute('aria-hidden') };
+ }, b);
+ check(drawn && drawn.visible && drawn.dots && drawn.hidden === 'true',
+ 'on screen, with the dots hidden from screen readers (' + JSON.stringify(drawn) + ')');
+ check((await said()).includes(words), 'and it is announced once, without the dots (' + JSON.stringify(await said()) + ')');
+ if (out) await pageA.screenshot({ path: out + '/web-connecting.png' });
+ await pageA.emulateMedia({ reducedMotion: 'reduce' });
+ const still = await pageA.evaluate((id) => {
+ const dot = document.querySelector('figure[data-tile="' + id + '"] .calls-ellipsis > span');
+ return dot ? getComputedStyle(dot).animationName : null;
+ }, b);
+ check(still === 'none', 'under reduced motion the dots keep still (' + still + ')');
+ if (out) await pageA.screenshot({ path: out + '/web-connecting-reduced-motion.png' });
+ await pageA.emulateMedia({ reducedMotion: 'no-preference' });
+
+ const clear = await until(async () => {
+ const s = await state(pageA);
+ const h = s.hearing[b];
+ return h && h.notice === '' && s.connecting[b] && !s.connecting[b].joining ? s : false;
+ }, 30000);
+ check(clear, 'once B hears A, the words go — ' + (await diagnose(pageA, 'member A')));
+ check(clear && ((clear.remoteMedia[b] || {}).hearing || []).includes(a), 'because B’s own report says so (' +
+ JSON.stringify(clear && clear.remoteMedia[b]) + ')');
+ check(releaseAt > 0 && Date.now() >= releaseAt, 'and not before the hold let go');
+ check((await said()).includes(nameB + ' can hear you now'), 'and A hears that B can hear it now (' + JSON.stringify(await said()) + ')');
+ await ctxB.unroute('**/signal', holdSignals);
+ await leaveRoom(pageB);
+ await pageB.close();
+
+ // 3. B as a client that does not say who it hears: the key stripped
+ // off every report, and the connection held back as before.
+ releaseAt = 0;
+ const oldClient = async (route) => {
+ let body = route.request().postData() || '';
+ if (body.includes('"type":"media"')) {
+ const sig = JSON.parse(body);
+ delete sig.payload.hearing;
+ body = JSON.stringify(sig);
+ return route.continue({ postData: body });
+ }
+ return holdSignals(route);
+ };
+ await ctxB.route('**/signal', oldClient);
+ pageB = await openRoom(ctxB);
+ const back = await until(async () => {
+ const s = await state(pageB);
+ return s.admitted && s.peer ? s.peer : false;
+ }, 20000);
+ check(back, 'B is back as an old client — ' + (await diagnose(pageB, 'member B')));
+ b = back;
+ const oldShowing = await until(async () => {
+ const h = (await state(pageA)).hearing[b];
+ return h && h.noticeKind === 'connecting' ? h : false;
+ }, 10000);
+ check(oldShowing, 'the words show for an old client too (' + JSON.stringify(oldShowing) + ')');
+ const oldClear = await until(async () => {
+ const s = await state(pageA);
+ return s.connecting[b] && !s.connecting[b].joining && s.hearing[b] && s.hearing[b].notice === '' ? s : false;
+ }, 30000);
+ check(oldClear && oldClear.remoteMedia[b] && !('hearing' in oldClear.remoteMedia[b]) &&
+ oldClear.connection[b] === 'connected',
+ 'and go once its connection is up, without any `hearing` from it (' + JSON.stringify(oldClear && oldClear.remoteMedia[b]) + ')');
+ check(Date.now() - (releaseAt || Date.now()) < 20000, 'well before the twenty-second cap');
+ await ctxB.unroute('**/signal', oldClient);
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// Speaker view and the viewer's order (0.4.0), in a call of four: two
// members and two guests. Everyone's microphone is silenced but one,
// which speaks a tone; the stage goes to that person only once they
diff --git a/test/browser/room-connecting.test.mjs b/test/browser/room-connecting.test.mjs
new file mode 100644
index 0000000..41fc497
--- /dev/null
+++ b/test/browser/room-connecting.test.mjs
@@ -0,0 +1,186 @@
+// room-connecting.test.mjs — "<name> is connecting and can't hear you yet"
+// (spec §15.24, 2026-10-01), gated without a browser.
+//
+// node --test test/browser/room-connecting.test.mjs
+//
+// The functions are lifted out of the shipped room.js by name, with the
+// constants they read, as room-reconnect.test.mjs does:
+//
+// connectingStep the watch: the grace, clearing on `hearing`, the
+// fallback for a client that does not say, the cap, and
+// a rejoin starting it again
+// hearsYou a peer's report on us: true, false, or does not say
+// peerNotice what a tile says, and what replaces what
+// judgeHeard this tab's own half: when a path counts as heard
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const src = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8');
+
+function lift(name) {
+ const start = src.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
+ if (src[i] === '{') depth += 1;
+ else if (src[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return src.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name);
+}
+function liftConst(name) {
+ const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);'));
+ assert.ok(m, name + ' is not a numeric const in room.js any more');
+ return 'const ' + name + ' = ' + m[1] + ';';
+}
+const CONSTS = ['CONNECTING_GRACE', 'CONNECTING_CAP'].map(liftConst).join('\n');
+const names = ['connectingRecord', 'connectingStep', 'hearsYou', 'peerNotice', 'remoteNotice', 'connectingWords',
+ 'canHearWords', 'judgeHeard'];
+// eslint-disable-next-line no-new-func
+const fns = new Function(CONSTS + '\n' + names.map(lift).join('\n') + '; return { ' + names.join(', ') +
+ ', GRACE: CONNECTING_GRACE, CAP: CONNECTING_CAP };')();
+const { connectingRecord, connectingStep, hearsYou, peerNotice, judgeHeard, GRACE, CAP } = fns;
+
+const ME = 'm'.repeat(32);
+const SAM = 's'.repeat(32);
+const waiting = { epoch: 1, hearsMe: false, reported: true, connected: false, acked: false };
+
+// Five looks a second from `from` to `to` seconds; when Sam's words
+// showed, and when they cleared with "can hear you now".
+function run(rec, from, to, sam) {
+ const shown = [];
+ const cleared = [];
+ for (let t = from * 10; t < to * 10; t += 2) {
+ const s = Math.round(t) / 10;
+ const p = sam(s);
+ const people = p ? new Map([[SAM, p]]) : new Map();
+ if (connectingStep(rec, s * 1000, people).includes(SAM)) cleared.push(s);
+ const r = rec.peers.get(SAM);
+ if (r && r.shown) shown.push(s);
+ }
+ return { shown, cleared };
+}
+
+test('the timings are the ones the spec names', () => {
+ assert.equal(GRACE, 1000);
+ assert.equal(CAP, 20000);
+});
+
+test('an ordinary join, heard within the grace, says nothing at all', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, (t) => ({ ...waiting, connected: t >= 0.4, hearsMe: t >= 0.8 }));
+ assert.deepEqual(seen.shown, []);
+ assert.deepEqual(seen.cleared, [], 'nothing was said, so "can hear you now" is not said either');
+ assert.equal(rec.peers.get(SAM).done, true);
+});
+
+test('a slow join says so after the grace, and clears the moment they hear us', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 12, (t) => ({ ...waiting, connected: t >= 5, hearsMe: t >= 6 }));
+ assert.equal(seen.shown[0], 1);
+ assert.equal(seen.shown.at(-1), 5.8);
+ assert.deepEqual(seen.cleared, [6]);
+});
+
+test('once cleared it stays cleared, whatever their list does after', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 30, (t) => ({ ...waiting, connected: true, hearsMe: t >= 3 && t < 10 }));
+ assert.deepEqual(seen.cleared, [3]);
+ assert.equal(seen.shown.at(-1), 2.8, '§15.23 and §15.20 speak for a problem after that');
+});
+
+test('it is given up at the cap, silently', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 40, () => waiting);
+ assert.equal(seen.shown[0], 1);
+ assert.equal(seen.shown.at(-1), 19.8);
+ assert.deepEqual(seen.cleared, [], 'a cap is not their hearing us');
+ assert.equal(rec.peers.get(SAM).done, true);
+});
+
+test('a client that does not say who it hears: its first report and a connection clear it', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: t >= 4, connected: t >= 2, acked: false }));
+ assert.deepEqual(seen.cleared, [4]);
+});
+
+test('or a connection and receiver reports showing our audio reaching them', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: false, connected: t >= 2, acked: t >= 3 }));
+ assert.deepEqual(seen.cleared, [3]);
+ const rec2 = connectingRecord();
+ const unconnected = run(rec2, 0, 10, () => ({ epoch: 1, hearsMe: null, reported: true, connected: false, acked: true }));
+ assert.deepEqual(unconnected.cleared, [], 'never without a connection');
+});
+
+test('a client that says who it hears is believed over our own evidence', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 10, () => ({ epoch: 1, hearsMe: false, reported: true, connected: true, acked: true }));
+ assert.deepEqual(seen.cleared, []);
+ assert.equal(seen.shown.length > 0, true);
+});
+
+test('a rejoin starts it again, and so does a new connection to the same person', () => {
+ const rec = connectingRecord();
+ const seen = run(rec, 0, 20, (t) => {
+ if (t >= 5 && t < 6) return null; // gone from the roster
+ if (t < 5) return { ...waiting, connected: true, hearsMe: t >= 2 };
+ return { ...waiting, connected: t >= 9, hearsMe: t >= 9 };
+ });
+ assert.deepEqual(seen.cleared, [2, 9]);
+ assert.equal(seen.shown.includes(7), true);
+ assert.equal(seen.shown.includes(6.8), false, 'the grace again from the moment they are back');
+
+ const rec2 = connectingRecord();
+ const rebuilt = run(rec2, 0, 20, (t) => ({ ...waiting, epoch: t < 8 ? 1 : 2, connected: true, hearsMe: t < 1 || t >= 12 }));
+ assert.deepEqual(rebuilt.cleared, [12]);
+ assert.equal(rebuilt.shown[0], 9);
+});
+
+test('hearsYou: listed, not listed, or not saying', () => {
+ assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [ME] }, ME), true);
+ assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [] }, ME), false);
+ assert.equal(hearsYou({ muted: false, cameraOff: false }, ME), null);
+ assert.equal(hearsYou(null, ME), null);
+});
+
+test('peerNotice: a more specific report replaces "connecting", and "Reconnecting" waits until after', () => {
+ const plain = { muted: false, cameraOff: false, hearing: [] };
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, true, false, false), { kind: '', text: '' }, 'inside the grace');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, false),
+ { kind: 'connecting', text: 'Sam is connecting and can’t hear you yet' });
+ assert.deepEqual(peerNotice('Sam', plain, ME, true, true, true, false).kind, 'connecting',
+ 'our own mute does not matter: it is about their hearing us when we speak');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, true).kind, 'connecting',
+ 'a connection that has never been up is not reconnecting');
+ assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, true, false, false),
+ { kind: 'report', text: 'Sam’s browser is blocking sound — they need to click to hear you.' },
+ 'even inside the grace: it is their own report');
+ assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, false, true, true, false),
+ { kind: 'report', text: 'Sam isn’t getting any sound from you.' });
+ assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, true, true, true, false).kind, 'connecting',
+ 'no sound from us while we are muted is expected, so it does not replace anything');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, true),
+ { kind: 'reconnecting', text: 'Reconnecting to Sam…' });
+ assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, false, false, false).kind, 'report');
+ assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, false), { kind: '', text: '' });
+});
+
+test('judgeHeard: arriving and decoded, or a muted sender on a live connection', () => {
+ const base = { connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, samples: 0, senderMuted: false };
+ assert.equal(judgeHeard(false, base), false, 'nothing has arrived');
+ assert.equal(judgeHeard(false, { ...base, packets: 10 }), false, 'packets, but nothing decoded');
+ assert.equal(judgeHeard(false, { ...base, packets: 10, samples: 480 }), true);
+ assert.equal(judgeHeard(false, { ...base, connected: false, packets: 10, samples: 480 }), false, 'not before the connection is up');
+ assert.equal(judgeHeard(false, { ...base, senderMuted: true }), true, 'a muted sender must not look unheard');
+ assert.equal(judgeHeard(false, { ...base, senderMuted: true, hasReceiver: false, receiverLive: false }), true,
+ 'nor one with no microphone at all');
+ assert.equal(judgeHeard(false, { ...base, senderMuted: true, receiverLive: false }), false, 'but not on a dead receiver');
+ assert.equal(judgeHeard(true, base), true, 'once heard, kept while the connection lasts');
+ assert.equal(judgeHeard(true, { ...base, connected: false }), true, 'a blip is not losing them');
+ assert.equal(judgeHeard(true, { ...base, bad: true }), false, 'a failed connection is');
+});
diff --git a/test/browser/room-mute.test.mjs b/test/browser/room-mute.test.mjs
index 20dfcb8..cce4d08 100644
--- a/test/browser/room-mute.test.mjs
+++ b/test/browser/room-mute.test.mjs
@@ -162,7 +162,7 @@ test('the dispatch routes media, and the report rides the broadcast lane', () =>
// a report goes, and that is the pair of them.
function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true, status = 200 } = {}) {
const sent = [];
- const env = { local, answer, status, blocked: false, noSound: [] };
+ const env = { local, answer, status, blocked: false, noSound: [], heard: null };
const made = new Function(
'state', 'remoteMedia', 'localMedia', 'broadcast', 'env',
['mediaFrom', 'sameMedia', 'mediaKey', 'mediaPayload', 'learnMedia', 'reportMedia', 'rosterMedia']
@@ -172,6 +172,7 @@ function reporter({ admitted = true, local = { muted: false, cameraOff: false },
'\nlet audioBlocked = false;' +
'\nlet screenStalled = [];' +
'\nconst noSoundList = () => env.noSound;' +
+ '\nconst hearingList = () => env.heard;' +
'\nreturn { rosterMedia, reportMedia, memo: () => mediaSent, extras: () => mediaExtras,' +
'\n block: (on) => { audioBlocked = on; } };',
)(
@@ -278,3 +279,45 @@ test('a 400 for the extras falls back to the two keys, at once and for good', as
r.reportMedia();
assert.deepEqual(r.sent.at(-1)[1], { muted: true, cameraOff: false });
});
+
+// §15.24: `hearing` is good news and travels whenever it is given, []
+// included — its presence is what tells the others this tab says who it
+// hears — and it is read back the same way: an empty list is kept, and
+// is a different report from one without the key.
+test('hearing is carried even when empty, and read back as given', () => {
+ assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], []),
+ { muted: false, cameraOff: false, hearing: [] });
+ assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], [P1, P2]),
+ { muted: false, cameraOff: false, hearing: [P1, P2] });
+ assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], true, [], [P1]),
+ { muted: false, cameraOff: false }, 'a server that refused the extras gets none');
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [] }), { muted: false, cameraOff: false, hearing: [] });
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [P1, 'nope', P1] }),
+ { muted: false, cameraOff: false, hearing: [P1] });
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: 'all' }), { muted: false, cameraOff: false });
+ assert.notEqual(mediaKey({ muted: false, cameraOff: false, hearing: [] }), mediaKey({ muted: false, cameraOff: false }));
+ assert.equal(mediaKey({ muted: false, cameraOff: false, hearing: [P2, P1] }), mediaKey({ muted: false, cameraOff: false, hearing: [P1, P2] }));
+});
+
+test('who this tab hears is reported on a change, and a roster that agrees costs nothing', () => {
+ const r = reporter();
+ r.env.heard = [];
+ r.rosterMedia({ peers: [{ peer: 'me' }] });
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [] });
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false, hearing: [] } }] });
+ assert.equal(r.sent.length, 1);
+ r.env.heard = [P1];
+ r.reportMedia();
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [P1] });
+ r.reportMedia();
+ assert.equal(r.sent.length, 2);
+});
+
+test('a 400 for a report carrying hearing falls back to the two keys', async () => {
+ const r = reporter({ status: 400 });
+ r.env.heard = [];
+ r.reportMedia();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(r.extras(), false);
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false });
+});