oficina / meet Public

Media reports carry what the receiver can hear; the capture hook is metered

Spec §15.20 groundwork: MediaState gains audioBlocked and noSoundFrom,
both optional and present only with bad news, so the ordinary report is
still §15.19's two-key object. The server validates them as strictly as
the required pair (1 KB cap, at most fifteen distinct peer ids). callscore
and the bridge gain SetNoSoundFrom for the native apps.

The capture post-processing hook now measures every buffer after the
suppressor (CaptureLevel, a peak hold) and can hand the processed audio
to a sink, for the microphone meter and the audio test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 62f0d63046dedbcd8232e6bf12237636f9869a0c parent 1d0c4d8
8 files changed, +385 −18
  • callscore/media_test.go +39 −0
  • callscore/room.go +39 −2
  • internal/calls/hub/media.go +9 −2
  • internal/calls/signal.go +93 −11
  • internal/callstest/media_test.go +71 −1
  • native/apple/Sources/CallsKit/NoiseSuppression.swift +83 −2
  • native/bridge/bridge.go +16 −0
  • protocol/messages.go +35 −0
diff --git a/callscore/media_test.go b/callscore/media_test.go
index 53c1d39..a210efa 100644
--- a/callscore/media_test.go
+++ b/callscore/media_test.go
@@ -175,3 +175,42 @@ func TestOwnMediaSurvivesAFreshJoin(t *testing.T) {
t.Fatal("no report after the fresh join")
}
}
+
+// §15.20: the peers this client hears silence from ride its own report.
+// Only peers in the roster other than itself are kept, in roster order,
+// so a stale or foreign id never reaches the hub; a list that changes
+// nothing sends nothing; and clearing it is a change back to the
+// two-key report.
+func TestNoSoundFromIsReportedFromTheRoster(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)
+ }
+ if m := nextMedia(t, posted); len(m.NoSoundFrom) != 0 || m.AudioBlocked {
+ t.Fatalf("admission report %+v, want the two-key report", m)
+ }
+ if err := r.SetNoSoundFrom([]protocol.PeerID{b, "self", "stranger", a}); err != nil {
+ t.Fatal(err)
+ }
+ m := nextMedia(t, posted)
+ if len(m.NoSoundFrom) != 2 || m.NoSoundFrom[0] != a || m.NoSoundFrom[1] != b {
+ t.Fatalf("noSoundFrom %v, want [aaaa bbbb] in roster order", m.NoSoundFrom)
+ }
+ if err := r.SetNoSoundFrom([]protocol.PeerID{a, b}); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "the same list is not a change")
+ if err := r.SetMedia(true, false); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); !m.Muted || len(m.NoSoundFrom) != 2 {
+ t.Fatalf("muting kept the list? %+v", m)
+ }
+ if err := r.SetNoSoundFrom(nil); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); len(m.NoSoundFrom) != 0 || !m.Muted {
+ t.Fatalf("cleared %+v", m)
+ }
+}
diff --git a/callscore/room.go b/callscore/room.go
index 06ec76b..843e75d 100644
--- a/callscore/room.go
+++ b/callscore/room.go
@@ -252,7 +252,44 @@ func pause(ctx context.Context, d time.Duration) bool {
// and the report goes then, as it does after every resume and restart.
func (r *Room) SetMedia(muted, cameraOff bool) error {
r.mu.Lock()
- r.media = protocol.MediaState{Muted: muted, CameraOff: cameraOff}
+ if r.media.Muted == muted && r.media.CameraOff == cameraOff {
+ r.mu.Unlock()
+ return nil
+ }
+ r.media.Muted, r.media.CameraOff = muted, cameraOff
+ r.mu.Unlock()
+ return r.reportMedia()
+}
+
+// SetNoSoundFrom records which peers this client is receiving silence
+// from (spec §15.20) and reports it with the rest of its media state.
+// Only peers in the current roster other than this client are kept, in
+// roster order, so the report is stable and bounded; an empty list is
+// the ordinary report. A native app is never refused playback by an
+// autoplay policy, so audioBlocked is never set from here.
+func (r *Room) SetNoSoundFrom(peers []protocol.PeerID) error {
+ want := make(map[protocol.PeerID]bool, len(peers))
+ for _, p := range peers {
+ want[p] = true
+ }
+ r.mu.Lock()
+ var next []protocol.PeerID
+ for _, p := range r.state.Peers {
+ if p.Peer != r.state.Peer && want[p.Peer] && len(next) < protocol.MaxNoSoundFrom {
+ next = append(next, p.Peer)
+ }
+ }
+ if (protocol.MediaState{NoSoundFrom: next}).Equal(protocol.MediaState{NoSoundFrom: r.media.NoSoundFrom}) {
+ r.mu.Unlock()
+ return nil
+ }
+ r.media.NoSoundFrom = next
+ r.mu.Unlock()
+ return r.reportMedia()
+}
+
+func (r *Room) reportMedia() error {
+ r.mu.Lock()
report := r.state.Phase == "admitted" && !r.stopped
r.mu.Unlock()
if !report {
@@ -278,7 +315,7 @@ func (r *Room) sendMedia() error {
func (r *Room) mediaStaleLocked() bool {
for _, p := range r.state.Peers {
if p.Peer == r.state.Peer {
- return p.Media == nil || *p.Media != r.media
+ return p.Media == nil || !p.Media.Equal(r.media)
}
}
return false
diff --git a/internal/calls/hub/media.go b/internal/calls/hub/media.go
index 6504a0d..22d3e66 100644
--- a/internal/calls/hub/media.go
+++ b/internal/calls/hub/media.go
@@ -12,6 +12,11 @@ import (
// {type: "media", from, name, payload: MediaState}, the envelope
// rebuilt from the sender's participation exactly as Fanout's is.
//
+// §15.20 adds the receiving half to the same report — audioBlocked and
+// noSoundFrom, each present only when it has bad news — 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.
+//
// It is state, not an event. A report that changes nothing is accepted
// and sends nothing, so a client may repeat it freely — on every roster,
// after every resume — and the room hears only changes. Nothing goes in
@@ -46,12 +51,14 @@ func (h *Hub) SetMedia(ctx context.Context, sqid, session string, from PeerID, m
if !p.Admitted {
return ErrNotAdmitted
}
- if p.Media != nil && *p.Media == m {
+ if p.Media != nil && p.Media.Equal(m) {
return nil
}
// A fresh value, never a write through the old pointer: rosters
- // already sent hold that one.
+ // already sent hold that one. The list is copied too, so nothing the
+ // caller still holds is shared with a roster.
next := m
+ next.NoSoundFrom = append([]PeerID(nil), m.NoSoundFrom...)
p.Media = &next
payload, err := json.Marshal(next)
if err != nil {
diff --git a/internal/calls/signal.go b/internal/calls/signal.go
index ae2608f..7c7e837 100644
--- a/internal/calls/signal.go
+++ b/internal/calls/signal.go
@@ -185,32 +185,114 @@ func exactKeys(raw json.RawMessage, want ...string) bool {
}
// maxMediaBytes bounds §15.19's payload before it is decoded. The
-// longest honest one, {"muted":false,"cameraOff":false}, is 34 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 two booleans.
-const maxMediaBytes = 128
+// longest honest one — both booleans false, audioBlocked, and a full
+// noSoundFrom of fifteen 32-character peer ids (§15.20) — is a little
+// over 600 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 = 1024
-// validateMedia is §15.19's shape: exactly {"muted": bool, "cameraOff":
-// bool}, both present, keys byte-exact and each once, no other member,
+// validateMedia is §15.19's shape as §15.20 extends it: "muted" and
+// "cameraOff", both booleans and both required; "audioBlocked", a
+// boolean, and "noSoundFrom", an array of at most fifteen distinct peer
+// ids, both optional. 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.
+//
+// A noSoundFrom id is checked for shape and not for presence: it is the
+// sender's claim about peers it was told of, the recipients ignore an id
+// they do not hold a tile for, and checking it against the room would
+// only race the roster.
func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
if len(raw) > maxMediaBytes {
return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size"
}
- if !exactKeys(raw, "muted", "cameraOff") {
+ if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom"}) {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
}
var p struct {
- Muted *bool `json:"muted"`
- CameraOff *bool `json:"cameraOff"`
+ Muted *bool `json:"muted"`
+ CameraOff *bool `json:"cameraOff"`
+ AudioBlocked json.RawMessage `json:"audioBlocked"`
+ NoSoundFrom json.RawMessage `json:"noSoundFrom"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil {
return protocol.MediaState{}, http.StatusBadRequest, "shape"
}
- return protocol.MediaState{Muted: *p.Muted, CameraOff: *p.CameraOff}, 0, ""
+ m := protocol.MediaState{Muted: *p.Muted, CameraOff: *p.CameraOff}
+ // Present means a value of the right type: a null in an optional
+ // member is not "absent", it is a payload nobody's client writes.
+ if p.AudioBlocked != nil {
+ if err := json.Unmarshal(p.AudioBlocked, &m.AudioBlocked); err != nil || bytes.Equal(p.AudioBlocked, []byte("null")) {
+ return protocol.MediaState{}, http.StatusBadRequest, "shape"
+ }
+ }
+ if p.NoSoundFrom != nil {
+ var ids []hub.PeerID
+ if err := json.Unmarshal(p.NoSoundFrom, &ids); err != nil || ids == nil || len(ids) > protocol.MaxNoSoundFrom {
+ return protocol.MediaState{}, http.StatusBadRequest, "shape"
+ }
+ seen := make(map[hub.PeerID]bool, len(ids))
+ for _, id := range ids {
+ if !validPeerID(id) || seen[id] {
+ return protocol.MediaState{}, http.StatusBadRequest, "shape"
+ }
+ seen[id] = true
+ }
+ // Empty is the ordinary report, and is kept as nil so that it
+ // compares equal to one that left the key out.
+ if len(ids) > 0 {
+ m.NoSoundFrom = ids
+ }
+ }
+ return m, 0, ""
+}
+
+// memberKeys is exactKeys with optional members: every name in required
+// exactly once, any name in optional at most once, each byte-exact, and
+// nothing else.
+func memberKeys(raw json.RawMessage, required, optional []string) bool {
+ allowed := make(map[string]bool, len(required)+len(optional))
+ for _, w := range required {
+ allowed[w] = true
+ }
+ for _, w := range optional {
+ allowed[w] = true
+ }
+ seen := make(map[string]bool, len(allowed))
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ open, err := dec.Token()
+ if err != nil {
+ return false
+ }
+ if d, ok := open.(json.Delim); !ok || d != '{' {
+ return false
+ }
+ for dec.More() {
+ key, err := dec.Token()
+ if err != nil {
+ return false
+ }
+ name, ok := key.(string)
+ if !ok || !allowed[name] || seen[name] {
+ return false
+ }
+ seen[name] = true
+ var value json.RawMessage
+ if err := dec.Decode(&value); err != nil {
+ return false
+ }
+ }
+ if _, err := dec.Token(); err != nil {
+ return false
+ }
+ for _, w := range required {
+ if !seen[w] {
+ return false
+ }
+ }
+ return !dec.More()
}
// canonicalJSON encodes one validated value the way it goes out on the
diff --git a/internal/callstest/media_test.go b/internal/callstest/media_test.go
index 008549f..13951af 100644
--- a/internal/callstest/media_test.go
+++ b/internal/callstest/media_test.go
@@ -100,6 +100,19 @@ func TestMediaShapes(t *testing.T) {
{"a number for a bool", `{"muted":1,"cameraOff":0}`},
{"not an object", `[true,false]`},
{"trailing content", `{"muted":true,"cameraOff":false} {}`},
+ // §15.20's two optional members, each held as hard as the two
+ // required ones.
+ {"audioBlocked null", `{"muted":true,"cameraOff":false,"audioBlocked":null}`},
+ {"audioBlocked a string", `{"muted":true,"cameraOff":false,"audioBlocked":"yes"}`},
+ {"audioBlocked twice", `{"muted":true,"cameraOff":false,"audioBlocked":true,"audioBlocked":false}`},
+ {"audioBlocked alone", `{"audioBlocked":true}`},
+ {"noSoundFrom null", `{"muted":true,"cameraOff":false,"noSoundFrom":null}`},
+ {"noSoundFrom an object", `{"muted":true,"cameraOff":false,"noSoundFrom":{"` + strings.Repeat("a", 32) + `":true}}`},
+ {"noSoundFrom a short id", `{"muted":true,"cameraOff":false,"noSoundFrom":["abc"]}`},
+ {"noSoundFrom an upper-case id", `{"muted":true,"cameraOff":false,"noSoundFrom":["` + strings.Repeat("A", 32) + `"]}`},
+ {"noSoundFrom a number", `{"muted":true,"cameraOff":false,"noSoundFrom":[1]}`},
+ {"noSoundFrom a repeat", `{"muted":true,"cameraOff":false,"noSoundFrom":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`},
+ {"noSoundFrom sixteen ids", `{"muted":true,"cameraOff":false,"noSoundFrom":[` + sixteenIDs() + `]}`},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
@@ -129,7 +142,7 @@ func TestMediaShapes(t *testing.T) {
}
})
t.Run("padded past the cap", func(t *testing.T) {
- payload := `{"muted":true,` + strings.Repeat(" ", 128) + `"cameraOff":false}`
+ payload := `{"muted":true,` + strings.Repeat(" ", 1024) + `"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)
@@ -137,6 +150,63 @@ func TestMediaShapes(t *testing.T) {
})
}
+func sixteenIDs() string {
+ ids := make([]string, 16)
+ for i := range ids {
+ ids[i] = `"` + strings.Repeat("0", 30) + string("0123456789abcdef"[i]) + `0"`
+ }
+ return strings.Join(ids, ",")
+}
+
+// Spec §15.20: the receiving half rides the same report. A browser that
+// was refused sound, and one hearing silence from a peer, say so; the
+// hub carries it canonically to the others and in rosters; the ordinary
+// report is still the two-key object, so a report that clears the bad
+// news is a change that goes out as exactly that.
+func TestMediaCarriesWhatTheReceiverCanHear(t *testing.T) {
+ a, sqid, pa, sb := chatPair(t)
+ silent := strings.Repeat("0", 31) + "1"
+ body := `{"peer":"` + pa + `","type":"media","payload":{"noSoundFrom":["` + silent + `"], "audioBlocked":true,"cameraOff":false,"muted":false}}`
+ res, rb := a.signalRaw(sqid, body)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("media: %d %s", res.StatusCode, rb)
+ }
+ _, data, ok := sb.nextOf("media", 2*time.Second)
+ if !ok {
+ t.Fatal("Bob did not receive the report")
+ }
+ want := `"payload":{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":["` + silent + `"]}`
+ if !strings.Contains(string(data), want) {
+ t.Fatalf("envelope %s, want %s", data, want)
+ }
+
+ // Fifteen ids is the most there can be, and is accepted.
+ ids := strings.Split(sixteenIDs(), ",")[:15]
+ res, rb = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"noSoundFrom":[`+strings.Join(ids, ",")+`]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("fifteen: %d %s", res.StatusCode, rb)
+ }
+ sb.nextOf("media", 2*time.Second)
+
+ // Cleared: back to the two-key report, which is a change.
+ res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"noSoundFrom":[]}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("clear: %d", res.StatusCode)
+ }
+ _, data, ok = sb.nextOf("media", 2*time.Second)
+ if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) {
+ t.Fatalf("cleared report %s", data)
+ }
+ // And an empty list is the same report as no list at all.
+ res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"audioBlocked":false}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("repeat: %d", res.StatusCode)
+ }
+ if ev, _, ok := sb.next(300 * time.Millisecond); ok && ev == "media" {
+ t.Fatal("a report that changed nothing reached Bob")
+ }
+}
+
// 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/apple/Sources/CallsKit/NoiseSuppression.swift b/native/apple/Sources/CallsKit/NoiseSuppression.swift
index fd37649..ce0168e 100644
--- a/native/apple/Sources/CallsKit/NoiseSuppression.swift
+++ b/native/apple/Sources/CallsKit/NoiseSuppression.swift
@@ -288,8 +288,35 @@ public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate,
public func audioProcessingProcess(audioBuffer: RTCAudioBuffer) {
lock.withLock { $0.buffers += 1 }
- guard audioBuffer.channels == 1 else { return }
- process(audioBuffer.rawBuffer(forChannel: 0), frames: audioBuffer.frames)
+ guard audioBuffer.channels >= 1 else { return }
+ let samples = audioBuffer.rawBuffer(forChannel: 0)
+ if audioBuffer.channels == 1 { process(samples, frames: audioBuffer.frames) }
+ observe(samples, frames: audioBuffer.frames)
+ }
+
+ /// What leaves the hook, measured: the level of every buffer after the
+ /// suppressor and gain have had it (or passed it through), which is the
+ /// audio that is encoded and sent. Read by the microphone meter.
+ public let level = CaptureLevel()
+
+ /// Something that wants the processed audio itself — the audio test's
+ /// recording. Called on the audio thread with WebRTC's ±32768 floats and
+ /// the rate WebRTC is processing at; it must copy and return, never
+ /// block. Nil almost always.
+ public var sink: (@Sendable (UnsafePointer<Float>, Int, Int) -> Void)? {
+ get { lock.withLock { _ in sinkBox } }
+ set { lock.withLock { _ in sinkBox = newValue } }
+ }
+ private var sinkBox: (@Sendable (UnsafePointer<Float>, Int, Int) -> Void)?
+
+ /// The measuring half of the hook, apart from WebRTC's buffer type so
+ /// the tests can drive it: after `process`, whatever the channel count
+ /// or rate, so a stream the suppressor passes through is metered too.
+ public func observe(_ samples: UnsafePointer<Float>, frames: Int) {
+ guard frames > 0 else { return }
+ level.record(samples, frames: frames, at: clock())
+ let (sink, rate) = lock.withLock { s in (sinkBox, s.rate) }
+ sink?(samples, frames, rate)
}
public func audioProcessingRelease() {
@@ -328,3 +355,57 @@ public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate,
}
}
}
+
+/// The level of the processed microphone, kept for a meter to read.
+///
+/// Written on the audio thread once per buffer (10 ms), read on the main
+/// actor a few times a second. What a read gets is the loudest buffer
+/// since the last read — a peak hold — so a meter sampled at 10 Hz does
+/// not miss a syllable that fell between two samples. Levels are 0...1 of
+/// full scale (WebRTC's ±32768).
+public final class CaptureLevel: @unchecked Sendable {
+ public struct Reading: Equatable, Sendable {
+ /// Loudest buffer RMS since the last read, 0...1.
+ public var rms: Float
+ /// Loudest single sample since the last read, 0...1.
+ public var peak: Float
+ /// Buffers measured since the last read. Zero means nothing flowed:
+ /// muted (WebRTC skips the hook then) or not capturing.
+ public var buffers: Int
+ /// When the last buffer was measured, on the clock the adapter was
+ /// given (uptime seconds by default).
+ public var at: Double
+ }
+ private let lock = OSAllocatedUnfairLock(initialState: Reading(rms: 0, peak: 0, buffers: 0, at: -.infinity))
+
+ public init() {}
+
+ public func record(_ samples: UnsafePointer<Float>, frames: Int, at time: Double) {
+ var energy: Float = 0
+ var peak: Float = 0
+ for i in 0..<frames {
+ let v = samples[i]
+ energy += v * v
+ peak = max(peak, abs(v))
+ }
+ let rms = (energy / Float(frames)).squareRoot() / 32768
+ let top = min(peak / 32768, 1)
+ lock.withLock { r in
+ r.rms = max(r.rms, min(rms, 1))
+ r.peak = max(r.peak, top)
+ r.buffers += 1
+ r.at = time
+ }
+ }
+
+ /// The reading since the last call, and a fresh start for the next.
+ public func take() -> Reading {
+ lock.withLock { r in
+ let out = r
+ r.rms = 0
+ r.peak = 0
+ r.buffers = 0
+ return out
+ }
+ }
+}
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index d00541f..1803359 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -12,6 +12,7 @@ import (
"net/url"
"amadan.net/oficina/calls/callscore"
+ "amadan.net/oficina/calls/protocol"
)
// Listener receives JSON events on a Go callback thread. Enqueue on the main
@@ -111,6 +112,21 @@ func (c *Client) Close() { c.room.Close() }
// server has not got it, and at once on every change.
func (c *Client) SetMedia(muted, cameraOff bool) error { return c.room.SetMedia(muted, cameraOff) }
+// SetNoSoundFrom is spec §15.20's receiving half: the peers whose audio
+// is reaching this app as silence, as a JSON array of peer ids (gomobile
+// carries no slices). The core keeps only peers in its roster and
+// reports the list with the rest of this app's media state; "[]" or ""
+// clears it.
+func (c *Client) SetNoSoundFrom(peersJSON string) error {
+ var ids []protocol.PeerID
+ if peersJSON != "" {
+ if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil {
+ return errors.New("invalid peer list")
+ }
+ }
+ return c.room.SetNoSoundFrom(ids)
+}
+
// RegisterDevice hands the server an Apple device token so a knock can
// reach this device when the app is closed. Best effort: the caller
// ignores the error beyond logging, because a device that did not
diff --git a/protocol/messages.go b/protocol/messages.go
index 67cac5e..231a737 100644
--- a/protocol/messages.go
+++ b/protocol/messages.go
@@ -119,4 +119,39 @@ type RosterPeer struct {
type MediaState struct {
Muted bool `json:"muted"`
CameraOff bool `json:"cameraOff"`
+ // AudioBlocked and NoSoundFrom are the receiving half (spec §15.20):
+ // what this peer can say about the sound reaching it, so that the
+ // person talking can be told. Both carry only bad news and are
+ // omitted when there is none, so an ordinary report is the two-key
+ // object §15.19 defined and a server from before §15.20 still takes
+ // it.
+ //
+ // AudioBlocked: the browser refused to play remote sound (an autoplay
+ // policy wanting a click first).
+ AudioBlocked bool `json:"audioBlocked,omitempty"`
+ // NoSoundFrom: the peers whose audio is arriving here as silence —
+ // connected, reporting themselves unmuted, and flat for several
+ // seconds. At most MaxNoSoundFrom, each a peer id, none repeated.
+ NoSoundFrom []PeerID `json:"noSoundFrom,omitempty"`
+}
+
+// MaxNoSoundFrom bounds MediaState.NoSoundFrom: a room holds at most
+// sixteen participations (§4.2), so fifteen others is every one there
+// could be.
+const MaxNoSoundFrom = 15
+
+// Equal compares two reports field by field; NoSoundFrom is 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.
+func (m MediaState) Equal(o MediaState) bool {
+ if m.Muted != o.Muted || m.CameraOff != o.CameraOff || m.AudioBlocked != o.AudioBlocked ||
+ len(m.NoSoundFrom) != len(o.NoSoundFrom) {
+ return false
+ }
+ for i := range m.NoSoundFrom {
+ if m.NoSoundFrom[i] != o.NoSoundFrom[i] {
+ return false
+ }
+ }
+ return true
}