fix(nests): close receiver-audio dropout cluster + auto-start broadcast on host create

Six changes that together close out the bug class the user reported
(receiver "blinks green ring for ~5 s then stops") and the related
host-side "circular spinner until first unmute" UX issue. Each is
defensible independently; together they form a defense-in-depth
against the moq-rs production-relay forward-queue cliff documented
in `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.

#3 — `NestMoqLiteBroadcaster.onLevel` only fires when
`current.send(opus)` returned `true` (frame reached an inbound
subscriber). Two-phone logs showed the host's green/glow firing for
the first ~7 s while no listener was attached and after a relay-side
cliff while no audio was on the wire. The local "I'm broadcasting"
ring now tracks the wire, not the runCatching outcome.

#5 — `DEFAULT_FRAMES_PER_GROUP: 5 → 10` (200 ms of audio per
group, 5 streams/sec). Two-phone production logs showed the relay
still cliffing at ~135 streams under sustained load with the old
5-frame default — same bug class the plan thought it had closed,
just shifted by half. Doubling the pack roughly doubles the
worst-case cliff window. Kept the existing `framesPerGroup = 5`
test scenarios intact since they're explicit on the call site.

#4 — `SUBSCRIBE_RETRY_BACKOFF_INITIAL_MS: 250 → 100`. Logs of the
join path showed 5 retries (250+500+1000+1000+1000 = 3.75 s) of
"subscribe stream FIN before reply" before the relay observed the
broadcast's announce. New ladder is 100+200+400+800+1000 = 2.5 s.
The MAX cap stays at 1000 ms so a permanently-gone publisher still
doesn't hammer the relay.

#2 — `NestViewModel.openSubscription` now passes
`maxLatencyMs = 500L` on every speaker SUBSCRIBE (new constant
`ROOM_AUDIO_MAX_LATENCY_MS`). Tells moq-rs to evict groups older
than 500 ms from the per-subscriber forward queue rather than
queuing them up to the relay's `MAX_GROUP_AGE = 30 s` default —
caps the queue growth that triggers the cliff. The wire support
existed in the listener; only the call site was passing 0L.

#1 — listener-side cliff detector. New `cliffDetectorJob` in
`NestViewModel` periodically scans `activeSubscriptions` against
`lastFrameAt` (per-pubkey monotonic TimeMark, updated in
`onSpeakerActivity`). When a speaker has been announced Active AND
we have an active subscription AND no frames have arrived for
`ROOM_AUDIO_CLIFF_TIMEOUT_MS = 4 s`, force a `recycleSession()` so
the orchestrator opens a fresh QUIC transport — a clean reset of
the relay's per-subscriber forward queue. `ROOM_AUDIO_CLIFF_COOLDOWN_MS = 8 s`
prevents back-to-back recycles while the new session is mid-handshake.
Started from `launchConnect` after `observeAnnounces`, cancelled in
`teardown`. Per-pubkey `lastFrameAt` entry dropped on
`closeSubscription` so a removed speaker doesn't trigger a stale
recycle.

#6 — auto-start broadcast pre-muted on host create.
`NestViewModel.startBroadcast` gets an `initialMuted: Boolean = false`
parameter; when `true` the broadcaster opens the publisher session,
calls `setMuted(true)` before the UI flips to `Broadcasting`, and the
mic stays open + capture loop keeps running with `if (muted) continue`
gating sends. New `LaunchedEffect(speakerPubkeyHex)` in
`OnStageIdleControls` calls `startBroadcast(initialMuted = true)`
automatically when mic permission is already granted, so a host who
just created a room sees their avatar transition to "Live (silent)"
and listeners can subscribe immediately without waiting for the host
to tap "Talk" first. Tap-to-talk path also passes `initialMuted = true`
now — unmute is a separate, explicit step on the mic toggle that
appears once we're in `Broadcasting(isMuted = true)`. Permission-
denied case still requires the manual Talk-button tap to launch the
runtime permission prompt.

Diagnostic logs from the previous two debug commits remain in place
(throttled to ≤ 1 line/sec/speaker at 50 fps capture cadence) so the
next two-phone repro can validate the fixes against logcat directly.
This commit is contained in:
Claude
2026-05-05 16:44:39 +00:00
parent be8dd0a3bc
commit cb61082bc4
4 changed files with 276 additions and 30 deletions
@@ -476,18 +476,23 @@ private class ReconnectingHandle(
// before the publisher exists, and the relay FINs the bidi
// without a SubscribeOk/Drop reply.
//
// - INITIAL = 250 ms: under moq-rs's typical announce-propagation
// latency (< 200 ms in production traces), so the very first
// retry usually succeeds and the listener-side buffer hides
// the gap.
// - Doubles each miss → 500 ms → 1 000 ms.
// - MAX = 1 000 ms: matches the previous flat constant; long
// enough that a never-arriving publisher doesn't hammer the
// relay, short enough to stay under the wrapper SharedFlow's
// ~1.3 s buffer once playback is rolling on the next attempt.
// - INITIAL = 100 ms: lower than the prior 250 ms because
// join-time logs (`claude/fix-nests-audio-receiver-HCgOY`)
// showed the listener was paying ~4 s of dead air on every
// first-join while the broadcaster's session warmed up
// on the relay (5 retries: 250+500+1000+1000+1000 = 3.75 s).
// 100 ms initial halves that to ~1.9 s
// (100+200+400+800+1000) without thrashing the relay on a
// permanently-gone publisher (the MAX cap below still
// bounds the total per-attempt rate).
// - Doubles each miss → 200 ms → 400 ms → 800 ms → 1 000 ms.
// - MAX = 1 000 ms: long enough that a never-arriving
// publisher doesn't hammer the relay; short enough to stay
// under the wrapper SharedFlow's ~1.3 s buffer once playback
// is rolling on the next attempt.
// - Reset on first successful subscribe so a subsequent
// publisher-cycle gap doesn't inherit the previous saturation.
private const val SUBSCRIBE_RETRY_BACKOFF_INITIAL_MS = 250L
private const val SUBSCRIBE_RETRY_BACKOFF_INITIAL_MS = 100L
private const val SUBSCRIBE_RETRY_BACKOFF_MAX_MS = 1_000L
private val SYNTH_OK =
@@ -251,6 +251,14 @@ class NestMoqLiteBroadcaster(
"broadcaster sent frame #$sentFrames (group $framesInCurrentGroup/$framesPerGroup)"
}
}
// Only tap the speaking-ring on a frame
// that actually reached an inbound
// subscriber. Without this gate the local
// ring lights up during the pre-subscribe
// window AND after a relay-side cliff —
// the speaker would believe they're being
// heard when no audio is on the wire.
onLevel(peakAmplitude(pcm))
} else {
droppedNoSubFrames += 1
if (droppedNoSubFrames % SEND_LOG_THROTTLE == 0L) {
@@ -259,12 +267,6 @@ class NestMoqLiteBroadcaster(
}
}
}
// Fire the speaking-ring tap only on
// a successful send. If the publisher
// throws (transport gone, peer dead),
// the frame didn't actually go out and
// the UI shouldn't claim we're talking.
onLevel(peakAmplitude(pcm))
}.onFailure { t ->
if (t is CancellationException) throw t
consecutiveSendErrors += 1
@@ -382,15 +384,32 @@ class NestMoqLiteBroadcaster(
companion object {
/**
* Default moq-lite group size = 5 Opus frames ≈ 100 ms of audio.
* Picked to keep the QUIC uni-stream creation rate
* (10 streams/sec at 20 ms cadence) under the production
* nostrnests relay's sustained per-subscriber forward
* ceiling (~40 streams/sec) while still giving late-joining
* subscribers a sub-100 ms initial audio gap. See
* [framesPerGroup] kdoc for the full rationale + history.
* Default moq-lite group size = 10 Opus frames ≈ 200 ms of audio.
*
* Picked to keep the QUIC uni-stream creation rate (5 streams/sec
* at 20 ms cadence) well under the production nostrnests relay's
* per-subscriber forward queue cliff. Two-phone production tests
* (`claude/fix-nests-audio-receiver-HCgOY` logcat) showed the
* relay still cliffed at ~135 streams under sustained load even
* with the old 5-frame default (10 streams/sec), giving ~13 s of
* audio before the listener's incoming uni-stream flow stopped
* — the same bug class documented in
* `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`,
* just shifted by half. Doubling the pack to 10 cuts the rate
* in half again and roughly doubles the cliff window in the
* worst case while halving the relay-side stream-handling
* pressure that triggers it.
*
* Belt-and-suspenders: pair this with the listener-side
* cliff-detector in `NestViewModel` (no-data-while-announced
* triggers `recycleSession()`) so the room recovers from a
* cliff event regardless of the exact threshold.
*
* Late-join gap: ≤ 200 ms (one group boundary) instead of
* ≤ 100 ms with `framesPerGroup = 5` — still imperceptible
* for live audio rooms.
*/
const val DEFAULT_FRAMES_PER_GROUP: Int = 5
const val DEFAULT_FRAMES_PER_GROUP: Int = 10
/**
* Maximum consecutive [MoqLitePublisherHandle.send] / [endGroup]