Working notes for the in-progress voice-over-signals branch
(feature/voice-over-signals). Captures the architectural decisions
still owed before further code lands. Written 2026-04-11.
Voice-over-signals shipped as a parallel module alongside the existing WebRTC path. That was the wrong shape: voice and WebRTC-audio are not two separate features, they are two transports for the same conceptual thing (the user's microphone). Symptoms of the wrong shape:
- "waiting for connection" overlay sticks around when WebRTC is off even though the Holochain-level circle (and therefore the room membership) is fine.
- Two simultaneous
getUserMediacalls (one from WebRTC, one from the voice module) compete for the same input device. - Mute state, device-picker state, and AGC/NS settings live inside the WebRTC code path and aren't reusable.
- No coherent way for one Starlink user to "downgrade" their incoming audio without the rest of the room coordinating in chat.
- Lift the audio source out of WebRTC. Create a transport-agnostic
MicSource(or similar) that owns thegetUserMediacall, the selected device id, the mute flag, and exposes aMediaStreamTrackfor consumers. WebRTC'saudioOn/audioOffbecomes one consumer; voice'sMediaStreamTrackProcessorbecomes another. - Reuse the existing mic button. Mute / device-pick UX stays identical regardless of transport — "the mic" is the user's mental model, transport is plumbing.
- Fix the overlay text. "waiting for connection" was always wrong: the connection (Holochain room membership) is established, indicated by the agent circle. The thing actually being waited on is media bytes, so the message should be "waiting for stream…" or similar. Trigger condition becomes "agent in room AND no media flowing yet," not "no WebRTC peer connection."
Three plausible models:
"I am sending my audio via signals." Local action, local effect. Easy to reason about. Failure mode: a friend who can't receive WebRTC has to ask you in chat to flip your switch — coordination cost on every degraded link.
"Please send me audio via signals." Captures the actual problem case (struggling Starlink user downgrades their own incoming stream unilaterally). Awkward semantically — you're commanding another machine — and weird to extend to other modules.
Either side of an (A, B) link can mark "this pair uses signals." If either flag is set, both directions use signals. State is symmetric, broadcast as part of the module's normal state envelope.
Network reachability between two peers is fundamentally symmetric. WebRTC ICE either finds a viable path between you or it doesn't; if it doesn't, both directions are dead. The asymmetric cases are rare enough that pretending symmetry costs us little. (C) gives the "Starlink user downgrades themselves and everyone in the room adapts" UX from (B) AND the "I notice my friend can't hear me, I downgrade for them" UX from (A).
The semantic shift in (C) is subtle but worth being explicit about: "I am using transport X with peer Y" is a property of the (me, Y) edge, not of me alone. It's not "my preference" — it's "our agreement about this link." When I click the toggle on Alice's pane, I'm declaring that the link to Alice is signals-only. When Alice's UI receives my declaration, she shows the same link as signals-only too. Either of us can flip it back. There is no negotiation, no precedence — just "either flag = signals."
This also gives a clean answer to the WebRTC-retry-loop concern: the global "WebRTC off" toggle goes away, and per-peer transport becomes the kill switch for retries. Marking a peer as signals-only:
- Tears down the existing WebRTC peer connection if any
- Removes the peer from the auto-retry init loop in
_processPongMetaData(the thing that firesInitRequestevery 5s when_openConnectionshas no entry) - Routes audio bytes via signals
- Persists across pongs (it lives in module state, which is in
PongMetaDataV1.moduleStates)
Manual retry on "I switched routers" becomes: click the per-peer toggle back to WebRTC, which kicks the init flow on the next pong. No new affordance, just inverting the same action.
A global mic-chevron entry can still exist for bulk-setting ("use signals with everyone in this room") but it is just a convenience that flips the per-peer flag for every current peer.
Once audio transport is pluggable, the voice module's "active" state
is no longer a per-module concept — it becomes "mic state for
transport=signals on this edge." The other storage location for the
same conceptual fact ("Alice's mic is on") is the existing WebRTC
conn.audio flag.
So the badges should consolidate: one mic indicator per peer, with the icon (or color, or badge shape) hinting at which transport is delivering it.
- WebRTC mic on: small filled mic
- WebRTC mic off: small filled mic with slash
- Signals mic on: small mic with a broadcast aura, or mic-with-dot
- Signals mic off: same shape but slashed
Same affordance, transport hinted by appearance. No green broadcast badge stacking on top of a separate WebRTC indicator.
If consolidation lands, the getStateIcons currently on the voice
module becomes wrong — the indicator should be owned by a unified
"mic" concept, not by either transport module. Which suggests the
voice module shouldn't have state icons at all, and the existing
WebRTC mic icon in video.ts:46-52
needs to grow transport-awareness.
- Cleanest: introduce a
micmodule that owns mic state and renders the icon.videoandvoiceboth become transport implementations that subscribe to it. Bigger refactor than the surface task. - Pragmatic: leave the icon in
video.tsfor now and have it look at bothconn.audio(WebRTC) and the voice module state (signals), with a comment noting the eventual extraction. Less pretty, less work.
The WebAudio squelch synth + onPeerStateChange hook drafted in this
branch are coupled to the bigger ontology question. Once mic state
lives in a transport-agnostic MicSource abstraction, transitions
stop being a "voice module" concern and become a "mic state changed"
concern. The squelch should fire when the unified mic state flips,
regardless of transport. That probably wants its own callback hook
or store subscription, not the per-module onPeerStateChange from
this branch.
Decision: hold the squelch work until the unified mic abstraction
exists. The onPeerStateChange plumbing in streams-store.ts and
types.ts is independently useful for other future modules but
should be revisited in the same pass.
- Per-pair union model (option C). Adopted. The global "WebRTC off" toggle goes away; per-peer transport becomes the kill switch for retries. Bulk "use signals for everyone" stays as a chevron entry but is a convenience over the per-peer flag.
- Mic-icon ownership: dedicated
micmodule. Chosen over the "keep it invideo.tswith transport-awareness" pragmatic path. Bigger diff, cleaner ontology —videoandvoicebecome transport implementations that subscribe to themicmodule's state; the mic module owns the indicator and the device-picker UX. MicSourceshape: confirmed. Class onstreamsStoreowninggetUserMedia, device id, mute flag, exposing aMediaStreamTrack. WebRTCaddTrackand voiceMediaStreamTrackProcessorboth consume the same track.- Per-peer toggle placement: confirmed. Clickable badge in the
pane's icon strip, same region as
mdiPhoneRefresh, cycling WebRTC ↔ signals.
Screen-share today broadcasts activateModule('screen-share') before
calling getDisplayMedia, to guarantee the initiator's own <video>
element is in the DOM when the stream arrives (see comment at
screen-share.ts:96-106).
The network-visible side effect is that peers render an
establishing connection... tile for the entire OS-picker duration —
seconds to minutes, or indefinite if the initiator walks away — and
the shared panel re-layouts twice per share attempt.
The two concerns conflated on one lever:
- Local: the initiator needs the
<video>element in the DOM before stream bytes arrive. - Remote: peers should only see a share tile once there is actually something to show.
Fix: introduce a phase field on ModuleStateEnvelope
('acquiring' | 'active', default 'active' for modules that don't
opt in).
- Initiator path: activate with
{ phase: 'acquiring' }. The module is in the local active set — itsrenderShare/DOM hooks fire so the<video>element exists — but the pane renderer does not produce a share tile on peer sides for envelopes inacquiring. OnscreenShareOn()success, transition to{ phase: 'active' }and broadcast. On picker cancel, deactivate fromacquiring; peers never saw anything. - Framework responsibility: the pane renderer (not each module's
renderShare) filters share-type modules by phase. A module-by-module "return empty template" approach leaves empty wrapper tiles and invites drift. - Generalizes beyond screen-share: mic and camera both have a
smaller but real acquiring gap (permission prompt on first grant),
and the
micmodule should use the same convention rather than inventing a local equivalent.
Decision: land the phase convention as part of the mic refactor pass (cheap once the module framework is already being touched) and fix screen-share to use it in the same pass or immediately after. The screen-share fix is standalone enough to defer if scope pressure demands.
Things the decisions above don't yet pin down, found by reading the actual code. Each is an open question the fresh implementation session needs to answer before writing code, not a decided fact.
audioOn/videoOneach callgetUserMediaseparately and both deposit tracks intothis.mainStream, which is theMediaStreambound into every WebRTC peer (see streams-store.ts:407-505 and streams-store.ts:597-689).MicSourcecan cleanly own the audio-onlygetUserMedia, but the resulting track still has to end up inmainStream(or whatever replaces it) so the WebRTC path continues to work. Decide: doesmainStreamsurvive as a composed view overMicSource.track+ camera track, or does WebRTC switch to per-trackaddTrackcalls and stop composing a local stream at all? This is the first structural choice the fresh session will hit.audioOffat streams-store.ts:691-732 only setstrack.enabled = false; it does NOT stop or remove the track. This is deliberate: it keeps the RTC transceiver open for fast re-enable without renegotiation. The voice module in contrast callstracks.forEach(t => t.stop())on deactivate (voice.ts:239-243). These two models are incompatible for a sharedMicSource. Decide:MicSource.setMuted(true)meanstrack.enabled = false(shared, fast, WebRTC-friendly). Voice's current "stop on deactivate" becomes "release consumer reference; source stays alive if other consumers attached."changeAudioInputat streams-store.ts:545-595 owns the device-switching path: newgetUserMedia, stop old track,replaceTrackon every peer. Currently it does not touch voice'smediaStream, which is a latent bug today (switching input in the WebRTC chevron leaves voice on the old device). The unifiedmicmodule naturally fixes it — but the refactor has to move this logic intoMicSourceand make voice subscribe to track changes.mainStreamClonesat streams-store.ts:907-913 exists for therefreshTracksForPeerreconnection path (streams-store.ts:2245). IfMicSourcebecomes the audio owner, the clone machinery has to be re-justified or removed. Don't regress the "peer sees stale stream, reset and rebuild" path while doing the refactor — has been a source of bugs historically.
- Retry suppression already has a hook at
streams-store.ts:2793:
const videoModuleActive = !!get(this._myModuleStates)['video'];gates the entire init-request branch at streams-store.ts:2822-2869. Per-peer suppression slots in as an additional&& !signalsOnly(pubkeyB64)on that condition — small edit, but means the per-peer transport store has to be a synchronousget-able store, not an async lookup. - Shape of per-peer transport state is unspecified. Module state
envelopes are keyed by
moduleId, not by(moduleId, peerId)— see streams-store.ts:1080-1084. The natural encoding is: themicmodule's payload carries{ signalsOnlyWith: [pubkeyA, pubkeyB, ...] }. Other peers read it and check whether they are in the list. Union semantics: the link (me, Y) is signals-only iff my list contains Y OR Y's list contains me. Decide and document this explicitly before coding. - Teardown hook on flag flip.
_dispatchPeerModuleTransition(streams-store.ts:1164-1178) only fires when theactiveflag transitions, NOT on payload-only updates. Marking a peer signals-only is payload-only, so there's no existing callback to hang "tear down the WebRTC connection now" on. Two options: (a) add anonModulePayloadChangehook that fires on payload updates and dispatch the teardown from there; (b) do the imperative teardown from the UI action that sets the flag. (a) is more general and the right shape if we want themicmodule to react declaratively to its own state changes; (b) is cheaper but couples the icon-strip click handler to WebRTC internals. Lean (a). - Initiator side of retry suppression works naturally because
handlePongUire-evaluates on every pong. Receiver side (AwaitingInit) similarly re-reads. No polling needed beyond what's already there.
- Share-type modules: the filter lives in
_getActiveSharesat room-view.ts:1781-1815. Lines 1799 and 1808 currently filter onenvelope.activealone; they need to also skipphase === 'acquiring'. One-line change at each site. - Agent-type modules (mic, video, voice, reactions, raise-hand)
don't go through
_getActiveShares, so phase filtering for them is a different question:getStateIcons,renderOverlay, andonPeerStateChangedispatch all run as soon as the module is active, regardless of phase. For themicmodule specifically, anacquiringphase probably should show the local mic icon (in an "acquiring" visual state) but should not yet cause peers to render "Alice's mic is on" indicators. Define what phase gates for each dispatch surface in the framework, rather than asking each module to opt in. - Voice module has the same "activate-first" defect as screen-share
(voice.ts:463-473):
activateModule('voice')→startCapture()→ deactivate on failure. Cheap because getUserMedia is fast, but the failure window is still peer-visible. The phase convention fixes this for free when voice gets refactored to consumeMicSource. - Screen-share uses
navigator.mediaDevices.getUserMediawithchromeMediaSource: 'desktop'(screen-share.ts + streams-store.ts:743), notgetDisplayMedia— the picker is an Electron/We-launcher custom flow, not the browser-native OS picker. Doesn't change the decision, just means "OS picker latency" in the earlier discussion is actually "custom picker latency," which can be even more variable.
- Voice module creates its own
AudioContextfor the squelch synth (voice.ts:293-305). When the squelch moves to themicmodule, there should be exactly oneAudioContextin the app, owned by mic (orMicSource), shared across squelch + future voice-module playback + any other audio consumer. Two contexts will eventually cause clock drift or autoplay-gesture headaches. Minor but worth pinning down now so the refactor doesn't leave two.
_lastReconcileTime/reconcileVideoStreamState/refreshTracksForPeerflow (streams-store.ts:915-919, 2245, 2865-2868) is the "peer's perception of my stream is stale, reset it" path. Refactor should preserve it. It does not need to be extended to signals transport in this pass (signals has no analogous failure mode since there's no WebRTC state to reconcile).
Answers to the "pre-flight findings" above, agreed before Phase 1 code lands. Listed in the same order as the findings so they're easy to cross-reference.
Keep mainStream as a composed view for now. MicSource owns the
audio-only getUserMedia and exposes a track; existing camera logic
still owns the video track; mainStream is assembled from both for the
WebRTC addTrack/addStream path. Dropping mainStream entirely
(per-track only) would touch mainStreamClones and
refreshTracksForPeer — explicitly out-of-scope for this pass. Separate
those later.
MicSource.setMuted(true) means track.enabled = false (fast
re-enable, no renegotiation, WebRTC-friendly). Mute is a single source
of truth across all consumers — any consumer can set it; every
consumer sees it simultaneously.
Consumer lifetime is refcounted. Justified by at least three concrete consumers:
- WebRTC (
audioOn/audioOff) - Voice module (
MediaStreamTrackProcessor→ Opus encoder) - Hypothetical but plausible: a transcription module doing
MediaStreamTrackProcessor→ ASR
Refcount is not speculative — it exists to stop voice's deactivate from tearing down the track while WebRTC (or a future transcription consumer) still holds it. API sketch:
acquire(consumerId): { track: MediaStreamTrack; release: () => void }
setMuted(muted: boolean)
changeDevice(deviceId: string)
onTrackChanged(cb: (newTrack: MediaStreamTrack) => void)
The underlying device is opened on first acquire, closed on last
release. Device-change broadcasts a track-replacement event so
consumers that bind to a specific track instance (voice's
MediaStreamTrackProcessor is the only one today) can rebuild their
pipeline. WebRTC consumers that just hold a reference can continue
working through replaceTrack without rebuild. Event shape gets
pinned in Phase 2.
The new mic module's payload carries:
{
muted: boolean,
deviceId?: string,
signalsOnlyWith: AgentPubKeyB64[]
}
Resolution is symmetric union:
signalsOnly(peer) =
myState.mic.signalsOnlyWith.includes(peer)
|| peerStates[peer].mic.signalsOnlyWith.includes(me)
Read synchronously via get() so the retry-loop gate at
streams-store.ts:2793 doesn't need to
become async.
Transient-gap note: when A flips signals-only-with-B and broadcasts,
A's retry loop stops on A's next pong; B's retry loop only stops once
A's ModuleState signal reaches B. During the gap B may fire one stray
InitRequest. Acceptable as a transient — both sides converge. Comment
it in the mic module teardown code so it's not mistaken for a bug later.
Add a new onModulePayloadChange(agentPubKeyB64, prev, next) hook to
ModuleDefinition in types.ts. Fired from handleModuleState (and
pong reconciliation) when a module's payload changes while active
stays true — the case _dispatchPeerModuleTransition currently ignores
(streams-store.ts:1164-1178).
The mic module uses it to WebRTC-tear-down a peer transitioning into
its signalsOnlyWith set. Generic enough that future modules can
react declaratively to their own peer-state evolution without hanging
imperative logic off UI click handlers.
Fire-condition caveat: dispatch should not fire the hook on every
payload delta. The mic module's payload changes on every mute toggle,
and most consumers only care about signalsOnlyWith membership. Two
options for avoiding the storm:
- (a) Fire on any payload diff; make the mic module's handler cheap and idempotent (re-check membership, no-op if unchanged).
- (b) Let modules opt in to hook dispatch via a predicate on the module definition.
Lean (a) for simplicity. Revisit if handler cost becomes measurable.
phase?: 'acquiring' | 'active' on ModuleStateEnvelope, default
'active' when absent (backward compatible with all current modules).
Share-type modules: filter in _getActiveShares at
room-view.ts:1799,1808 — skip
envelopes where phase === 'acquiring'. One-line edit at each site.
Agent-type modules: framework-level rules, applied by the dispatch surfaces rather than per-module opt-in.
| Surface | acquiring behavior |
|---|---|
getStateIcons |
Suppressed for peer views. Allowed for isMe. |
renderOverlay |
Suppressed for peer views. Allowed for isMe. |
onPeerStateChange |
Does not fire; waits for transition to active. |
onModulePayloadChange |
Does not fire while either prev or next is acquiring. |
onData |
Unchanged — data arrival is independent of phase. |
Rationale: acquiring means "I've reserved the module slot locally so
my UI can mount (mic icon in loading state, <video> element in DOM)
but peers should not yet believe anything is happening." Self-facing
dispatch surfaces fire; peer-facing ones wait.
One AudioContext in the app, owned by MicSource (the longest-lived
object that cares about audio timing). Squelch, voice playback, and
any future audio consumer borrow it. Voice's ensureAudioContext
(voice.ts:293-305) goes away
in Phase 4.
MicSourceclass with shared track, device/mute state- New
micmodule owning the mic indicator and device-picker UX;videoandvoicebecome transport implementations subscribing to it - Refactor
audioOn/audioOffto consumeMicSource - Per-pair transport store (symmetric union semantics)
- Suppress WebRTC init retries for peers marked signals-only
- Voice module reads from
MicSourceinstead of its owngetUserMedia - Add
phase: 'acquiring' | 'active'toModuleStateEnvelope; pane renderer filters share tiles by phase;micmodule usesacquiringacross the permission prompt - Fix
screen-shareto use the phase convention (activate asacquiring, transition toactiveon stream arrival, deactivate directly fromacquiringon picker cancel) - "Waiting for stream…" overlay copy + corrected trigger condition
- Per-peer toggle in the icon strip
- Bulk "use signals for everyone" entry in mic chevron
- Remove the standalone "video" toolbar toggle added earlier in this branch (replaced by the per-peer mechanism)
- Renaming the
videomodule (worth doing eventually but noisy) - Squelch + module transitions (revisit after mic abstraction)
- Unreliable-datagram path through kitsune2 (pursue only if measured signal latency under load proves unacceptable)
- Native (Moss-side) AEC or audio capture
- Generalized
StreamSource<T>abstraction. Tempting given thatmic, eventuallycamera, already-shippedscreen-share, and hypothetical sensor feeds all "produce something consumers subscribe to." Premature: only one source (MicSource) is being designed in depth; per-source surfaces (AEC/NS/AGC, display-media picker, facingMode, sensor sampling) don't compress; the unification breaks on non-MediaStreamTracksources (sensors). Revisit whenMicSourceplus at least one other concrete source exist and have diverged in ways that a base class would have prevented. The phase convention above is the actually-shared cross-source concept and lives at the module-framework level, not in a source base class.