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:
+33
-3
@@ -50,6 +50,7 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -270,8 +271,11 @@ private fun OnStageControls(
|
|||||||
|
|
||||||
is BroadcastUiState.Failed -> {
|
is BroadcastUiState.Failed -> {
|
||||||
// Reason is shown in the status strip; this button retries.
|
// Reason is shown in the status strip; this button retries.
|
||||||
|
// Same auto-muted semantics as the idle path: the retry
|
||||||
|
// should bring the publisher back up without opening the
|
||||||
|
// mic, then the user can unmute deliberately.
|
||||||
TalkButton(
|
TalkButton(
|
||||||
onClick = { viewModel.startBroadcast(speakerPubkeyHex) },
|
onClick = { viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true) },
|
||||||
contentDescription = stringRes(R.string.nest_talk),
|
contentDescription = stringRes(R.string.nest_talk),
|
||||||
)
|
)
|
||||||
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
|
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
|
||||||
@@ -289,17 +293,43 @@ private fun OnStageIdleControls(
|
|||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
permissionDenied = !granted
|
permissionDenied = !granted
|
||||||
if (granted) viewModel.startBroadcast(speakerPubkeyHex)
|
// Permission grant on a Talk-button tap should start a
|
||||||
|
// *muted* broadcast — auto-start semantics. The host
|
||||||
|
// taps Talk to claim the on-stage slot; unmute is a
|
||||||
|
// separate, deliberate action below.
|
||||||
|
if (granted) viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
|
||||||
}
|
}
|
||||||
// The launcher callback never fires for Settings-deep-link grants, so
|
// The launcher callback never fires for Settings-deep-link grants, so
|
||||||
// re-check on every recomposition to auto-clear the warning.
|
// re-check on every recomposition to auto-clear the warning.
|
||||||
val showDenialWarning =
|
val showDenialWarning =
|
||||||
permissionDenied && !context.hasMicPermission()
|
permissionDenied && !context.hasMicPermission()
|
||||||
|
|
||||||
|
// Auto-start the muted broadcast as soon as the host (or any
|
||||||
|
// on-stage speaker) lands on this composable WITH mic permission
|
||||||
|
// already granted. Without this, a host who creates a room sees
|
||||||
|
// a "Connecting..." spinner over their avatar until they tap Talk
|
||||||
|
// — the publisher session only opens on tap, so listeners can't
|
||||||
|
// subscribe yet, and the `connectingSpeakers` overlay sticks. By
|
||||||
|
// pre-opening the publisher in muted state here, listeners get
|
||||||
|
// an immediate Active announce and the host's avatar leaves the
|
||||||
|
// spinner state on its own. Unmute is still a separate tap on
|
||||||
|
// the mic toggle that appears once we transition to
|
||||||
|
// `BroadcastUiState.Broadcasting(isMuted = true)`.
|
||||||
|
//
|
||||||
|
// Permission-denied case is intentionally left to the manual
|
||||||
|
// Talk-button tap below: we don't want a host to be auto-prompted
|
||||||
|
// for the mic the instant they enter the room. Tapping Talk is
|
||||||
|
// the consent gesture that triggers `permissionLauncher`.
|
||||||
|
LaunchedEffect(speakerPubkeyHex) {
|
||||||
|
if (context.hasMicPermission()) {
|
||||||
|
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TalkButton(
|
TalkButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (context.hasMicPermission()) {
|
if (context.hasMicPermission()) {
|
||||||
viewModel.startBroadcast(speakerPubkeyHex)
|
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
|
||||||
} else {
|
} else {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-3
@@ -265,6 +265,41 @@ class NestViewModel(
|
|||||||
private val activeSubscriptions = mutableMapOf<String, ActiveSubscription>()
|
private val activeSubscriptions = mutableMapOf<String, ActiveSubscription>()
|
||||||
private val speakingExpiryJobs = mutableMapOf<String, Job>()
|
private val speakingExpiryJobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monotonic [kotlin.time.TimeMark] of the last MoQ object delivered
|
||||||
|
* to the consumer flow per speaker pubkey. Updated in
|
||||||
|
* [onSpeakerActivity] alongside the speaking-now flag. Read by
|
||||||
|
* [cliffDetectorJob] to spot the relay-side forward-queue cliff
|
||||||
|
* documented in
|
||||||
|
* `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`:
|
||||||
|
* the relay stops opening new uni streams to the listener while
|
||||||
|
* the announce stream still says the broadcast is Active and the
|
||||||
|
* subscribe bidi is still open from QUIC's POV — meaning no other
|
||||||
|
* layer notices.
|
||||||
|
*/
|
||||||
|
private val lastFrameAt = mutableMapOf<String, kotlin.time.TimeSource.Monotonic.ValueTimeMark>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic scan of [activeSubscriptions] vs [_announcedSpeakers]
|
||||||
|
* vs [lastFrameAt]. When a speaker has been announced Active
|
||||||
|
* AND we have an active subscription AND no frames have arrived
|
||||||
|
* for [ROOM_AUDIO_CLIFF_TIMEOUT_MS], force a `recycleSession()`
|
||||||
|
* so the orchestrator opens a fresh QUIC transport — a clean
|
||||||
|
* recovery from the relay-side per-subscriber forward-queue
|
||||||
|
* starvation that has no QUIC- or moq-lite-level signal.
|
||||||
|
*/
|
||||||
|
private var cliffDetectorJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [kotlin.time.TimeMark] of the most recent recycle the cliff
|
||||||
|
* detector triggered, or `null` if it has never fired. Acts as a
|
||||||
|
* cooldown so a single cliff event doesn't kick off multiple
|
||||||
|
* recycles back-to-back while the wrapper is mid-handshake on
|
||||||
|
* the new session — the new session has no incoming frames yet,
|
||||||
|
* which would otherwise trip the detector again immediately.
|
||||||
|
*/
|
||||||
|
private var lastCliffRecycleAt: kotlin.time.TimeSource.Monotonic.ValueTimeMark? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-speaker catalog-fetch coroutines. Each entry is the
|
* Per-speaker catalog-fetch coroutines. Each entry is the
|
||||||
* background `subscribeCatalog` collector launched in
|
* background `subscribeCatalog` collector launched in
|
||||||
@@ -561,7 +596,10 @@ class NestViewModel(
|
|||||||
* session over a separate WebTransport — nests' protocol uses one
|
* session over a separate WebTransport — nests' protocol uses one
|
||||||
* session per direction.
|
* session per direction.
|
||||||
*/
|
*/
|
||||||
fun startBroadcast(speakerPubkeyHex: String) {
|
fun startBroadcast(
|
||||||
|
speakerPubkeyHex: String,
|
||||||
|
initialMuted: Boolean = false,
|
||||||
|
) {
|
||||||
if (closed || !canBroadcast) return
|
if (closed || !canBroadcast) return
|
||||||
if (_uiState.value.connection !is ConnectionUiState.Connected) return
|
if (_uiState.value.connection !is ConnectionUiState.Connected) return
|
||||||
if (_uiState.value.broadcast is BroadcastUiState.Broadcasting ||
|
if (_uiState.value.broadcast is BroadcastUiState.Broadcasting ||
|
||||||
@@ -608,7 +646,26 @@ class NestViewModel(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
broadcastHandle = handle
|
broadcastHandle = handle
|
||||||
_uiState.update { it.copy(broadcast = BroadcastUiState.Broadcasting(isMuted = false)) }
|
// Auto-start path (room creation): the host wants
|
||||||
|
// listeners to be able to subscribe BEFORE they hit
|
||||||
|
// unmute, so we open the publisher session pre-muted.
|
||||||
|
// The mic stays open + the broadcaster keeps
|
||||||
|
// capturing + encoding, but `setMuted(true)` makes
|
||||||
|
// every send drop on the floor inside
|
||||||
|
// `NestMoqLiteBroadcaster.start`'s `if (muted) continue`
|
||||||
|
// gate. Listener-side announces fire as soon as the
|
||||||
|
// publisher registers with the relay, so the audience
|
||||||
|
// can subscribe immediately and switch from
|
||||||
|
// "Connecting…" to "Live (silent)" while they wait
|
||||||
|
// for the host's first word. Composes with `focusMuted`
|
||||||
|
// exactly the same way [setMicMuted] does so an
|
||||||
|
// inbound phone call during the auto-start window
|
||||||
|
// doesn't get a silent unmute.
|
||||||
|
if (initialMuted) {
|
||||||
|
val effective = initialMuted || focusMuted
|
||||||
|
runCatching { handle.setMuted(effective) }
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(broadcast = BroadcastUiState.Broadcasting(isMuted = initialMuted)) }
|
||||||
} catch (ce: CancellationException) {
|
} catch (ce: CancellationException) {
|
||||||
throw ce
|
throw ce
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -855,6 +912,7 @@ class NestViewModel(
|
|||||||
listener = l
|
listener = l
|
||||||
observeListenerState(l)
|
observeListenerState(l)
|
||||||
observeAnnounces(l)
|
observeAnnounces(l)
|
||||||
|
startCliffDetector()
|
||||||
} catch (ce: CancellationException) {
|
} catch (ce: CancellationException) {
|
||||||
throw ce
|
throw ce
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -915,6 +973,11 @@ class NestViewModel(
|
|||||||
if (rawAudioLevels.remove(slot.pubkey) != null) {
|
if (rawAudioLevels.remove(slot.pubkey) != null) {
|
||||||
_audioLevels.value = rawAudioLevels.toMap()
|
_audioLevels.value = rawAudioLevels.toMap()
|
||||||
}
|
}
|
||||||
|
// Drop the cliff-detector heartbeat for this pubkey so the next
|
||||||
|
// tick doesn't see a stale "last frame was 30s ago" entry for a
|
||||||
|
// speaker we already stopped subscribing to and trigger a
|
||||||
|
// gratuitous recycle.
|
||||||
|
lastFrameAt.remove(slot.pubkey)
|
||||||
if (roomPlayer != null || handle != null) {
|
if (roomPlayer != null || handle != null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
roomPlayer?.runCatching { stop() }
|
roomPlayer?.runCatching { stop() }
|
||||||
@@ -961,7 +1024,21 @@ class NestViewModel(
|
|||||||
) {
|
) {
|
||||||
if (closed || activeSubscriptions[pubkey] !== slot) return
|
if (closed || activeSubscriptions[pubkey] !== slot) return
|
||||||
try {
|
try {
|
||||||
val handle = l.subscribeSpeaker(pubkey)
|
// [maxLatencyMs] tells the relay our staleness tolerance:
|
||||||
|
// groups older than this should be evicted, not queued.
|
||||||
|
// With `0L` (the wire default the JS reference uses) the
|
||||||
|
// relay's per-subscriber forward queue grows unbounded
|
||||||
|
// until it cliffs — see
|
||||||
|
// `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.
|
||||||
|
// 500 ms is long enough to ride out a typical relay-side
|
||||||
|
// hiccup without pruning a healthy group, short enough
|
||||||
|
// that a wedged subscriber drains its buffer before the
|
||||||
|
// queue overflows. Combined with the cliff-detector below
|
||||||
|
// that triggers `recycleSession()` when frames stop
|
||||||
|
// arriving despite the broadcast still being announced,
|
||||||
|
// this is a defense-in-depth pair against the moq-rs
|
||||||
|
// forward-queue starvation behaviour.
|
||||||
|
val handle = l.subscribeSpeaker(pubkey, maxLatencyMs = ROOM_AUDIO_MAX_LATENCY_MS)
|
||||||
// Re-check after the suspending subscribeSpeaker — the user
|
// Re-check after the suspending subscribeSpeaker — the user
|
||||||
// may have removed this speaker via updateSpeakers / disconnected
|
// may have removed this speaker via updateSpeakers / disconnected
|
||||||
// while the SUBSCRIBE was in flight. If so, abandon the handle
|
// while the SUBSCRIBE was in flight. If so, abandon the handle
|
||||||
@@ -1089,6 +1166,10 @@ class NestViewModel(
|
|||||||
announcesJob = null
|
announcesJob = null
|
||||||
levelEmitterJob?.cancel()
|
levelEmitterJob?.cancel()
|
||||||
levelEmitterJob = null
|
levelEmitterJob = null
|
||||||
|
cliffDetectorJob?.cancel()
|
||||||
|
cliffDetectorJob = null
|
||||||
|
lastFrameAt.clear()
|
||||||
|
lastCliffRecycleAt = null
|
||||||
// Audio-focus observation only ends on the final teardown —
|
// Audio-focus observation only ends on the final teardown —
|
||||||
// a transient disconnect+reconnect (user retry, room swap)
|
// a transient disconnect+reconnect (user retry, room swap)
|
||||||
// keeps the bus subscription alive so a focus loss that
|
// keeps the bus subscription alive so a focus loss that
|
||||||
@@ -1175,6 +1256,14 @@ class NestViewModel(
|
|||||||
*/
|
*/
|
||||||
private fun onSpeakerActivity(pubkey: String) {
|
private fun onSpeakerActivity(pubkey: String) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
// Cliff-detector heartbeat: each MoQ object proves the
|
||||||
|
// listener-side relay-forward queue is still flowing for this
|
||||||
|
// speaker. The detector only acts when an announced speaker's
|
||||||
|
// last-frame timestamp ages past `ROOM_AUDIO_CLIFF_TIMEOUT_MS`,
|
||||||
|
// so an active stream resets it on every frame.
|
||||||
|
lastFrameAt[pubkey] =
|
||||||
|
kotlin.time.TimeSource.Monotonic
|
||||||
|
.markNow()
|
||||||
speakingExpiryJobs[pubkey]?.cancel()
|
speakingExpiryJobs[pubkey]?.cancel()
|
||||||
// First frame for this subscription — clear the buffering
|
// First frame for this subscription — clear the buffering
|
||||||
// overlay. Subsequent frames are no-ops here.
|
// overlay. Subsequent frames are no-ops here.
|
||||||
@@ -1191,6 +1280,61 @@ class NestViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic scan that detects the relay-side forward-queue cliff:
|
||||||
|
* an announced speaker whose subscription is open but hasn't
|
||||||
|
* delivered any object in [ROOM_AUDIO_CLIFF_TIMEOUT_MS]. Triggers
|
||||||
|
* `recycleSession()` on the listener so the wrapper opens a fresh
|
||||||
|
* QUIC transport — the new session has a fresh per-subscriber
|
||||||
|
* queue on the relay's side.
|
||||||
|
*
|
||||||
|
* Idempotent on start. Stops itself when the listener is gone
|
||||||
|
* (explicit teardown signal) — no-ops on transient empty
|
||||||
|
* `activeSubscriptions` so an audience-only listener doesn't
|
||||||
|
* needlessly recycle.
|
||||||
|
*/
|
||||||
|
private fun startCliffDetector() {
|
||||||
|
if (cliffDetectorJob?.isActive == true) return
|
||||||
|
cliffDetectorJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(ROOM_AUDIO_CLIFF_CHECK_INTERVAL_MS)
|
||||||
|
if (closed) return@launch
|
||||||
|
val l = listener ?: continue
|
||||||
|
if (_uiState.value.connection !is ConnectionUiState.Connected) continue
|
||||||
|
// Cooldown: if we very recently triggered a recycle,
|
||||||
|
// give the orchestrator time to bring up the new
|
||||||
|
// session before re-checking. A new session has
|
||||||
|
// nothing in `lastFrameAt` yet which would otherwise
|
||||||
|
// immediately re-trigger.
|
||||||
|
val sinceRecycle = lastCliffRecycleAt?.elapsedNow()
|
||||||
|
if (sinceRecycle != null &&
|
||||||
|
sinceRecycle.inWholeMilliseconds < ROOM_AUDIO_CLIFF_COOLDOWN_MS
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val announced = _announcedSpeakers.value
|
||||||
|
val stalled =
|
||||||
|
activeSubscriptions
|
||||||
|
.asSequence()
|
||||||
|
.filter { (pubkey, slot) -> slot.isPlaying && pubkey in announced }
|
||||||
|
.filter { (pubkey, _) ->
|
||||||
|
val mark = lastFrameAt[pubkey] ?: return@filter false
|
||||||
|
mark.elapsedNow().inWholeMilliseconds >= ROOM_AUDIO_CLIFF_TIMEOUT_MS
|
||||||
|
}.map { it.key }
|
||||||
|
.toList()
|
||||||
|
if (stalled.isEmpty()) continue
|
||||||
|
com.vitorpamplona.quartz.utils.Log.w("NestRx") {
|
||||||
|
"cliff-detector: announced+subscribed but silent for ≥${ROOM_AUDIO_CLIFF_TIMEOUT_MS}ms — recycling session. stalled=$stalled"
|
||||||
|
}
|
||||||
|
lastCliffRecycleAt =
|
||||||
|
kotlin.time.TimeSource.Monotonic
|
||||||
|
.markNow()
|
||||||
|
runCatching { l.recycleSession() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearSpeaking(pubkey: String) {
|
private fun clearSpeaking(pubkey: String) {
|
||||||
speakingExpiryJobs.remove(pubkey)
|
speakingExpiryJobs.remove(pubkey)
|
||||||
if (_uiState.value.speakingNow.contains(pubkey)) {
|
if (_uiState.value.speakingNow.contains(pubkey)) {
|
||||||
@@ -1442,6 +1586,54 @@ const val ROOM_PLAYER_PREROLL_FRAMES: Int = 5
|
|||||||
*/
|
*/
|
||||||
const val LEVEL_TICK_MS: Long = 100L
|
const val LEVEL_TICK_MS: Long = 100L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `max_latency` we send on every speaker SUBSCRIBE. Tells the relay
|
||||||
|
* to evict groups older than this from the per-subscriber forward
|
||||||
|
* queue rather than holding them up to the relay's `MAX_GROUP_AGE`
|
||||||
|
* default (30 s). Defends against the moq-rs forward-queue cliff
|
||||||
|
* documented in
|
||||||
|
* `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.
|
||||||
|
* 500 ms = 25 Opus frames at 20 ms cadence — long enough to ride
|
||||||
|
* out a typical hiccup without dropping a healthy frame, short
|
||||||
|
* enough to keep the relay's per-subscriber queue bounded.
|
||||||
|
*/
|
||||||
|
const val ROOM_AUDIO_MAX_LATENCY_MS: Long = 500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inactivity threshold for the listener-side cliff detector. If
|
||||||
|
* a speaker is `_announcedSpeakers` (= relay says they're broadcasting)
|
||||||
|
* AND the wrapper's MoqObject flow doesn't deliver a frame for this
|
||||||
|
* many milliseconds, the listener forces a `recycleSession()` so the
|
||||||
|
* orchestrator opens a fresh QUIC transport. Resets the relay's
|
||||||
|
* per-subscriber forward queue.
|
||||||
|
*
|
||||||
|
* 4 s gives enough headroom to ride out a publisher-side group-rollover
|
||||||
|
* hiccup (typically < 200 ms) without false-positive recycling, while
|
||||||
|
* keeping the audible gap from a real cliff to ~5 s of silence at
|
||||||
|
* worst (4 s detection + ~1 s reconnect handshake).
|
||||||
|
*/
|
||||||
|
const val ROOM_AUDIO_CLIFF_TIMEOUT_MS: Long = 4_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the cliff detector wakes up to scan
|
||||||
|
* `activeSubscriptions` against `lastFrameAt`. 1 s is a coarse
|
||||||
|
* enough cadence not to cost anything (one map walk per second)
|
||||||
|
* while keeping detection latency well under the 4 s threshold.
|
||||||
|
*/
|
||||||
|
const val ROOM_AUDIO_CLIFF_CHECK_INTERVAL_MS: Long = 1_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cooldown after a cliff-detector-driven recycle. Suppresses
|
||||||
|
* back-to-back recycles while the wrapper is mid-handshake on the
|
||||||
|
* fresh session — a brand-new session has no `lastFrameAt` entries
|
||||||
|
* yet, which would otherwise immediately re-trigger the detector
|
||||||
|
* on the next 1 s tick. Long enough to cover the typical reconnect
|
||||||
|
* handshake plus a couple of audio-frame round trips, short enough
|
||||||
|
* that a recycle that didn't actually clear the cliff is recovered
|
||||||
|
* from quickly.
|
||||||
|
*/
|
||||||
|
const val ROOM_AUDIO_CLIFF_COOLDOWN_MS: Long = 8_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a kind-7 reaction stays in
|
* How long a kind-7 reaction stays in
|
||||||
* [NestViewModel.recentReactions] before the eviction sweep
|
* [NestViewModel.recentReactions] before the eviction sweep
|
||||||
|
|||||||
+15
-10
@@ -476,18 +476,23 @@ private class ReconnectingHandle(
|
|||||||
// before the publisher exists, and the relay FINs the bidi
|
// before the publisher exists, and the relay FINs the bidi
|
||||||
// without a SubscribeOk/Drop reply.
|
// without a SubscribeOk/Drop reply.
|
||||||
//
|
//
|
||||||
// - INITIAL = 250 ms: under moq-rs's typical announce-propagation
|
// - INITIAL = 100 ms: lower than the prior 250 ms because
|
||||||
// latency (< 200 ms in production traces), so the very first
|
// join-time logs (`claude/fix-nests-audio-receiver-HCgOY`)
|
||||||
// retry usually succeeds and the listener-side buffer hides
|
// showed the listener was paying ~4 s of dead air on every
|
||||||
// the gap.
|
// first-join while the broadcaster's session warmed up
|
||||||
// - Doubles each miss → 500 ms → 1 000 ms.
|
// on the relay (5 retries: 250+500+1000+1000+1000 = 3.75 s).
|
||||||
// - MAX = 1 000 ms: matches the previous flat constant; long
|
// 100 ms initial halves that to ~1.9 s
|
||||||
// enough that a never-arriving publisher doesn't hammer the
|
// (100+200+400+800+1000) without thrashing the relay on a
|
||||||
// relay, short enough to stay under the wrapper SharedFlow's
|
// permanently-gone publisher (the MAX cap below still
|
||||||
// ~1.3 s buffer once playback is rolling on the next attempt.
|
// 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
|
// - Reset on first successful subscribe so a subsequent
|
||||||
// publisher-cycle gap doesn't inherit the previous saturation.
|
// 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 const val SUBSCRIBE_RETRY_BACKOFF_MAX_MS = 1_000L
|
||||||
|
|
||||||
private val SYNTH_OK =
|
private val SYNTH_OK =
|
||||||
|
|||||||
+33
-14
@@ -251,6 +251,14 @@ class NestMoqLiteBroadcaster(
|
|||||||
"broadcaster sent frame #$sentFrames (group $framesInCurrentGroup/$framesPerGroup)"
|
"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 {
|
} else {
|
||||||
droppedNoSubFrames += 1
|
droppedNoSubFrames += 1
|
||||||
if (droppedNoSubFrames % SEND_LOG_THROTTLE == 0L) {
|
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 ->
|
}.onFailure { t ->
|
||||||
if (t is CancellationException) throw t
|
if (t is CancellationException) throw t
|
||||||
consecutiveSendErrors += 1
|
consecutiveSendErrors += 1
|
||||||
@@ -382,15 +384,32 @@ class NestMoqLiteBroadcaster(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Default moq-lite group size = 5 Opus frames ≈ 100 ms of audio.
|
* Default moq-lite group size = 10 Opus frames ≈ 200 ms of audio.
|
||||||
* Picked to keep the QUIC uni-stream creation rate
|
*
|
||||||
* (10 streams/sec at 20 ms cadence) under the production
|
* Picked to keep the QUIC uni-stream creation rate (5 streams/sec
|
||||||
* nostrnests relay's sustained per-subscriber forward
|
* at 20 ms cadence) well under the production nostrnests relay's
|
||||||
* ceiling (~40 streams/sec) while still giving late-joining
|
* per-subscriber forward queue cliff. Two-phone production tests
|
||||||
* subscribers a sub-100 ms initial audio gap. See
|
* (`claude/fix-nests-audio-receiver-HCgOY` logcat) showed the
|
||||||
* [framesPerGroup] kdoc for the full rationale + history.
|
* 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]
|
* Maximum consecutive [MoqLitePublisherHandle.send] / [endGroup]
|
||||||
|
|||||||
Reference in New Issue
Block a user