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>
15 files changed,
+1068
−52
Makefile+1 −0callscore/media_test.go+112 −3callscore/room.go+69 −22internal/calls/hub/media.go+6 −1internal/calls/hub/media_test.go+71 −0internal/calls/signal.go+18 −8internal/calls/static/calls.css+17 −0internal/calls/static/room.js+277 −15internal/callstest/browser_test.go+1 −0internal/callstest/media_test.go+65 −1native/bridge/bridge.go+15 −0protocol/messages.go+17 −1test/browser/call.mjs+169 −0test/browser/room-connecting.test.mjs+186 −0test/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 }); |
| +}); |