fix(nests): close 4 audio-dropout sources across listener + speaker

1. Listener-side pre-roll + bigger playback buffer + audio-priority thread.
   - NestPlayer buffers 5 decoded frames (~100 ms) before starting
     the AudioPlayer, masking the first-frame underrun that fires
     whenever Compose / GC briefly stalls Main.
   - AudioTrackPlayer sizes the AudioTrack at max(minBuffer*16, 250 ms)
     instead of minBuffer*4 (~80 ms) and writes via a per-instance
     audio-priority single-thread executor (Process.THREAD_PRIORITY_AUDIO)
     instead of Dispatchers.IO, so WRITE_BLOCKING never contends with
     unrelated IO work.

2. MoqLiteSession.subscribe: hoist response typeCode out of the
   collect lambda. readVarint advances pos permanently while
   readSizePrefixed only rolls back its own length-varint, so a
   chunk-split between type and body would re-read the body's size
   prefix as the type code on the next chunk and misframe the
   response. Mirrors the same fix already in handleInboundBidi.

3. MoqLiteSession.subscribe: register the subscription in the map
   BEFORE writing the SUBSCRIBE bytes on the wire. The relay can
   open the first group's uni stream before our continuation
   re-enters [state] to register; if so, drainOneGroup looked the
   id up against an empty map and silently dropped the frame
   (~1 frame / 20 ms gap on first attach). Wrap the post-register
   writes so a transport-failure unwinds the orphan registration.

4. Hot-swap moq-lite publisher across JWT-refresh boundaries.
   - NestMoqLiteBroadcaster: publisher is now @Volatile + supports
     swapPublisher(); capture loop snapshots the reference per frame
     and resets framesInCurrentGroup on swap.
   - MoqLiteNestsSpeaker implements a new internal
     HotSwappablePublisherSource interface that exposes
     openPublisherForHotSwap(track) without spinning up a broadcaster.
   - ReissuingBroadcastHandle keeps a single long-lived broadcaster
     across session recycles when the speaker supports hot swap;
     legacy IETF / fake speakers fall back to close-then-restart.
   - connectReconnectingNestsSpeaker.orchestrator hoists the old-
     session close onto a sibling launch so the wrapper can swap
     the publisher into the broadcaster on the new session before
     the old session's WebTransport drops. Eliminates the previously-
     accepted 50–150 ms audible silence at every JWT refresh.
This commit is contained in:
Claude
2026-05-05 03:06:40 +00:00
parent 1acd54eada
commit 076b301d84
7 changed files with 618 additions and 124 deletions
@@ -868,7 +868,19 @@ class NestViewModel(
try {
val isMuted = _uiState.value.isMuted
val isHushed = pubkey in _uiState.value.locallyHushed
val roomPlayer = NestPlayer(decoder, player, viewModelScope)
val roomPlayer =
NestPlayer(
decoder = decoder,
player = player,
scope = viewModelScope,
// ~100 ms of audio buffered before the AudioTrack
// starts consuming. Long enough to hide a typical
// Main-thread hiccup (Compose recomposition / GC)
// without making the join feel laggy. Empirically
// tuned in the audio-rooms audit; see NestPlayer
// kdoc for details.
prerollFrames = ROOM_PLAYER_PREROLL_FRAMES,
)
// Apply current mute + per-speaker hush state before play()
// opens the device so the first frame respects them.
player.setMutedSafe(isMuted)
@@ -1246,6 +1258,17 @@ sealed class BroadcastUiState {
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
/**
* Per-speaker pre-roll: number of decoded PCM frames buffered before the
* underlying [com.vitorpamplona.nestsclient.audio.AudioPlayer] starts
* consuming. 5 × 20 ms ≈ 100 ms of audio — long enough to mask a typical
* Main-thread stall (Compose recomposition / GC) without adding perceptible
* join latency. Combines with [com.vitorpamplona.nestsclient.audio.AudioTrackPlayer]'s
* ~250 ms AudioTrack buffer for ~350 ms of total slack between the
* arrival-of-frame and the underrun horizon.
*/
const val ROOM_PLAYER_PREROLL_FRAMES: Int = 5
/**
* Coalescing interval for [NestViewModel.audioLevels]. The decode loop
* pushes a fresh peak every ~20 ms (one per Opus frame); we publish to