From 3889a213e5ea78ec473a3c10c9e89bcfe0ecd7da Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 14 Oct 2026 14:40:25 +0000 Subject: [PATCH 1/3] docs: define admin-mute vs self-mute state model and unmute semantics Specifies the layered adminMuted/selfMuted state model, effective-mute logic (adminMuted || selfMuted), and the data-channel mute/unmute payload contract, per the acceptance criteria. Documentation only; implementation tracked in #676 and #678. Closes #677 Co-Authored-By: Claude Opus 4.8 --- docs/mute-state-model.md | 214 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/mute-state-model.md diff --git a/docs/mute-state-model.md b/docs/mute-state-model.md new file mode 100644 index 00000000..1cd5a5c0 --- /dev/null +++ b/docs/mute-state-model.md @@ -0,0 +1,214 @@ +# Mute State Model: Admin-Mute vs Self-Mute and Unmute Semantics + +This document defines the state model and data-channel payload contract that +distinguishes an **admin-forced mute** from a participant's **own self-mute**, +and specifies the unmute semantics for each. + +It is a **specification / contract** document. It describes the current behaviour +accurately and defines the target model, but it does not change any runtime code. +Implementation is tracked in the follow-up issues referenced below. + +- Part of #280 / breakdown of #637. +- Defined by: #677 (this document). +- Implemented by: #676 (admin mute/unmute UI + payload) and #678 (target-client + state propagation and effective-mute logic). + +--- + +## 1. Current behaviour + +Today there is a single remote-mute path and **no distinction** between a mute +an admin forced on a participant and a mute the participant chose for +themselves. There is also no documented unmute payload. + +### 1.1 Sending a mute (admin side) + +`muteParticipant()` in +`src/components/production-line/production-line.tsx` sends a data-channel +message over the open SMB `RTCDataChannel`: + +```json +{ + "type": "EndpointMessage", + "to": "", + "payload": { + "muteParticipant": "mute" + } +} +``` + +### 1.2 Receiving a mute (target side) + +1. `parseDataChannelMessage(data)` in + `src/components/production-line/data-channel-parser.ts` validates the JSON and + returns an `EndpointMessage` whose `payload` carries + `muteParticipant?: string` (and/or `forceDisconnect?: string`). +2. `isRemoteMute(msg)` returns + `msg.payload.muteParticipant === "mute" && msg.to !== msg.from` + (the `to !== from` guard ignores self-addressed echoes). +3. `onDataChannelMessage` in + `src/components/production-line/use-rtc-connection.ts` dispatches + `UPDATE_CALL` with `updates: { isRemotelyMuted: isRemoteMute(message) }`. +4. `isRemotelyMuted` lives on `CallState` in `src/global-state/types.ts`. +5. An effect in `production-line.tsx` reacts: + `if (isRemotelyMuted && !isProgramOutputLine) muteInput(true)`. +6. `muteInput(true)` in + `src/components/production-line/use-mute-input.tsx` sets + `track.enabled = false` on every input track, sets the local + `isInputMuted = true`, and — because the mute was consumed — dispatches + `isRemotelyMuted: false` back to the store. + +### 1.3 Gaps this document closes + +- **No admin/self distinction.** There is only one local `isInputMuted` + boolean. An admin mute and a self mute are indistinguishable once applied, so + the system cannot reason about "who muted whom". +- **No unmute payload.** `parseDataChannelMessage` already tolerates an + arbitrary `muteParticipant` string (so `"unmute"` parses), but `isRemoteMute` + only recognises `"mute"`, and nothing acts on an unmute. There is no contract + for admin-unmute. +- **Risk of overwriting self-mute.** A naive admin-unmute that force-sets + `track.enabled = true` would override a participant who deliberately muted + themselves — violating the broadcast-privacy norm used by RTS / Clear-Com + panels, where an admin cannot force a self-muted operator live. + +--- + +## 2. Target state model + +The target client tracks **two independent boolean states** per call. Neither +overwrites the other; they are composed at the point audio is applied. + +| State | Owner | Meaning | +| ------------ | ------------------ | -------------------------------------------------------- | +| `selfMuted` | The participant | The participant muted their own input. | +| `adminMuted` | An admin / another client | An admin forced this participant muted via data channel. | + +Both are stored on `CallState` +(`src/global-state/types.ts`). `adminMuted` replaces the single-shot role that +`isRemotelyMuted` plays today; `selfMuted` is the persisted form of the local +`isInputMuted` toggle. The two states are set by **different** inputs and are +never collapsed into one another. + +### 2.1 Effective mute + +Audio output is governed by the **effective mute**, the logical OR of the two +states: + +``` +effectiveMuted = adminMuted || selfMuted +``` + +The input tracks are driven directly from it: + +``` +track.enabled = !(adminMuted || selfMuted) +``` + +Because the two states are OR-combined and stored separately, clearing one does +**not** clear the other. This is the mechanism that preserves a participant's +self-mute choice across an admin-unmute. + +--- + +## 3. Data-channel payload contract + +All mute/unmute directives are carried as `EndpointMessage` payloads over the +SMB data channel, consistent with the existing format. The directive key stays +`muteParticipant`; its value gains an `"unmute"` member that mirrors `"mute"`. + +### 3.1 Admin mute (existing — unchanged) + +```json +{ + "type": "EndpointMessage", + "to": "", + "payload": { + "muteParticipant": "mute" + } +} +``` + +Effect on target: `adminMuted = true`. + +### 3.2 Admin unmute (new — mirror of mute) + +```json +{ + "type": "EndpointMessage", + "to": "", + "payload": { + "muteParticipant": "unmute" + } +} +``` + +Effect on target: `adminMuted = false`. It **only** clears `adminMuted`. It must +**not** touch `selfMuted` and must **not** force `track.enabled = true`. After +the state change, `effectiveMuted` is recomputed from the OR, so a +self-muted participant stays muted. + +`isRemoteMute` / its unmute counterpart keep the `to !== from` guard so that +self-addressed echoes are ignored. + +### 3.3 Self-unmute (local action — not a data-channel directive) + +A participant's own mute and unmute are **local** UI actions (the existing mute +toggle driving `muteInput`); they set `selfMuted` and are **not** sent as +`muteParticipant` directives to themselves. This keeps the admin channel and the +self channel fully independent: + +- Self-mute/unmute writes `selfMuted` only. +- Admin mute/unmute (`muteParticipant: "mute" | "unmute"`) writes `adminMuted` + only. + +A participant performing a local unmute clears only their `selfMuted`; if +`adminMuted` is still set they remain muted until the admin sends an unmute. + +--- + +## 4. Worked examples + +`enabled` is the resulting `MediaStreamTrack.enabled` +(`!(adminMuted || selfMuted)`). + +| # | Start (`adminMuted`, `selfMuted`) | Event | End (`adminMuted`, `selfMuted`) | `effectiveMuted` | `enabled` | +| - | --------------------------------- | --------------------------------------- | ------------------------------- | ---------------- | --------- | +| 1 | (false, false) | admin sends `mute` | (true, false) | true | false | +| 2 | (true, false) | admin sends `unmute` | (false, false) | false | true | +| 3 | (false, true) | admin sends `mute` | (true, true) | true | false | +| 4 | (true, true) | admin sends `unmute` | (false, true) | true | false | +| 5 | (true, false) | participant self-mutes | (true, true) | true | false | +| 6 | (false, true) | participant self-unmutes | (false, false) | false | true | + +### 4.1 Acceptance scenario (a): admin-unmute of a self-muted user leaves them muted + +Row **4**. The participant chose to self-mute (`selfMuted = true`) and an admin +had also muted them (`adminMuted = true`). The admin sends `unmute`, clearing +`adminMuted` to `false`. Because `selfMuted` is untouched, +`effectiveMuted = false || true = true` and `track.enabled` stays `false` — the +participant **remains muted**, honouring their own choice. + +### 4.2 Acceptance scenario (b): admin-unmute of an admin-only-muted user restores prior self-mute state + +Row **2**. The participant never self-muted (`selfMuted = false`); only the admin +muted them (`adminMuted = true`). The admin sends `unmute`, clearing +`adminMuted` to `false`. `effectiveMuted = false || false = false` and +`track.enabled` becomes `true` — the participant becomes audible again, i.e. +their prior self-mute state (false) is restored. + +--- + +## 5. Summary of the contract + +- Two independent, persisted booleans on `CallState`: `adminMuted` and + `selfMuted` (the latter supersedes the single-shot `isRemotelyMuted` + + `isInputMuted` behaviour described in Section 1). +- `effectiveMuted = adminMuted || selfMuted`; input tracks follow + `track.enabled = !(adminMuted || selfMuted)`. +- Data-channel directives (`EndpointMessage.payload.muteParticipant`): + `"mute"` sets `adminMuted = true`; the new `"unmute"` sets + `adminMuted = false`. Neither touches `selfMuted`. +- Self-mute/unmute are local actions that write only `selfMuted`. +- Admin-unmute never force-unmutes a self-muted participant; it only clears the + admin layer. From e14d48b4098e9856966442920edf47d9bdc2cad2 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 14 Oct 2026 15:36:35 +0000 Subject: [PATCH 2/3] docs: address self-review feedback on mute state model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Retain the program-output special case (isProgramOutputLine / isProgramUser) explicitly in §2.1 and §5 instead of letting the effectiveMuted OR formula silently subsume it. - Fix §5 so isRemotelyMuted maps to adminMuted (not selfMuted). - Note that #678 must drop the consume/reset dispatch in use-mute-input.tsx:33-43 now that adminMuted is persisted. - Clarify in §3.2 that `from` on the "unmute" directive is injected by the SMB bridge, mirroring the mute path. Co-Authored-By: Claude Opus 4.8 --- docs/mute-state-model.md | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/mute-state-model.md b/docs/mute-state-model.md index 1cd5a5c0..06537d1b 100644 --- a/docs/mute-state-model.md +++ b/docs/mute-state-model.md @@ -90,6 +90,12 @@ Both are stored on `CallState` `isInputMuted` toggle. The two states are set by **different** inputs and are never collapsed into one another. +Because `adminMuted` is now **persisted** rather than consumed, #678 must also +drop the consume-and-reset step the current path performs in +`use-mute-input.tsx:33-43` (§1.2 step 6), which dispatches `isRemotelyMuted: +false` back to the store immediately after applying the mute. If that reset were +kept, `adminMuted` could never stay set and an admin-only mute could not hold. + ### 2.1 Effective mute Audio output is governed by the **effective mute**, the logical OR of the two @@ -109,6 +115,26 @@ Because the two states are OR-combined and stored separately, clearing one does **not** clear the other. This is the mechanism that preserves a participant's self-mute choice across an admin-unmute. +#### Program-output lines retain their existing special case + +The formula above is the rule for a **normal** line. It deliberately does **not** +subsume the two program-output behaviours that §1.2 already relies on, and #678 +must preserve both rather than regressing them: + +- **Admin mute stays gated on `!isProgramOutputLine`.** Today the effect in + `production-line.tsx` applies a remote mute only `if (isRemotelyMuted && + !isProgramOutputLine)`. The `adminMuted` layer inherits that gate: on a + program-output line `adminMuted` must not drive `track.enabled`, so switching to + the OR formula must not newly honour admin mute there. +- **Forced mute for non-program users is unchanged.** `use-mute-input.tsx` + force-mutes when `isProgramOutputLine && !isProgramUser`; that forced mute is + independent of both `adminMuted` and `selfMuted` and must continue to apply. + +So the complete target rule is "normal lines follow +`track.enabled = !(adminMuted || selfMuted)`; program-output lines keep the +existing `isProgramOutputLine` / `isProgramUser` handling". Scoping the +program-output dimension out of the OR formula is intentional — it is not a gap. + --- ## 3. Data-channel payload contract @@ -149,7 +175,9 @@ the state change, `effectiveMuted` is recomputed from the OR, so a self-muted participant stays muted. `isRemoteMute` / its unmute counterpart keep the `to !== from` guard so that -self-addressed echoes are ignored. +self-addressed echoes are ignored. As on the mute path, the `from` field on the +`"unmute"` directive is injected by the SMB bridge (the sender does not set it), +which is what makes the `to !== from` comparison meaningful. ### 3.3 Self-unmute (local action — not a data-channel directive) @@ -202,10 +230,12 @@ their prior self-mute state (false) is restored. ## 5. Summary of the contract - Two independent, persisted booleans on `CallState`: `adminMuted` and - `selfMuted` (the latter supersedes the single-shot `isRemotelyMuted` + - `isInputMuted` behaviour described in Section 1). -- `effectiveMuted = adminMuted || selfMuted`; input tracks follow - `track.enabled = !(adminMuted || selfMuted)`. + `selfMuted`. `adminMuted` supersedes the single-shot `isRemotelyMuted` role; + `selfMuted` is the persisted form of the local `isInputMuted` toggle (both + described in Section 1). +- `effectiveMuted = adminMuted || selfMuted`; on **normal** lines input tracks + follow `track.enabled = !(adminMuted || selfMuted)`. Program-output lines keep + their existing `isProgramOutputLine` / `isProgramUser` handling (see §2.1). - Data-channel directives (`EndpointMessage.payload.muteParticipant`): `"mute"` sets `adminMuted = true`; the new `"unmute"` sets `adminMuted = false`. Neither touches `selfMuted`. From b534cb8bb04553057d665657835593488c539b6a Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 14 Oct 2026 15:46:40 +0000 Subject: [PATCH 3/3] style: apply prettier formatting to mute-state-model doc Prettier-only reformat (markdown table alignment and inline-code wrapping) of the already-reviewed content; no semantic change. Co-Authored-By: Claude Opus 4.8 --- docs/mute-state-model.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/mute-state-model.md b/docs/mute-state-model.md index 06537d1b..10ab3b14 100644 --- a/docs/mute-state-model.md +++ b/docs/mute-state-model.md @@ -79,9 +79,9 @@ message over the open SMB `RTCDataChannel`: The target client tracks **two independent boolean states** per call. Neither overwrites the other; they are composed at the point audio is applied. -| State | Owner | Meaning | -| ------------ | ------------------ | -------------------------------------------------------- | -| `selfMuted` | The participant | The participant muted their own input. | +| State | Owner | Meaning | +| ------------ | ------------------------- | -------------------------------------------------------- | +| `selfMuted` | The participant | The participant muted their own input. | | `adminMuted` | An admin / another client | An admin forced this participant muted via data channel. | Both are stored on `CallState` @@ -123,7 +123,7 @@ must preserve both rather than regressing them: - **Admin mute stays gated on `!isProgramOutputLine`.** Today the effect in `production-line.tsx` applies a remote mute only `if (isRemotelyMuted && - !isProgramOutputLine)`. The `adminMuted` layer inherits that gate: on a +!isProgramOutputLine)`. The `adminMuted` layer inherits that gate: on a program-output line `adminMuted` must not drive `track.enabled`, so switching to the OR formula must not newly honour admin mute there. - **Forced mute for non-program users is unchanged.** `use-mute-input.tsx` @@ -200,14 +200,14 @@ A participant performing a local unmute clears only their `selfMuted`; if `enabled` is the resulting `MediaStreamTrack.enabled` (`!(adminMuted || selfMuted)`). -| # | Start (`adminMuted`, `selfMuted`) | Event | End (`adminMuted`, `selfMuted`) | `effectiveMuted` | `enabled` | -| - | --------------------------------- | --------------------------------------- | ------------------------------- | ---------------- | --------- | -| 1 | (false, false) | admin sends `mute` | (true, false) | true | false | -| 2 | (true, false) | admin sends `unmute` | (false, false) | false | true | -| 3 | (false, true) | admin sends `mute` | (true, true) | true | false | -| 4 | (true, true) | admin sends `unmute` | (false, true) | true | false | -| 5 | (true, false) | participant self-mutes | (true, true) | true | false | -| 6 | (false, true) | participant self-unmutes | (false, false) | false | true | +| # | Start (`adminMuted`, `selfMuted`) | Event | End (`adminMuted`, `selfMuted`) | `effectiveMuted` | `enabled` | +| --- | --------------------------------- | ------------------------ | ------------------------------- | ---------------- | --------- | +| 1 | (false, false) | admin sends `mute` | (true, false) | true | false | +| 2 | (true, false) | admin sends `unmute` | (false, false) | false | true | +| 3 | (false, true) | admin sends `mute` | (true, true) | true | false | +| 4 | (true, true) | admin sends `unmute` | (false, true) | true | false | +| 5 | (true, false) | participant self-mutes | (true, true) | true | false | +| 6 | (false, true) | participant self-unmutes | (false, false) | false | true | ### 4.1 Acceptance scenario (a): admin-unmute of a self-muted user leaves them muted