Merge feat/cant-hear-you-yet: "… is connecting and can't hear you yet" (§15.24)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
31 files changed,
+2048
−80
Makefile+1 −0callscore/media_test.go+112 −3callscore/room.go+69 −22docs/superpowers/specs/2026-09-07-meet-design.md+99 −0internal/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+292 −15internal/callstest/browser_test.go+1 −0internal/callstest/media_test.go+65 −1native/README.md+9 −0native/apple/Sources/CallsInterop/CallSnapshots.swift+46 −0native/apple/Sources/CallsInterop/Interop.swift+6 −0native/apple/Sources/CallsKit/AudioMeter.swift+63 −8native/apple/Sources/CallsKit/AudioWork.swift+6 −0native/apple/Sources/CallsKit/Call.swift+114 −8native/apple/Sources/CallsKit/CallFixtures.swift+8 −0native/apple/Sources/CallsKit/ConnectingWatch.swift+172 −0native/apple/Sources/CallsKit/Media.swift+51 −7native/apple/Sources/CallsKit/MiniWindow.swift+52 −1native/apple/Sources/CallsKit/Wire.swift+6 −1native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift+240 −0native/bridge/bridge.go+15 −0native/ios/UITests/CallsPhoneUITests.swift+24 −0protocol/messages.go+17 −1test/browser/call.mjs+184 −1test/browser/native-auth.mjs+3 −2test/browser/native.mjs+41 −0test/browser/room-connecting.test.mjs+196 −0test/browser/room-mute.test.mjs+44 −1
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore 7.288s ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.627s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub 0.358s ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 8.829s ? amadan.net/oficina/calls/protocol [no test files] ? amadan.net/oficina/calls/scripts/writeestilo [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 10.359s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub 2.912s ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 28.505s
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/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 243ec8e..97222f9 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -2566,3 +2566,102 @@ the apps, `RECONNECT_*` in room.js) with unit tests either side; the browser |
| gate's `renewal` scenario and the native gate's `resume` and `renewals` watch |
| for the words through several renewals and fail on any. The core's phase is |
| unchanged: controls that need an admitted call still follow it. |
| + |
| +### 15.24 §15.19, §15.20 — "… is connecting and can't hear you yet" (2026-10-01) |
| + |
| +**Why.** Paul, 2026-10-01, asking for what Zoom shows: "didemeski is |
| +connecting to audio and cannot hear you yet …". When someone joins, there is |
| +a gap — a few hundred milliseconds on a good network, seconds on a bad one, |
| +for ever on a broken one — before their side is receiving and playing your |
| +audio, and nothing said so: people greeted a newcomer who could not yet hear |
| +them. Nothing on the wire said whether anyone could hear anyone; §15.20 only |
| +carried bad news after five seconds of flat sound. |
| + |
| +**Decision.** A positive receive signal, and a notice built on it. |
| + |
| +- **`hearing`.** §15.19's `media` gains one more optional member: the peers |
| + whose audio this client is actually receiving and playing. Unlike §15.20's |
| + and §15.21's lists it is good news, and its presence is itself news: an |
| + absent list is a client that does not say (one from before this section), |
| + `[]` is a client that hears nobody yet. So it is sent whenever the server |
| + takes it, `[]` included, and the hub keeps it with its emptiness — `[]` |
| + travels as `[]`, and a change between absent and `[]` is a change. |
| + Otherwise it is held as `noSoundFrom` is: at most fifteen distinct |
| + well-formed peer ids, checked for shape and not presence, a `null`, a |
| + non-array, a repeat or a sixteenth id refused 400; re-encoded last |
| + (`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`, `screenStalled`, |
| + `hearing`). The raw payload cap is 3 KB, room for all three lists full |
| + (about 1,700 bytes) and whitespace. Sent only on a change, like the rest. |
| +- **The rule — who a client lists.** A peer is listed once the audio path |
| + from them is established and is playing here: |
| + - *established*: the connection is up (ICE connected or completed) and |
| + either their audio is arriving — inbound packets received and decoded |
| + samples coming out (`totalSamplesReceived` in the browser; frames at the |
| + receive tap in the apps) on a live receiver — or **they say they are |
| + muted**, in which case the connection being up and the receiver live |
| + (or no audio receiver at all, for someone with no microphone) is enough. |
| + A muted sender may send only silence, or nothing, and must not look |
| + unheard: the moment they unmute, it plays. Once established it stays so |
| + until the connection fails or closes; a blip is not losing them. |
| + - *playing*: in the browser, the tile's element is playing and was not |
| + refused by an autoplay policy (a refused tile is §15.20's |
| + `audioBlocked`, and is not listed until a click plays it). The apps play |
| + whatever they decode; there is no policy to refuse them. |
| +- **The notice.** From the moment a person appears in the roster — or |
| + appears again, or is given a new connection (a new pair generation) — |
| + their tile says "Samuel is connecting and can't hear you yet", with three |
| + dots after it that brighten in turn (one steady ellipsis under reduced |
| + motion). It goes the moment their report lists you in `hearing`, and a |
| + screen reader is told "Samuel can hear you now". In order: |
| + - **The grace.** It is not said at all if they hear you within 1 s of |
| + appearing (`CONNECTING_GRACE`, `ConnectingWatch.grace`), so ordinary |
| + joins say nothing. The browser gate measured 284 ms from appearing to |
| + being heard on a loopback. |
| + - **More specific news replaces it**: their `audioBlocked` ("Samuel's |
| + browser is blocking sound — they need to click to hear you"), or you in |
| + their `noSoundFrom` while you are unmuted ("Samuel isn't getting any |
| + sound from you"). Those are their own reports and show even inside the |
| + grace. |
| + - **"Reconnecting to Samuel…" waits until after** (§15.23): a connection |
| + that has never been up is not reconnecting. While someone is in their |
| + first moments the reconnect watch is told nothing is wrong with them, |
| + so a slow join of two people is never "Reconnecting…" for the call. |
| + - **A client that does not say** (no `hearing` in its report — a client |
| + from before this section, or any client on a server that refused the |
| + key) is let go once the connection to it is up and either its receiver |
| + reports show your audio reaching it (`remote-inbound-rtp` for your |
| + microphone, which exists only once one has come back, with packets |
| + sent) or it has made a media report without the list. A client that |
| + does say is believed over this evidence. |
| + - **The cap.** Whatever happens, it is given up 20 s after they appeared |
| + (`CONNECTING_CAP`, `ConnectingWatch.cap`), silently; §15.20's "isn't |
| + getting any sound" and §15.23's "Reconnecting to …" speak for any |
| + problem after that. |
| + - **Your own mute does not matter**: it is about their being able to |
| + hear you once you speak. |
| +- **Where.** On the person's tile, in the place and the glass of the other |
| + tile notices, with an ear where they have a warning triangle: every |
| + layout on the Mac and the iPhone, and the browser's call window. In the |
| + browser and in the apps' Grid, Speaker and Classic every person in the |
| + roster has a tile from the roster on, so there is no call-level form; |
| + the Mac's mini window, which shows one person, puts a small ear on its |
| + glass for anyone else still connecting (their names in its tooltip and |
| + for VoiceOver), and its tab shows the same ear. Screen readers hear the |
| + sentence once when it appears and "… can hear you now" when it goes, |
| + politely, and never the dots. |
| + |
| +**Compatibility, both orders.** Old clients ignore the member (the browser's |
| +`mediaFrom`, the core's decoder and the apps' `PeerMedia` all drop unknown |
| +keys). A server from before this section refuses a report carrying it |
| +(400): the browser then drops the extras for the rest of the page (§15.20) |
| +— though the browser's page is served by the same server, so only a page |
| +open across a deploy meets that — and the apps' core resends the same |
| +report without `hearing` and leaves it out from then on, then without |
| +`screenStalled` too if that is refused next, so a mute is never lost. The |
| +people on new clients then fall back to their own evidence about that |
| +client, as they do for an old one. So the server and the clients may ship |
| +in either order. |
| + |
| +**Not logged, not stored.** As §15.19. The apps log "… can hear us" in the |
| +connection log (`os_log` category `connection`), the browser on its |
| +console, each with the first six characters of the peer id only. |
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..ee8aefe 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,193 @@ |
| 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); |
| + } |
| + |
| + // hearingMoved is whether `hearing` has changed in a way worth a report |
| + // (both lists joined with commas): someone heard who was not, or someone |
| + // still here no longer heard. Someone who has left is not news — every |
| + // departure would otherwise cost a report from everyone left — and their |
| + // id rides along, ignored, until the next report for another reason. |
| + // Pure, and lifted by name in the node test. |
| + function hearingMoved(before, now, present) { |
| + const was = new Set(before ? before.split(',') : []); |
| + const is = new Set(now ? now.split(',') : []); |
| + for (const id of is) if (!was.has(id)) return true; |
| + for (const id of was) if (!is.has(id) && present.has(id)) return true; |
| + return false; |
| + } |
| + |
| + // 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 +4948,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 +5430,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 +5481,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 +5500,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 +5538,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 +5632,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,7 +5705,10 @@ |
| else delete tilesEl.dataset.speaker; |
| } |
| const key = noSoundList().join(','); |
| - if (key !== noSoundKey) { |
| + const heardKey = hearingList().join(','); |
| + const heardMoved = hearingMoved(hearingKey, heardKey, new Set(pcs.keys())); |
| + hearingKey = heardKey; |
| + if (key !== noSoundKey || heardMoved) { |
| noSoundKey = key; |
| reportMedia(); |
| } |
| @@ -6755,7 +7024,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 +7249,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/README.md b/native/README.md| index bfefb36..2dc0fb9 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -135,6 +135,15 @@ because nothing on it measured sound. Now: |
| isn't getting any sound from you". Each line is in the tile's accessibility |
| label and announced when it appears. The app itself is never blocked from |
| playing sound, so it never reports `audioBlocked`. |
| +- **Who can hear you yet** (spec §15.24): the app reports `hearing`, the people |
| + whose audio path is established here (connected, packets in and decoded |
| + frames at the tap, or connected and muted), read five times a second until |
| + it is. From the moment someone joins, their tile says "Samuel is connecting |
| + and can't hear you yet" — past a one-second grace, with dots that take turns |
| + (steady under Reduce Motion) — until their report lists you, their own |
| + blocked or no-sound report replacing it, and never past twenty seconds |
| + (`ConnectingWatch.swift`). The mini window and its tab show a small ear for |
| + anyone it is not showing. |
| The rules (`AudioLevelRules.swift`) are held by `AudioLevelTests` on an |
| injected clock; `TestNativeMacCallsBrowser` checks that the browser measures |
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift| index adfe63b..192da92 100644 |
| --- a/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| @@ -60,6 +60,52 @@ import SwiftUI |
| } |
| } |
| + /// Spec §15.24: two people who have just joined — Samuel still |
| + /// connecting and unable to hear you yet, Bea's browser blocking sound in |
| + /// place of that — in Grid, Speaker and Classic, light and dark, over a |
| + /// busy picture, and the mini window and its tab with the small ear for |
| + /// someone it is not showing. |
| + static func saveConnecting(to folder: URL) async throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| + let size = CGSize(width: 1000, height: 640) |
| + for scheme in [ColorScheme.light, .dark] { |
| + let suffix = LobbySnapshots.name(scheme) |
| + func shot( |
| + _ name: String, grid: Bool = false, style: CallLayoutStyle = .fullWindow, |
| + backdrop: FixtureBackdrop? = nil |
| + ) async throws { |
| + let call = Call(playAudio: false) |
| + CallFixture.connecting.apply(to: call) |
| + try await LobbySnapshots.render( |
| + MacCallView( |
| + call: call, grid: grid, showingChat: false, showingDevices: false, style: style, |
| + controlsShown: true, leave: {}, rejoin: {} |
| + ).tint(CallsAppearance.accent).environment(\.fixtureBackdrop, backdrop), |
| + scheme: scheme, size: size, |
| + to: folder.appendingPathComponent("mac-connecting-\(name)-\(suffix).png")) |
| + } |
| + try await shot("grid", grid: true) |
| + try await shot("speaker") |
| + try await shot("classic-grid", grid: true, style: .classic) |
| + try await shot("busy-grid", grid: true, backdrop: .busy) |
| + for hovering in [false, true] { |
| + let call = Call(playAudio: false) |
| + CallFixture.connecting.apply(to: call) |
| + let width = MiniWindowRules.size.width |
| + try await LobbySnapshots.render( |
| + MiniWindowSnapshot.window(call: call, hovering: hovering), scheme: scheme, |
| + size: CGSize(width: width, height: width / MiniWindowRules.aspect), |
| + to: folder.appendingPathComponent("mac-connecting-mini\(hovering ? "-hover" : "")-\(suffix).png")) |
| + } |
| + let call = Call(playAudio: false) |
| + CallFixture.connecting.apply(to: call) |
| + try await LobbySnapshots.render( |
| + MiniWindowSnapshot.tab(call: call), scheme: scheme, size: MiniWindowTab.size, |
| + to: folder.appendingPathComponent("mac-connecting-tab-\(suffix).png")) |
| + } |
| + } |
| + |
| /// The glass and the glow as the window server draws them (0.3.7). An |
| /// off-screen bitmap has no Liquid Glass and no backdrop to blur, so |
| /// these are real windows, parked below the desktop where nobody sees |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index 21ef045..af6e902 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -34,6 +34,12 @@ struct Input: Decodable { |
| } catch { exit(1) } |
| return |
| } |
| + if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--connecting-snapshots" { |
| + do { |
| + try await CallSnapshots.saveConnecting(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| + } catch { exit(1) } |
| + return |
| + } |
| if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--glass-snapshots" { |
| do { |
| try await CallSnapshots.saveGlass(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift| index ccf985d..a8312ba 100644 |
| --- a/native/apple/Sources/CallsKit/AudioMeter.swift |
| +++ b/native/apple/Sources/CallsKit/AudioMeter.swift |
| @@ -46,15 +46,19 @@ import SwiftUI |
| @Published public private(set) var speaking = false |
| /// Their report about you: blocked, or not hearing you. |
| @Published public private(set) var notices: [String] = [] |
| + /// "<name> is connecting and can't hear you yet" (spec §15.24), drawn |
| + /// with a moving ellipsis; never beside `notices`. |
| + @Published public private(set) var connecting: String? |
| /// The glow behind their name. |
| public let glow = GlowLevel() |
| public init() {} |
| - func show(state: PeerAudioState, speaking: Bool, notices: [String]) { |
| + func show(state: PeerAudioState, speaking: Bool, notices: [String], connecting: String? = nil) { |
| if self.state != state { self.state = state } |
| if self.speaking != speaking { self.speaking = speaking } |
| if self.notices != notices { self.notices = notices } |
| + if self.connecting != connecting { self.connecting = connecting } |
| } |
| } |
| @@ -124,7 +128,8 @@ import SwiftUI |
| /// Returns the state the rule arrived at. |
| @discardableResult |
| func updatePeer( |
| - _ id: String, name: String, sample: PeerAudioSample, notices: [String], now: Double |
| + _ id: String, name: String, sample: PeerAudioSample, notices: [String], connecting: String? = nil, |
| + now: Double |
| ) -> PeerAudioState { |
| var watch = watches[id] ?? PeerAudioWatch() |
| let before = watch.state |
| @@ -132,8 +137,9 @@ import SwiftUI |
| watches[id] = watch |
| lastLevel[id] = sample.level |
| let model = peer(id) |
| - let fresh = notices.filter { !model.notices.contains($0) } |
| - model.show(state: state, speaking: watch.speaking, notices: notices) |
| + var fresh = notices.filter { !model.notices.contains($0) } |
| + if let connecting, model.connecting != connecting { fresh.append(connecting) } |
| + model.show(state: state, speaking: watch.speaking, notices: notices, connecting: connecting) |
| model.glow.update(level: sample.level, speaking: watch.speaking, now: now) |
| if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) } |
| for line in fresh { AudioAnnouncer.say(line) } |
| @@ -224,6 +230,9 @@ public struct TileAudioInfo: Equatable { |
| /// Lines above the name: their notices about you, or your microphone |
| /// warning. |
| public var notices: [String] |
| + /// "<name> is connecting and can't hear you yet" (spec §15.24), with its |
| + /// moving ellipsis; nil when not said. |
| + public var connecting: String? = nil |
| /// The ⓘ's text, when a line has one. |
| public var detail: String? |
| @@ -232,6 +241,7 @@ public struct TileAudioInfo: Equatable { |
| var out: [String] = [] |
| if case .noSound(let name) = status { out.append(AudioWords.noSound(from: name)) } |
| out.append(contentsOf: notices) |
| + if let connecting { out.append(connecting) } |
| return out |
| } |
| @@ -251,7 +261,7 @@ public struct TileAudioInfo: Equatable { |
| case .meter: status = .live |
| } |
| return TileAudioInfo( |
| - status: status, speaking: audio.speaking, notices: audio.notices, |
| + status: status, speaking: audio.speaking, notices: audio.notices, connecting: audio.connecting, |
| detail: audio.state == .noSound ? AudioWords.noSoundDetail : nil) |
| } |
| } |
| @@ -338,15 +348,55 @@ public struct TileAudioNotices: View { |
| let info: TileAudioInfo? |
| public init(_ info: TileAudioInfo?) { self.info = info } |
| public var body: some View { |
| - if let info, !info.notices.isEmpty { |
| + if let info, !info.notices.isEmpty || info.connecting != nil { |
| VStack(alignment: .leading, spacing: 4) { |
| ForEach(info.notices, id: \.self) { line in |
| AudioLine( |
| text: line, systemImage: "exclamationmark.triangle.fill", |
| detail: line == AudioWords.micSilent ? info.detail : nil) |
| } |
| + if let connecting = info.connecting { |
| + AudioLine(text: connecting, systemImage: "ear", ellipsis: true) |
| + .accessibilityIdentifier("connectingNotice") |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +/// "<name> is connecting and can't hear you yet" and three dots after it |
| +/// (spec §15.24) that brighten in turn — waiting, not wrong — or one steady |
| +/// ellipsis under Reduce Motion. One run of text, so a long name wraps the |
| +/// dots with the words. VoiceOver hears the sentence, never the dots. |
| +public struct ConnectingText: View { |
| + let words: String |
| + @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| + public init(_ words: String) { self.words = words } |
| + public var body: some View { |
| + Group { |
| + if reduceMotion { |
| + Text(words + "…") |
| + } else { |
| + TimelineView(.periodic(from: .now, by: 0.1)) { context in |
| + let t = context.date.timeIntervalSinceReferenceDate |
| + (0..<3).reduce(Text(words)) { line, dot in |
| + line + Text(".").foregroundColor(.white.opacity(Self.opacity(at: t, dot: dot))) |
| + } |
| + } |
| } |
| } |
| + .accessibilityLabel(words) |
| + } |
| + |
| + /// Each dot's brightness at `t`: dim, lit at its turn in a 1.4 s cycle, |
| + /// a fifth of a second after the one before — the browser's |
| + /// `calls-ellipsis` keyframes. |
| + static func opacity(at t: Double, dot: Int) -> Double { |
| + let cycle = 1.4 |
| + let phase = (t - Double(dot) * 0.2).truncatingRemainder(dividingBy: cycle) |
| + let p = (phase < 0 ? phase + cycle : phase) / cycle |
| + let lift = p <= 0.4 ? p / 0.4 : p <= 0.8 ? (0.8 - p) / 0.4 : 0 |
| + return 0.35 + 0.65 * lift |
| } |
| } |
| @@ -357,12 +407,17 @@ struct AudioLine: View { |
| let text: String |
| let systemImage: String |
| var detail: String? |
| + /// The connecting notice's moving dots after the words (spec §15.24). |
| + var ellipsis = false |
| @State private var showing = false |
| var body: some View { |
| HStack(spacing: 5) { |
| Image(systemName: systemImage) |
| - Text(text).lineLimit(2).multilineTextAlignment(.leading) |
| - .fixedSize(horizontal: false, vertical: true) |
| + Group { |
| + if ellipsis { ConnectingText(text) } else { Text(text) } |
| + } |
| + .lineLimit(2).multilineTextAlignment(.leading) |
| + .fixedSize(horizontal: false, vertical: true) |
| if let detail { |
| Button { |
| showing.toggle() |
diff --git a/native/apple/Sources/CallsKit/AudioWork.swift b/native/apple/Sources/CallsKit/AudioWork.swift| index ccf38ec..15e0a36 100644 |
| --- a/native/apple/Sources/CallsKit/AudioWork.swift |
| +++ b/native/apple/Sources/CallsKit/AudioWork.swift |
| @@ -131,6 +131,12 @@ enum PeerCalls { |
| } |
| } |
| + static func statistics(_ pc: RTCPeerConnection, for sender: RTCRtpSender) async -> RTCStatisticsReport { |
| + await withCheckedContinuation { c in |
| + AudioWork.queue.async { pc.statistics(for: sender) { c.resume(returning: $0) } } |
| + } |
| + } |
| + |
| /// Anything else, read or done on the audio queue. |
| static func read<T>(_ pc: RTCPeerConnection, _ body: @escaping (RTCPeerConnection) -> T) async -> T { |
| await AudioWork.run { body(pc) } |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 56f4107..46b4418 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -87,6 +87,17 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| private var reconnectWatch = ReconnectWatch() |
| @Published public private(set) var reconnecting = false |
| @Published public private(set) var reconnectingPeers: Set<String> = [] |
| + /// "<name> is connecting and can't hear you yet" (spec §15.24): the |
| + /// watch, and the people it is said for now — on their tiles, and as a |
| + /// dot in the mini window when they are not the one it shows. |
| + private var connectingWatch = ConnectingWatch() |
| + @Published public private(set) var connectingPeers: Set<String> = [] |
| + /// This app's own half: whose audio path is established (HearingRule), |
| + /// per connection generation; whose receiver reports have shown this |
| + /// app's audio reaching them; and the list last reported as `hearing`. |
| + private var heardFrom: [String: (epoch: Int64, heard: Bool)] = [:] |
| + private var ackedBy: [String: Int64] = [:] |
| + private var reportedHearing: [String] = [] |
| /// The phase the notices are drawn for: the core's, except that its |
| /// `reconnecting` is only said once ReconnectWatch says so, and is said |
| /// for a second after it is over. |
| @@ -519,6 +530,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| private func watchLevels() { |
| levelWatch?.cancel() |
| levels.reset() |
| + resetConnecting() |
| reportedNoSound = [] |
| audioBytes = [:] |
| audioArriving = [:] |
| @@ -544,11 +556,21 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| screenReceived = [:] |
| screenReceivedMoving = [:] |
| levels.reset() |
| + resetConnecting() |
| reportedNoSound = [] |
| audioBytes = [:] |
| audioArriving = [:] |
| } |
| + private func resetConnecting() { |
| + connectingWatch = ConnectingWatch() |
| + if !connectingPeers.isEmpty { connectingPeers = [] } |
| + heardFrom = [:] |
| + ackedBy = [:] |
| + // The core starts every report with `hearing: []` (spec §15.24). |
| + reportedHearing = [] |
| + } |
| + |
| private func sampleMic(now: Double) { |
| let denoiser = media.audio.denoiser |
| let sent = denoiser.level.take() |
| @@ -575,30 +597,79 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| private func samplePeers(stats: Bool, now: Double) async { |
| - let readings = await media.peerAudio(stats: stats) |
| + // §15.24: the people whose audio path is not established yet are read |
| + // closely, five times a second, until it is; the people in their first |
| + // moments who do not say who they hear are asked about this app's audio. |
| + let mine = state?.peer ?? "" |
| + let joining = connectingWatch.joining |
| + var detail = Set<String>() |
| + var acks = Set<String>() |
| + for person in state?.peers ?? [] where person.peer != mine { |
| + let epoch = person.gen ?? 0 |
| + if heardFrom[person.peer].map({ $0.epoch != epoch || !$0.heard }) ?? true { detail.insert(person.peer) } |
| + if joining.contains(person.peer), HearingRule.hearsYou(person.media, me: mine) == nil, |
| + ackedBy[person.peer] != epoch |
| + { |
| + acks.insert(person.peer) |
| + } |
| + } |
| + let readings = await media.peerAudio(stats: stats, detail: detail, acks: acks) |
| guard levelWatch != nil, let state else { return } |
| let me = state.peer |
| var present = Set<String>() |
| var people: [String: ReconnectWatch.Peer] = [:] |
| + var arrivals: [String: ConnectingWatch.Peer] = [:] |
| for person in state.peers where person.peer != me { |
| guard let reading = readings[person.peer] else { continue } |
| present.insert(person.peer) |
| - if let bytes = reading.bytes { |
| + if stats, let bytes = reading.bytes { |
| audioArriving[person.peer] = bytes > (audioBytes[person.peer] ?? 0) |
| audioBytes[person.peer] = bytes |
| } |
| - people[person.peer] = ReconnectWatch.Peer( |
| - connectionBad: reading.connectionBad, |
| - audioMissing: audioArriving[person.peer] == false && person.media?.muted != true) |
| - var notices = AudioWords.notices(name: person.name, media: person.media, me: me, muted: muted) |
| - if reconnectingPeers.contains(person.peer) { notices.insert(ReconnectWords.peer(person.name), at: 0) } |
| + let epoch = person.gen ?? 0 |
| + var was = heardFrom[person.peer] ?? (epoch, false) |
| + if was.epoch != epoch { was = (epoch, false) } |
| + let heard = HearingRule.heard( |
| + was: was.heard, |
| + .init( |
| + connected: reading.connected, bad: reading.gone, hasReceiver: reading.hasReceiver, |
| + receiverLive: reading.receiverLive, packets: reading.packets ?? 0, decoded: reading.buffers, |
| + senderMuted: person.media?.muted == true)) |
| + heardFrom[person.peer] = (epoch, heard) |
| + if reading.acked == true { ackedBy[person.peer] = epoch } |
| + arrivals[person.peer] = ConnectingWatch.Peer( |
| + epoch: epoch, hearsMe: HearingRule.hearsYou(person.media, me: me), reported: person.media != nil, |
| + connected: reading.connected, acked: ackedBy[person.peer] == epoch) |
| + } |
| + for id in Array(heardFrom.keys) where !present.contains(id) { heardFrom.removeValue(forKey: id) } |
| + for id in Array(ackedBy.keys) where !present.contains(id) { ackedBy.removeValue(forKey: id) } |
| + for id in connectingWatch.observe(at: now, people: arrivals) { |
| + CallLog.info("\(id.prefix(6)) can hear us") |
| + AudioAnnouncer.say(ConnectingWords.canHear(name(of: id))) |
| + } |
| + let nowJoining = connectingWatch.joining |
| + if connectingPeers != connectingWatch.shown { connectingPeers = connectingWatch.shown } |
| + for person in state.peers where person.peer != me { |
| + guard let reading = readings[person.peer] else { continue } |
| + // Someone in their first moments has not lost anything yet: §15.24 |
| + // speaks for them until it gives up. |
| + people[person.peer] = |
| + nowJoining.contains(person.peer) |
| + ? ReconnectWatch.Peer() |
| + : ReconnectWatch.Peer( |
| + connectionBad: reading.connectionBad, |
| + audioMissing: audioArriving[person.peer] == false && person.media?.muted != true) |
| + let said = ConnectingWatch.tile( |
| + name: person.name, media: person.media, me: me, muted: muted, |
| + joining: nowJoining.contains(person.peer), shown: connectingPeers.contains(person.peer), |
| + reconnecting: reconnectingPeers.contains(person.peer)) |
| remotePeakSinceSample = max(remotePeakSinceSample, reading.peak) |
| levels.updatePeer( |
| person.peer, name: person.name, |
| sample: PeerAudioSample( |
| reported: person.media, connected: reading.connected, |
| arriving: audioArriving[person.peer] ?? false, level: reading.peak), |
| - notices: notices, |
| + notices: said.notices, connecting: said.connecting, |
| now: now) |
| } |
| watchReconnect(now: now, people: people) |
| @@ -608,6 +679,18 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| audioArriving.removeValue(forKey: id) |
| } |
| reportNoSound(levels.noSoundFrom) |
| + reportHearing(heardFrom.filter { $0.value.heard }.keys.sorted(), present: present) |
| + } |
| + |
| + /// §15.24: whose audio this app is receiving and playing. Sent only on a |
| + /// change, in order, off the main thread, as `noSoundFrom` is. |
| + private func reportHearing(_ peers: [String], present: Set<String>) { |
| + guard peers != reportedHearing, let client = bridge else { return } |
| + let moved = HearingRule.moved(from: reportedHearing, to: peers, present: present) |
| + reportedHearing = peers |
| + guard moved else { return } |
| + let body = json(peers) ?? "[]" |
| + noSoundQueue.async { try? client.setHearing(body) } |
| } |
| /// One look by ReconnectWatch (spec §15.23), and the words it says. |
| @@ -1052,6 +1135,11 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| values["remoteScreens"] = UInt64(remoteScreens.count) |
| values["screens"] = UInt64(screens.count) |
| values["invalidEvents"] = invalidEvents |
| + // §15.24: people still in their first moments, people whose tiles say |
| + // they cannot hear you yet, and how many this app reports it hears. |
| + values["connectingJoining"] = UInt64(connectingWatch.joining.count) |
| + values["connectingShown"] = UInt64(connectingPeers.count) |
| + values["hearing"] = UInt64(reportedHearing.count) |
| // This app's own screen, as it leaves: the slowest connection's. |
| if let screen = screenReport { |
| values["screenSendWidth"] = UInt64(screen.width) |
| @@ -1156,6 +1244,24 @@ extension Call { |
| self.sharingScreen = sharingScreen |
| } |
| + /// Spec §15.24 for the snapshots: these people have just joined and the |
| + /// grace has passed, so each tile says what it would — "connecting", or |
| + /// a report of theirs that replaces it. |
| + @_spi(Fixtures) public func showFixtureConnecting(_ peers: [String]) { |
| + let me = state?.peer ?? "" |
| + var shown: Set<String> = [] |
| + for person in state?.peers ?? [] where peers.contains(person.peer) { |
| + let said = ConnectingWatch.tile( |
| + name: person.name, media: person.media, me: me, muted: muted, joining: true, shown: true, |
| + reconnecting: false) |
| + if said.connecting != nil { shown.insert(person.peer) } |
| + levels.peer(person.peer).show( |
| + state: person.media?.muted == true ? .muted : .meter(0), speaking: false, notices: said.notices, |
| + connecting: said.connecting) |
| + } |
| + connectingPeers = shown |
| + } |
| + |
| /// Everyone's glow, and yours, held at one level, for the snapshots. |
| @_spi(Fixtures) public func showFixtureSpeaking(level: Float) { |
| levels.mic.glow.showFixture(level: level) |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| index 071b94b..f09378d 100644 |
| --- a/native/apple/Sources/CallsKit/CallFixtures.swift |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -16,6 +16,9 @@ import SwiftUI |
| case screen |
| /// Somebody waiting to be let in, and the connection coming back. |
| case notices |
| + /// Two people who have just joined (spec §15.24): one still connecting |
| + /// and unable to hear you yet, the other's browser blocking sound. |
| + case connecting |
| public static let title = "Call, 24 Sep 10:21" |
| @@ -41,6 +44,11 @@ import SwiftUI |
| call.showFixture( |
| title: Self.title, status: "Reconnecting…", |
| state: roster("reconnecting", [me, ana], waiting: [knock]), muted: true, floor: true) |
| + case .connecting: |
| + let sam = #"{"peer":"sam","name":"Samuel Okafor","kind":"member","media":{"muted":false,"cameraOff":false,"hearing":[]}}"# |
| + let bea = #"{"peer":"bea","name":"Bea Ruiz","kind":"guest","media":{"muted":false,"cameraOff":true,"audioBlocked":true,"hearing":[]}}"# |
| + call.showFixture(title: Self.title, state: roster("admitted", [me, ana, sam, bea])) |
| + call.showFixtureConnecting(["sam", "bea"]) |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/ConnectingWatch.swift b/native/apple/Sources/CallsKit/ConnectingWatch.swift| new file mode 100644 |
| index 0000000..798cafc |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/ConnectingWatch.swift |
| @@ -0,0 +1,172 @@ |
| +import Foundation |
| + |
| +/// "<name> is connecting and can't hear you yet" (spec §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 |
| +/// your 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 |
| +/// app can see of its own audio reaching them. |
| +/// |
| +/// For each person from the moment they appear in the roster (or appear |
| +/// again, or are given a new connection): |
| +/// |
| +/// - the words are said only once `grace` (1 s) has passed without their |
| +/// hearing you, so an ordinary join says nothing; |
| +/// - they go the moment their report lists you in `hearing` — "Samuel can |
| +/// hear you now"; |
| +/// - for a client that does not say who it hears, once the connection is up |
| +/// and either their receiver reports show your audio reaching them or |
| +/// they have made a report without the list; |
| +/// - and never past `cap` (20 s), after which §15.20's and §15.23's words |
| +/// speak for any problem. |
| +/// |
| +/// Your own mute does not matter: it is about their hearing you when you |
| +/// speak. Pure: fed the facts as they are, with the time. The browser's |
| +/// `connectingStep` is its twin. |
| +struct ConnectingWatch: Equatable { |
| + static let grace = 1.0 |
| + static let cap = 20.0 |
| + |
| + /// One person as they are now. |
| + struct Peer: Equatable { |
| + /// The pair generation: a new one is a new connection, and they cannot |
| + /// hear you on it yet. |
| + var epoch: Int64 = 0 |
| + /// Their report on you (`HearingRule.hearsYou`): listed, not listed, |
| + /// or nil for no list at all. |
| + var hearsMe: Bool? |
| + /// They have made a media report. |
| + var reported = false |
| + /// The connection to them is up. |
| + var connected = false |
| + /// Their receiver reports say your audio reaches them. |
| + var acked = false |
| + } |
| + |
| + private struct Record: Equatable { |
| + var epoch: Int64 |
| + var since: Double |
| + var done = false |
| + var shown = false |
| + } |
| + private var records: [String: Record] = [:] |
| + |
| + init() {} |
| + |
| + /// People still in their first moments, whether or not the words show yet. |
| + var joining: Set<String> { Set(records.filter { !$0.value.done }.keys) } |
| + /// People whose tiles say it. |
| + var shown: Set<String> { Set(records.filter { $0.value.shown }.keys) } |
| + |
| + /// One look at everyone but you. Returns the people whose words have just |
| + /// gone because they can hear you, for "… can hear you now". |
| + @discardableResult |
| + mutating func observe(at now: Double, people: [String: Peer]) -> [String] { |
| + for id in Array(records.keys) where people[id] == nil { records.removeValue(forKey: id) } |
| + var cleared: [String] = [] |
| + for (id, p) in people { |
| + var r = records[id] ?? Record(epoch: p.epoch, since: now) |
| + if r.epoch != p.epoch { r = Record(epoch: p.epoch, since: now) } |
| + if !r.done { |
| + let fallback = p.hearsMe == nil && p.connected && (p.acked || p.reported) |
| + if p.hearsMe == true || fallback { |
| + if r.shown { cleared.append(id) } |
| + r.done = true |
| + r.shown = false |
| + } else if now - r.since >= Self.cap { |
| + r.done = true |
| + r.shown = false |
| + } else { |
| + r.shown = now - r.since >= Self.grace |
| + } |
| + } |
| + records[id] = r |
| + } |
| + return cleared.sorted() |
| + } |
| + |
| + /// What a person's tile says about them and you, in order (the browser's |
| + /// `peerNotice`): in their first moments, their own more specific report |
| + /// (their browser blocking sound, or no sound from you) and otherwise the |
| + /// connecting words once the grace has passed — "Reconnecting" would be |
| + /// wrong of a connection that has never been up; after that, §15.23's |
| + /// "Reconnecting to …" first and then their reports. `connecting` is the |
| + /// line that carries the moving ellipsis, and is never set beside |
| + /// `notices`. |
| + static func tile( |
| + name: String, media: PeerMedia?, me: String, muted: Bool, joining: Bool, shown: Bool, |
| + reconnecting: Bool |
| + ) -> (notices: [String], connecting: String?) { |
| + let reports = AudioWords.notices(name: name, media: media, me: me, muted: muted) |
| + if joining { |
| + if !reports.isEmpty { return (reports, nil) } |
| + return ([], shown ? ConnectingWords.connecting(name) : nil) |
| + } |
| + return ((reconnecting ? [ReconnectWords.peer(name)] : []) + reports, nil) |
| + } |
| +} |
| + |
| +/// This app's half of §15.24: whose audio it can say it hears. |
| +enum HearingRule { |
| + /// One connection's audio as it is now. |
| + struct Facts: Equatable { |
| + /// ICE connected or completed. |
| + var connected = false |
| + /// ICE failed or closed: the path is gone. |
| + var bad = false |
| + var hasReceiver = false |
| + /// The audio receiver's track is live. |
| + var receiverLive = false |
| + /// Audio packets received on it so far. |
| + var packets: UInt64 = 0 |
| + /// Decoded audio frames that reached the receive tap since the last look. |
| + var decoded = 0 |
| + /// They say they are muted (or have no microphone). |
| + var senderMuted = false |
| + } |
| + |
| + /// Whether the audio path from someone 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 frames 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, because |
| + /// the moment they unmute, it plays. The browser's `judgeHeard` is its |
| + /// twin. A native app plays whatever it decodes: there is no autoplay |
| + /// policy to refuse it. |
| + static func heard(was: Bool, _ f: Facts) -> Bool { |
| + 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.decoded > 0 |
| + } |
| + |
| + /// Whether `hearing` has changed in a way worth a report: someone heard |
| + /// who was not, or someone still here no longer heard. Someone who has |
| + /// left is not news — every departure would otherwise cost a report from |
| + /// everyone left — and the core carries their id, ignored, until the next |
| + /// report for another reason. The browser's `hearingMoved` is its twin. |
| + static func moved(from before: [String], to now: [String], present: Set<String>) -> Bool { |
| + let was = Set(before) |
| + let is_ = Set(now) |
| + if !is_.subtracting(was).isEmpty { return true } |
| + return !was.subtracting(is_).intersection(present).isEmpty |
| + } |
| + |
| + /// A person's report on you: true when their `hearing` lists you, false |
| + /// when it has a list and you are not on it, nil when it has no list — no |
| + /// report yet, or a client from before §15.24. |
| + static func hearsYou(_ media: PeerMedia?, me: String) -> Bool? { |
| + guard let list = media?.hearing else { return nil } |
| + return !me.isEmpty && list.contains(me) |
| + } |
| +} |
| + |
| +/// The words (spec §15.24). The ellipsis is drawn apart from them |
| +/// (`ConnectingText`), so VoiceOver hears the sentence and not the dots. |
| +public enum ConnectingWords { |
| + public static func connecting(_ name: String) -> String { "\(name) is connecting and can’t hear you yet" } |
| + public static func canHear(_ name: String) -> String { "\(name) can hear you now" } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index 92857a4..b96c140 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -386,32 +386,66 @@ import LiveKitWebRTC |
| /// Each remote person's sound, for the meters (spec §15.20): the level |
| /// their receive tap has seen since the last look, whether the |
| - /// connection is up, and — when `stats` is set — the audio bytes |
| - /// received so far, one `statistics(for:)` per audio receiver rather |
| - /// than the whole report. Nil bytes when not asked. |
| - func peerAudio(stats: Bool) async -> [String: PeerAudioReading] { |
| + /// connection is up, and — when `stats` is set, or for the people in |
| + /// `detail` — the audio bytes and packets received so far and whether |
| + /// the receiver is live, one `statistics(for:)` per audio receiver rather |
| + /// than the whole report. For the people in `acks`, whether their |
| + /// receiver reports say this app's audio reaches them (spec §15.24's |
| + /// fallback for a client that does not say who it hears). |
| + func peerAudio(stats: Bool, detail: Set<String> = [], acks: Set<String> = []) async -> [String: PeerAudioReading] { |
| var out: [String: PeerAudioReading] = [:] |
| for (id, peer) in peers { |
| guard let pc = peer.pc else { continue } |
| let (peak, buffers) = peer.tap.take() |
| + let deep = stats || detail.contains(id) |
| + let ack = acks.contains(id) |
| // Read on the audio queue, as every call into the connection is |
| // (PeerCalls): five times a second, and each one a blocking hop to |
| // WebRTC's signalling thread. |
| - let (state, receivers) = await PeerCalls.read(pc) { pc in |
| - (pc.iceConnectionState, stats ? pc.receivers.filter { $0.track?.kind == "audio" } : []) |
| + let (state, receivers, senders) = await PeerCalls.read(pc) { pc in |
| + ( |
| + pc.iceConnectionState, deep ? pc.receivers.filter { $0.track?.kind == "audio" } : [], |
| + ack ? pc.senders.filter { $0.track?.kind == "audio" } : [] |
| + ) |
| } |
| var reading = PeerAudioReading( |
| peak: peak, buffers: buffers, connected: state == .connected || state == .completed, |
| connectionBad: state == .disconnected || state == .failed) |
| - if stats { |
| + reading.gone = state == .failed || state == .closed |
| + if deep { |
| var bytes: UInt64 = 0 |
| + var packets: UInt64 = 0 |
| + var live = false |
| for receiver in receivers { |
| + if receiver.track?.readyState == .live { live = true } |
| let report = await PeerCalls.statistics(pc, for: receiver) |
| for stat in report.statistics.values where stat.type == "inbound-rtp" { |
| bytes += (stat.values["bytesReceived"] as? NSNumber)?.uint64Value ?? 0 |
| + packets += (stat.values["packetsReceived"] as? NSNumber)?.uint64Value ?? 0 |
| } |
| } |
| reading.bytes = bytes |
| + reading.packets = packets |
| + reading.hasReceiver = !receivers.isEmpty |
| + reading.receiverLive = live |
| + } |
| + if ack { |
| + var sent: UInt64 = 0 |
| + var acked = false |
| + for sender in senders { |
| + let report = await PeerCalls.statistics(pc, for: sender) |
| + for stat in report.statistics.values { |
| + if stat.type == "outbound-rtp" { |
| + sent += (stat.values["packetsSent"] as? NSNumber)?.uint64Value ?? 0 |
| + } else if stat.type == "remote-inbound-rtp" { |
| + // Exists only once a receiver report has come back. |
| + let received = (stat.values["packetsReceived"] as? NSNumber)?.uint64Value ?? 0 |
| + let measured = (stat.values["roundTripTimeMeasurements"] as? NSNumber)?.uint64Value ?? 0 |
| + if received > 0 || measured > 0 || stat.values["roundTripTime"] != nil { acked = true } |
| + } |
| + } |
| + } |
| + reading.acked = sent > 0 && acked |
| } |
| out[id] = reading |
| } |
| @@ -464,7 +498,17 @@ struct PeerAudioReading: Equatable, Sendable { |
| var connected: Bool |
| /// ICE `disconnected` or `failed` (spec §15.23's three seconds). |
| var connectionBad = false |
| + /// ICE `failed` or `closed`: whatever was heard on it is gone (§15.24). |
| + var gone = false |
| var bytes: UInt64? |
| + /// Audio packets received, the receiver there at all, and its track |
| + /// live (§15.24); read with `bytes`. |
| + var packets: UInt64? |
| + var hasReceiver = false |
| + var receiverLive = false |
| + /// Their receiver reports say this app's audio reaches them; nil when |
| + /// not asked. |
| + var acked: Bool? |
| } |
| @MainActor private final class MediaPeer: NSObject, RTCPeerConnectionDelegate { |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index 9b819b0..d4af785 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -422,6 +422,13 @@ |
| return item |
| } |
| + /// The people still connecting who are not the one on show. |
| + private var offStage: [String] { |
| + var shown: String? |
| + if case .camera(let peer) = subject { shown = peer } |
| + return call.connectingPeers.filter { $0 != shown }.sorted() |
| + } |
| + |
| var body: some View { |
| ZStack { |
| Group { |
| @@ -455,6 +462,10 @@ |
| .accessibilityLabel("Your microphone is muted") |
| } |
| Spacer() |
| + // Spec §15.24, for the people this window is not showing: the |
| + // one it shows says it on its own tile. Top right, clear of |
| + // the tile's Muted badge. |
| + ConnectingDot(names: offStage.map { call.name(of: $0) }) |
| if showingControls { |
| Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") { |
| actions.collapse() |
| @@ -513,6 +524,17 @@ |
| .background( |
| call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, |
| in: Circle()) |
| + // Spec §15.24: someone is still connecting and cannot hear you |
| + // yet. A dot only — the tab has no room for words; its |
| + // accessibility label and the window, opened, say who. |
| + .overlay(alignment: .topTrailing) { |
| + if !call.connectingPeers.isEmpty { |
| + Circle().fill(ConnectingDot.colour).frame(width: 8, height: 8) |
| + .overlay(Circle().strokeBorder(Color.black.opacity(0.35), lineWidth: 1)) |
| + .offset(x: 2, y: -2) |
| + .help(call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) }.joined(separator: "\n")) |
| + } |
| + } |
| Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail) |
| .foregroundStyle(.white) |
| Spacer(minLength: 0) |
| @@ -531,7 +553,9 @@ |
| .clipShape(Capsule()) |
| .accessibilityElement(children: .ignore) |
| .accessibilityAddTraits(.isButton) |
| - .accessibilityLabel("Calls, \(caption)") |
| + .accessibilityLabel( |
| + (["Calls, \(caption)"] + call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) }) |
| + .joined(separator: ". ")) |
| .accessibilityValue(call.muted ? "Microphone muted" : "Microphone on") |
| .accessibilityHint("Shows the mini window.") |
| .accessibilityAction { actions.expand() } |
| @@ -543,6 +567,33 @@ |
| } |
| } |
| + /// The mini window's whole say on spec §15.24: a small ear on the glass |
| + /// while someone it is not showing is connecting and cannot hear you yet, |
| + /// with their names in its tooltip and for VoiceOver. Nothing at all |
| + /// otherwise. |
| + struct ConnectingDot: View { |
| + /// A warm amber, light enough to tell from the muted microphone's |
| + /// brown on the tab. |
| + static let colour = Color(red: 1, green: 0.78, blue: 0.42) |
| + let names: [String] |
| + var body: some View { |
| + if !names.isEmpty { |
| + let words = names.map(ConnectingWords.connecting).joined(separator: "\n") |
| + Image(systemName: "ear") |
| + .font(.callout.bold()).foregroundStyle(.white) |
| + .frame(width: 32, height: 32) |
| + .callOverlaySurface(Circle(), rim: false) |
| + .overlay(alignment: .topTrailing) { |
| + Circle().fill(Self.colour).frame(width: 7, height: 7) |
| + } |
| + .help(words) |
| + .accessibilityElement(children: .ignore) |
| + .accessibilityLabel(words) |
| + .accessibilityIdentifier("connectingDot") |
| + } |
| + } |
| + } |
| + |
| /// The window's background: a click, a drag that moves the window, and |
| /// — for the expanded window — a drag from an edge or corner that |
| /// resizes it. Handled here rather than with isMovableByWindowBackground |
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift| index 2c20d31..d750c01 100644 |
| --- a/native/apple/Sources/CallsKit/Wire.swift |
| +++ b/native/apple/Sources/CallsKit/Wire.swift |
| @@ -24,16 +24,21 @@ public struct PeerMedia: Decodable, Equatable, Sendable { |
| /// The peers whose shared screen has stopped moving for it (§15.21). |
| /// A sharer that finds itself here recovers its screen. Nil is none. |
| public let screenStalled: [String]? |
| + /// The peers whose audio it is actually receiving and playing (§15.24). |
| + /// Nil (absent) is a client that does not say; empty is one that hears |
| + /// nobody yet. |
| + public let hearing: [String]? |
| public init( |
| muted: Bool, cameraOff: Bool, audioBlocked: Bool? = nil, noSoundFrom: [String]? = nil, |
| - screenStalled: [String]? = nil |
| + screenStalled: [String]? = nil, hearing: [String]? = nil |
| ) { |
| self.muted = muted |
| self.cameraOff = cameraOff |
| self.audioBlocked = audioBlocked |
| self.noSoundFrom = noSoundFrom |
| self.screenStalled = screenStalled |
| + self.hearing = hearing |
| } |
| } |
| public struct RoomState: Decodable { |
diff --git a/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift| new file mode 100644 |
| index 0000000..aa12d4d |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift |
| @@ -0,0 +1,240 @@ |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// "<name> is connecting and can't hear you yet" (spec §15.24). Paul, |
| +/// 2026-10-01, asking for Zoom's "… is connecting to audio and cannot hear |
| +/// you yet": the gap after someone joins, said rather than talked into. |
| +final class ConnectingWatchTests: XCTestCase { |
| + private typealias Peer = ConnectingWatch.Peer |
| + private let waiting = Peer(epoch: 1, hearsMe: false, reported: true, connected: false, acked: false) |
| + |
| + /// Runs the watch at 5 Hz from `from` to `to` seconds; when Sam's words |
| + /// showed, and when they cleared with "can hear you now". `sam` nil is |
| + /// Sam not in the roster. |
| + private func run( |
| + _ watch: inout ConnectingWatch, from: Double, to: Double, sam: (Double) -> Peer? |
| + ) -> (shown: [Double], cleared: [Double]) { |
| + var shown: [Double] = [] |
| + var cleared: [Double] = [] |
| + var t = from |
| + while t < to { |
| + let people = sam(t).map { ["sam": $0] } ?? [:] |
| + if watch.observe(at: t, people: people).contains("sam") { cleared.append(t) } |
| + if watch.shown.contains("sam") { shown.append(t) } |
| + t = (t * 10 + 2).rounded() / 10 |
| + } |
| + return (shown, cleared) |
| + } |
| + |
| + func testTheTimingsAreTheOnesTheSpecNames() { |
| + XCTAssertEqual(ConnectingWatch.grace, 1) |
| + XCTAssertEqual(ConnectingWatch.cap, 20) |
| + } |
| + |
| + func testAnOrdinaryJoinHeardWithinTheGraceSaysNothing() { |
| + var watch = ConnectingWatch() |
| + var sam = waiting |
| + let seen = run(&watch, from: 0, to: 10) { t in |
| + sam.connected = t >= 0.4 |
| + sam.hearsMe = t >= 0.8 |
| + return sam |
| + } |
| + XCTAssertEqual(seen.shown, []) |
| + XCTAssertEqual(seen.cleared, [], "nothing was said, so \"can hear you now\" is not said either") |
| + XCTAssertFalse(watch.joining.contains("sam")) |
| + } |
| + |
| + func testASlowJoinIsSaidAfterTheGraceAndClearsWhenTheyHearYou() { |
| + var watch = ConnectingWatch() |
| + var sam = waiting |
| + let seen = run(&watch, from: 0, to: 12) { t in |
| + sam.connected = t >= 5 |
| + sam.hearsMe = t >= 6 |
| + return sam |
| + } |
| + XCTAssertEqual(seen.shown.first, 1) |
| + XCTAssertEqual(seen.shown.last, 5.8) |
| + XCTAssertEqual(seen.cleared, [6]) |
| + } |
| + |
| + func testOnceClearedItStaysClearedWhateverTheirListDoes() { |
| + var watch = ConnectingWatch() |
| + var sam = waiting |
| + sam.connected = true |
| + let seen = run(&watch, from: 0, to: 30) { t in |
| + sam.hearsMe = t >= 3 && t < 10 |
| + return sam |
| + } |
| + XCTAssertEqual(seen.cleared, [3]) |
| + XCTAssertEqual(seen.shown.last, 2.8) |
| + } |
| + |
| + func testItIsGivenUpAtTheCapSilently() { |
| + var watch = ConnectingWatch() |
| + let seen = run(&watch, from: 0, to: 40) { _ in self.waiting } |
| + XCTAssertEqual(seen.shown.first, 1) |
| + XCTAssertEqual(seen.shown.last, 19.8) |
| + XCTAssertEqual(seen.cleared, [], "a cap is not their hearing you") |
| + XCTAssertTrue(watch.joining.isEmpty) |
| + } |
| + |
| + func testAClientThatDoesNotSayIsLetGoOnItsFirstReportAndAConnection() { |
| + var watch = ConnectingWatch() |
| + let seen = run(&watch, from: 0, to: 10) { t in |
| + Peer(epoch: 1, hearsMe: nil, reported: t >= 4, connected: t >= 2, acked: false) |
| + } |
| + XCTAssertEqual(seen.cleared, [4]) |
| + } |
| + |
| + func testOrOnAConnectionAndReceiverReportsShowingYourAudioReachesThem() { |
| + var watch = ConnectingWatch() |
| + let seen = run(&watch, from: 0, to: 10) { t in |
| + Peer(epoch: 1, hearsMe: nil, reported: false, connected: t >= 2, acked: t >= 3) |
| + } |
| + XCTAssertEqual(seen.cleared, [3]) |
| + var unconnected = ConnectingWatch() |
| + let never = run(&unconnected, from: 0, to: 10) { _ in |
| + Peer(epoch: 1, hearsMe: nil, reported: true, connected: false, acked: true) |
| + } |
| + XCTAssertEqual(never.cleared, [], "never without a connection") |
| + } |
| + |
| + func testAClientThatSaysWhoItHearsIsBelievedOverYourOwnEvidence() { |
| + var watch = ConnectingWatch() |
| + let seen = run(&watch, from: 0, to: 10) { _ in |
| + Peer(epoch: 1, hearsMe: false, reported: true, connected: true, acked: true) |
| + } |
| + XCTAssertEqual(seen.cleared, []) |
| + XCTAssertFalse(seen.shown.isEmpty) |
| + } |
| + |
| + func testARejoinStartsItAgainAndSoDoesANewConnection() { |
| + var watch = ConnectingWatch() |
| + var sam = waiting |
| + let seen = run(&watch, from: 0, to: 20) { t in |
| + if t >= 5 && t < 6 { return nil } |
| + sam.connected = true |
| + sam.hearsMe = t < 5 ? t >= 2 : t >= 9 |
| + return sam |
| + } |
| + XCTAssertEqual(seen.cleared, [2, 9]) |
| + XCTAssertTrue(seen.shown.contains(7)) |
| + XCTAssertFalse(seen.shown.contains(6.8), "the grace again from the moment they are back") |
| + |
| + var rebuilt = ConnectingWatch() |
| + var again = waiting |
| + again.connected = true |
| + let second = run(&rebuilt, from: 0, to: 20) { t in |
| + again.epoch = t < 8 ? 1 : 2 |
| + again.hearsMe = t < 1 || t >= 12 |
| + return again |
| + } |
| + XCTAssertEqual(second.cleared, [12]) |
| + XCTAssertEqual(second.shown.first, 9) |
| + } |
| + |
| + func testAMoreSpecificReportReplacesConnectingAndReconnectingWaitsUntilAfter() { |
| + let plain = PeerMedia(muted: false, cameraOff: false, hearing: []) |
| + func tile( |
| + _ media: PeerMedia, muted: Bool = false, joining: Bool = true, shown: Bool = true, |
| + reconnecting: Bool = false |
| + ) -> (notices: [String], connecting: String?) { |
| + ConnectingWatch.tile( |
| + name: "Sam", media: media, me: "me", muted: muted, joining: joining, shown: shown, |
| + reconnecting: reconnecting) |
| + } |
| + XCTAssertNil(tile(plain, shown: false).connecting, "inside the grace") |
| + XCTAssertEqual(tile(plain).connecting, "Sam is connecting and can’t hear you yet") |
| + XCTAssertEqual(tile(plain).notices, []) |
| + XCTAssertNotNil(tile(plain, muted: true).connecting, "your own mute does not matter") |
| + XCTAssertEqual( |
| + tile(plain, reconnecting: true).notices, [], "a connection that has never been up is not reconnecting") |
| + let blocked = PeerMedia(muted: false, cameraOff: false, audioBlocked: true, hearing: []) |
| + XCTAssertEqual(tile(blocked, shown: false).notices, [AudioWords.blocked("Sam")], "even inside the grace") |
| + XCTAssertNil(tile(blocked).connecting) |
| + let silent = PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"], hearing: ["me"]) |
| + XCTAssertEqual(tile(silent).notices, [AudioWords.notHearingYou("Sam")]) |
| + XCTAssertNil(tile(silent).connecting) |
| + XCTAssertNotNil( |
| + tile(PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"]), muted: true).connecting, |
| + "no sound from you while you are muted is expected, so it replaces nothing") |
| + XCTAssertEqual( |
| + tile(plain, joining: false, shown: false, reconnecting: true).notices, [ReconnectWords.peer("Sam")]) |
| + XCTAssertEqual( |
| + tile(blocked, joining: false, shown: false, reconnecting: true).notices, |
| + [ReconnectWords.peer("Sam"), AudioWords.blocked("Sam")]) |
| + XCTAssertEqual(tile(plain, joining: false, shown: false).notices, []) |
| + } |
| + |
| + func testHearsYou() { |
| + XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: ["me"]), me: "me"), true) |
| + XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: []), me: "me"), false) |
| + XCTAssertNil(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false), me: "me")) |
| + XCTAssertNil(HearingRule.hearsYou(nil, me: "me")) |
| + } |
| + |
| + /// This app's own half: when a path counts as heard. A muted sender must |
| + /// not look unheard. |
| + func testHeardIsArrivingAndDecodedOrAMutedSenderOnALiveConnection() { |
| + let base = HearingRule.Facts( |
| + connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, decoded: 0, |
| + senderMuted: false) |
| + func with(_ change: (inout HearingRule.Facts) -> Void) -> HearingRule.Facts { |
| + var f = base |
| + change(&f) |
| + return f |
| + } |
| + XCTAssertFalse(HearingRule.heard(was: false, base), "nothing has arrived") |
| + XCTAssertFalse(HearingRule.heard(was: false, with { $0.packets = 10 }), "packets, nothing decoded") |
| + XCTAssertTrue(HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3 })) |
| + XCTAssertFalse( |
| + HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3; $0.connected = false }), |
| + "not before the connection is up") |
| + XCTAssertTrue(HearingRule.heard(was: false, with { $0.senderMuted = true }), "a muted sender") |
| + XCTAssertTrue( |
| + HearingRule.heard(was: false, with { $0.senderMuted = true; $0.hasReceiver = false; $0.receiverLive = false }), |
| + "nor one with no microphone at all") |
| + XCTAssertFalse( |
| + HearingRule.heard(was: false, with { $0.senderMuted = true; $0.receiverLive = false }), "but not a dead receiver") |
| + XCTAssertTrue(HearingRule.heard(was: true, base), "once heard, kept") |
| + XCTAssertTrue(HearingRule.heard(was: true, with { $0.connected = false }), "a blip is not losing them") |
| + XCTAssertFalse(HearingRule.heard(was: true, with { $0.bad = true }), "a failed connection is") |
| + } |
| + |
| + func testTheHearingListDecodesAndAbsentIsNotEmpty() throws { |
| + func media(_ json: String) throws -> PeerMedia? { |
| + let state = try JSONDecoder().decode( |
| + RoomState.self, |
| + from: Data( |
| + #"{"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[{"peer":"sam","name":"Sam","kind":"member","gen":1,"media":\#(json)}]}"# |
| + .utf8)) |
| + return Call.peerMedia("sam", in: state) |
| + } |
| + XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":["me"]}"#)?.hearing, ["me"]) |
| + XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":[]}"#)?.hearing, []) |
| + XCTAssertNil(try media(#"{"muted":false,"cameraOff":false}"#)?.hearing) |
| + } |
| + |
| + func testTheDotsTakeTurnsAndNeverGoOut() { |
| + for dot in 0..<3 { |
| + for step in 0..<28 { |
| + let o = ConnectingText.opacity(at: Double(step) * 0.05, dot: dot) |
| + XCTAssertGreaterThanOrEqual(o, 0.35) |
| + XCTAssertLessThanOrEqual(o, 1.0001) |
| + } |
| + } |
| + XCTAssertEqual(ConnectingText.opacity(at: 0.56, dot: 0), 1, accuracy: 0.001) |
| + XCTAssertEqual(ConnectingText.opacity(at: 0.76, dot: 1), 1, accuracy: 0.001) |
| + XCTAssertEqual(ConnectingText.opacity(at: 0.96, dot: 2), 1, accuracy: 0.001) |
| + } |
| + |
| + func testAHearingReportGoesForAGainOrALossOfSomeoneStillHereAndNotForADeparture() { |
| + let here: Set<String> = ["a", "b"] |
| + XCTAssertTrue(HearingRule.moved(from: [], to: ["a"], present: here)) |
| + XCTAssertTrue(HearingRule.moved(from: ["a"], to: ["a", "b"], present: here)) |
| + XCTAssertTrue(HearingRule.moved(from: ["a", "b"], to: ["a"], present: here)) |
| + XCTAssertFalse(HearingRule.moved(from: ["a", "c"], to: ["a"], present: here), "c has left") |
| + XCTAssertFalse(HearingRule.moved(from: ["a"], to: ["a"], present: here)) |
| + } |
| +} |
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/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index d9a9a47..92525ab 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -244,6 +244,30 @@ final class CallsPhoneUITests: XCTestCase { |
| } |
| } |
| + /// Spec §15.24: two people who have just joined — one connecting and |
| + /// unable to hear you yet, the other's browser blocking sound in place of |
| + /// that — in Grid and Speaker, light and dark. The notice is on the tile, |
| + /// and VoiceOver hears it in the tile's label, without the dots. |
| + @MainActor func testConnectingSnapshots() throws { |
| + XCUIDevice.shared.orientation = .portrait |
| + for dark in [false, true] { |
| + let scheme = dark ? "dark" : "light" |
| + for layout in ["grid", "speaker"] { |
| + let app = launchCall( |
| + "connecting", |
| + ["-CallsFixtureControls", "hidden", "-CallsFixtureLayout", layout, "-CallsFixtureOrder", "sam,bea,ana,me"], |
| + dark: dark) |
| + let notice = app.descendants(matching: .any) |
| + .matching(NSPredicate(format: "label CONTAINS %@", "Samuel Okafor is connecting and can’t hear you yet")) |
| + .firstMatch |
| + XCTAssertTrue(notice.waitForExistence(timeout: 15), "Samuel's tile says it") |
| + XCTAssertFalse(notice.label.contains("…"), "and VoiceOver hears no dots") |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(app, "connecting-\(layout)-portrait-\(scheme)") |
| + } |
| + } |
| + } |
| + |
| /// The overlay over black, white and a busy frame, in each of your own |
| /// states, light and dark: it should look the same in both. The |
| /// microphone sits beside the menu here so its muted state shows. |
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..f39030c 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, |
| }; |
| }); |
| } |
| @@ -3870,8 +3874,16 @@ const run = { |
| return; |
| } |
| + // §15.24: A reports who it hears once the pair is up; let that report |
| + // go before everything A posts is held, or it is counted below as a |
| + // second message the recovery sent. |
| + const settled = await until(async () => ((await state(pageA)).hearingList || []).includes(pair.b.peer), 10000); |
| + check(settled, 'A reports that it hears B before the cut'); |
| + await sleep(500); |
| + |
| let firstAt = 0; |
| let signals = 0; |
| + const posted = []; |
| let hungProbes = 0; |
| // Everything A posts from here is answered 404, twenty seconds |
| // after it was sent: "that peer is not here", arriving with a third |
| @@ -3879,6 +3891,8 @@ const run = { |
| await ctxA.route('**/signal', async (route) => { |
| if (!firstAt) firstAt = Date.now(); |
| signals += 1; |
| + const m = (route.request().postData() || '').match(/"type":"([a-z-]+)"/); |
| + posted.push(m ? m[1] : '?'); |
| await sleep(20000); |
| await route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"not-found"}' }); |
| }); |
| @@ -3913,7 +3927,7 @@ const run = { |
| // scenario sent is the only thing A ever posted, and it is still the |
| // only thing ten seconds later. |
| const atDrop = signals; |
| - check(atDrop === 1, 'the offer was the only thing A posted (' + atDrop + ')'); |
| + check(atDrop === 1, 'the offer was the only thing A posted (' + atDrop + ': ' + posted.join(', ') + ')'); |
| check(dropped && dropped.stale && dropped.stale.includes(pair.b.peer), |
| 'and A holds the target marked stale, waiting for a roster rather than asking again (' + |
| JSON.stringify(dropped && dropped.stale) + ')'); |
| @@ -4548,6 +4562,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 +4601,170 @@ 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') + ')'); |
| + // A signal held back for seconds can land after the pair has moved to |
| + // a new generation, and the hub refuses it 409 (§15.9) — which is the |
| + // hold's doing, and what the client recovers from. Nothing else. |
| + const errorsB = (consoleErrors.get('member B') || []).filter((e) => !/status of 409/.test(e)).join('; '); |
| + check(errorsB === '', 'no console errors in B but the held signals’ 409s (' + errorsB + ')'); |
| + 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/native-auth.mjs b/test/browser/native-auth.mjs| index 25d2624..75bb927 100644 |
| --- a/test/browser/native-auth.mjs |
| +++ b/test/browser/native-auth.mjs |
| @@ -13,14 +13,15 @@ try { |
| process.stdout.write("stage:signin\n"); |
| // A member's target lands on the sign-in page. An invitation now lands on |
| // the guest screen instead (guest.html, added with guest joining): join in |
| - // the app, a name form to continue in the browser, and a link to sign in. |
| + // the app, a name form to continue in the browser, and the bar's Sign in. |
| // Only the sign-in page carries the address field, so follow the link when |
| // that is what this target produced. |
| const address = page.locator('input[name="address"]'); |
| if ((await address.count()) === 0) { |
| await Promise.all([ |
| page.waitForURL('**/signin*'), |
| - page.getByRole('link', { name: 'sign in', exact: true }).click(), |
| + // Since the shared chrome (f86d4da) the link is the bar's "Sign in". |
| + page.getByRole('link', { name: 'Sign in', exact: true }).first().click(), |
| ]); |
| } |
| await address.fill(config.address); |
diff --git a/test/browser/native.mjs b/test/browser/native.mjs| index 9ade7d2..f5a85ca 100644 |
| --- a/test/browser/native.mjs |
| +++ b/test/browser/native.mjs |
| @@ -28,6 +28,20 @@ try { |
| await page.goto(`${origin}/m/${room}`); |
| await page.waitForFunction(() => !!window.meet); |
| console.log('browser before native', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length}))); |
| + // Spec §15.24, both ways: the browser's offers, answers and candidates |
| + // are held back for the first few seconds, so the app and the browser |
| + // each have someone who cannot hear them yet, and each says so. |
| + const HOLD = 5000; |
| + 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 new Promise(r => setTimeout(r, Math.max(0, releaseAt - Date.now()))); |
| + } |
| + await route.continue(); |
| + }; |
| + if (mode !== 'guest') await page.route('**/signal', holdSignals); |
| native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); |
| let sample = null; |
| @@ -76,6 +90,33 @@ try { |
| await page.getByRole('button', { name: 'Admit', exact: true }).click(); |
| console.log('PASS guest media waits for member admission'); |
| } |
| + if (mode !== 'guest') { |
| + await wait(() => sample?.peer, 'the app is in the call'); |
| + const app = sample.peer; |
| + const connecting = () => page.evaluate((id) => { |
| + const s = window.meet.state(); |
| + return { c: s.connecting[id] || null, h: s.hearing[id] || null, media: s.remoteMedia[id] || null, |
| + me: s.peer, hearingList: s.hearingList }; |
| + }, app); |
| + await wait(async () => { const c = await connecting(); return c.h && c.h.noticeKind === 'connecting'; }, |
| + 'the browser says the app cannot hear it yet', 15); |
| + const words = (await connecting()).h.notice; |
| + assert.match(words, / is connecting and can’t hear you yet$/); |
| + console.log(`PASS the browser says "${words}" while the connection is held`); |
| + await wait(() => sample?.stats.connectingShown === 1, 'the app says the browser cannot hear it yet', 15); |
| + console.log('PASS the app says the browser cannot hear it yet'); |
| + await wait(async () => { const c = await connecting(); return c.c && !c.c.joining && c.h.notice === ''; }, |
| + 'the browser stops saying it once the app hears it', 30); |
| + const heard = await connecting(); |
| + assert.ok((heard.media.hearing || []).includes(heard.me), 'because the app reports it hears the browser'); |
| + assert.ok(Date.now() >= releaseAt, 'and not before the hold let go'); |
| + console.log('PASS the app reports hearing the browser, and the browser\u2019s notice goes'); |
| + await wait(() => sample?.stats.connectingShown === 0 && sample?.stats.connectingJoining === 0 && sample?.stats.hearing === 1, |
| + 'the app stops saying it once the browser hears it', 30); |
| + assert.ok(heard.hearingList.includes(app) || (await connecting()).hearingList.includes(app), 'the browser reports it hears the app'); |
| + console.log('PASS the browser reports hearing the app, and the app\u2019s notice goes'); |
| + await page.unroute('**/signal', holdSignals); |
| + } |
| await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.audioBytesReceived > 0 && sample?.stats.videoBytesSent > 0 && sample?.stats.audioBytesSent > 0, 'native receives browser audio/video'); |
| console.log('PASS native decoded browser video and received audio'); |
| // The synthetic microphone runs through WebRTC's capture processing, so |
diff --git a/test/browser/room-connecting.test.mjs b/test/browser/room-connecting.test.mjs| new file mode 100644 |
| index 0000000..74a57de |
| --- /dev/null |
| +++ b/test/browser/room-connecting.test.mjs |
| @@ -0,0 +1,196 @@ |
| +// 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', 'hearingMoved']; |
| +// 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, hearingMoved, 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'); |
| +}); |
| + |
| +test('hearingMoved: a gain, or a loss of someone still here, and never a departure alone', () => { |
| + const here = new Set(['a', 'b']); |
| + assert.equal(hearingMoved('', 'a', here), true); |
| + assert.equal(hearingMoved('a', 'a,b', here), true); |
| + assert.equal(hearingMoved('a,b', 'a', here), true, 'b is still here and no longer heard'); |
| + assert.equal(hearingMoved('a,c', 'a', here), false, 'c has left: not worth a report from everyone'); |
| + assert.equal(hearingMoved('a', 'a', here), false); |
| + assert.equal(hearingMoved('', '', here), false); |
| +}); |
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 }); |
| +}); |