oficina / meet Public

Spec §15.20: what the receiver can hear; hub test for the receiving half

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev c727e4375c51cefe012f8532d0e8e623d612be1a parent b2295f8
2 files changed, +127 −1
  • docs/superpowers/specs/2026-09-07-meet-design.md +69 −1
  • internal/calls/hub/media_test.go +58 −0
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 9e3a680..224da8a 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -567,7 +567,7 @@ a multi-host fleet; that is noted, not solved.
| `reset {to}` | an admitted peer | both peers of the pair | Under the hub lock, increment the pair's generation and send `pair {peer, gen}` to both, into their replay rings. Idempotent in effect: two resets yield one higher generation both sides converge on. |
| `chat {text}` | an admitted peer | all admitted peers | Fan out with the sender's name. 2 KB. Not stored. |
| `share {on}` | an admitted peer | all admitted peers | Fan out so tiles can be labelled. |
-| `media {muted, cameraOff}` (2026-09-23, §15.19) | an admitted peer | all other admitted peers | Record on the participation, carry in every later `roster` and `peer`; fan out only a change. |
+| `media {muted, cameraOff, audioBlocked?, noSoundFrom?}` (2026-09-23, §15.19; 2026-09-25, §15.20) | an admitted peer | all other admitted peers | Record on the participation, carry in every later `roster` and `peer`; fan out only a change. |
| `hello`, `roster`, `peer`, `gone`, `knock`, `knock-gone`, `full`, `ended`, `lost`, `pair`, `gap`, `state` | server | as named | `roster`, `peer`, `pair` and `gap` go only to admitted peers; a waiting guest receives `hello`, `state`, then `roster` on admission, or `lost`, or `ended`, and nothing else. |
`offer`, `answer` and `candidate` also carry `seq` and `gen` (§4.6);
@@ -2295,3 +2295,71 @@ for the feature to exist, but no order of deployment breaks a call.
**Not logged, not stored.** Like every accepted message, a report logs
nothing; a refused one logs type, room, peer and outcome only (§4.4).
The state lives on the in-memory participation and dies with it (§2.2).
+
+### 15.20 §15.19, §5 — what the receiver can hear (2026-09-25)
+
+**Why.** On 2026-09-25 Tony, in a browser, could not hear Paul, who was
+on the Mac app with a multi-channel audio interface. Paul's device panel
+looked fine because nothing on it measured sound; Tony's side had no way
+to tell Paul anything; and the server keeps no call logs (§0.1), so there
+was nothing to read afterwards. The failure was undiagnosable from either
+end. Two measured facts shaped the answer:
+
+- Chromium (Chrome and Edge) does not apply its autoplay policy to a
+ media element whose source is a WebRTC `MediaStream`: with the desktop
+ policy forced on and no user gesture at all, the remote tile plays,
+ while the same browser refuses a plain audio file. A refusal is still
+ possible in Safari, Firefox and a browser set to block sound, so the
+ recovery below is kept, but it was not what happened here.
+- LiveKit's AVAudioEngine audio device module connects the microphone in
+ mono, and the conversion from a multi-channel interface keeps its first
+ input only. A microphone on any other input sends silence.
+
+**Decision.** §15.19's `media` report gains two optional members, each
+carrying only bad news and omitted when there is none:
+
+- `audioBlocked: true` — this browser was refused playback of remote
+ sound and is waiting for a click.
+- `noSoundFrom: [peer, …]` — the peers whose audio is arriving here
+ (connected, packets flowing) as silence, for about five seconds, while
+ they report themselves unmuted. At most fifteen distinct peer ids.
+
+Validation is §15.19's, extended: `muted` and `cameraOff` are required;
+the two new members are optional, byte-exact, at most once each,
+`audioBlocked` a boolean and `noSoundFrom` an array of well-formed peer
+ids with no repeats; no other member; the raw payload capped at 1 KB.
+Ids are checked for shape, not presence — the recipients ignore an id
+they hold no tile for. The hub stores, carries in rosters and fans out
+the report exactly as before, and a change to either member is a change.
+The payload out is re-encoded from the validated values, in the order
+`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`.
+
+**Compatibility.** Because the members are omitted when there is no bad
+news, the ordinary report is still the two-key object, and a server
+from before this section accepts it; only a report carrying bad news is
+refused there (400), and the web client then falls back to the two-key
+form for the rest of the page. Old clients ignore the extra members.
+Deploy the server before, or with, the clients.
+
+**What clients show.** Every client measures sound where it can, so a
+person can see it rather than infer it:
+
+- A microphone meter on one's own tile, measured on what is sent, and
+ "Your microphone isn't picking anything up" when it has been flat for
+ about five seconds while unmuted.
+- A level meter on every other person's tile, measured on what arrives,
+ with "No sound from <name>" when their audio has been flat for about
+ five seconds while they report themselves unmuted, and the Muted badge
+ instead of either when they are muted.
+- On a person's tile, their report about us: "<name>'s browser is
+ blocking sound — they need to click to hear you", or "<name> isn't
+ getting any sound from you".
+- In a browser whose playback was refused, a page-level "Click anywhere
+ to turn on sound"; any click or key anywhere resumes every tile.
+- An audio test, before a call and during one, that meters the
+ microphone, plays a test sound, and records a few seconds through the
+ real capture path and plays it back. The recording never leaves the
+ device and is discarded after playback.
+
+**Not logged, not stored.** As §15.19: the report is the sender's claim,
+logged only on refusal, and held on the in-memory participation.
diff --git a/internal/calls/hub/media_test.go b/internal/calls/hub/media_test.go
index 6890c14..a1889f0 100644
--- a/internal/calls/hub/media_test.go
+++ b/internal/calls/hub/media_test.go
@@ -359,3 +359,61 @@ func TestSetMediaUnderConcurrency(t *testing.T) {
close(stop)
wg.Wait()
}
+
+// TestSetMediaCarriesTheReceivingHalf is §15.20 in the hub: the report's
+// optional members are part of the state — a change to either is fanned
+// out, the same list again is silent, and the hub keeps its own copy of
+// the list so a caller that reuses its slice cannot rewrite a roster
+// already sent.
+func TestSetMediaCarriesTheReceivingHalf(t *testing.T) {
+ now := time.Date(2026, 9, 25, 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)
+
+ list := []PeerID{b.Peer}
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{AudioBlocked: true, NoSoundFrom: list}); err != nil {
+ t.Fatal(err)
+ }
+ got := drain(b.Sub)
+ if len(got) != 1 {
+ t.Fatalf("Bob got %d events, want 1", len(got))
+ }
+ want := `{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":["` + string(b.Peer) + `"]}`
+ if string(got[0].Payload) != want {
+ t.Fatalf("payload %s, want %s", got[0].Payload, 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.NoSoundFrom) != 1 || m.NoSoundFrom[0] != b.Peer {
+ t.Fatalf("roster media %+v, want the hub's own copy of the list", m)
+ }
+
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{AudioBlocked: true, NoSoundFrom: []PeerID{b.Peer}}); err != nil {
+ t.Fatal(err)
+ }
+ if got := drain(b.Sub); len(got) != 0 {
+ t.Fatalf("the same report again reached Bob: %v", types(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("clearing the bad news sent %v", got)
+ }
+}