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:
+15
-10
@@ -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 =
|
||||
|
||||
+33
-14
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user