From bed1b328ce1eb81bceb4fb4e1d1555916547375d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 12:08:15 +0000 Subject: [PATCH] fix(nests): self-terminate the audio-level emitter when no one is talking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous emitter ran a permanent while(true){delay} loop on the viewModelScope from connect-time onward. Cheap in production but catastrophic for any test that calls runTest's advanceUntilIdle() after vm.connect() — virtual time never reached idle, hanging NestViewModelTest's existing connection-state cases for ~15 minutes before the worker timeout killed them. Now the emitter is started lazily by onAudioLevel() and exits the loop the first tick after rawAudioLevels empties, so an idle room schedules nothing. The next decoded frame restarts it. Idempotent, so the launch path doesn't need to call it any more — removed the boot from launchConnect(). --- .../amethyst/commons/viewmodels/NestViewModel.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index b7049f06d..2cecca768 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -656,7 +656,6 @@ class NestViewModel( listener = l observeListenerState(l) observeAnnounces(l) - startLevelEmitter() } catch (ce: CancellationException) { throw ce } catch (t: Throwable) { @@ -949,9 +948,9 @@ class NestViewModel( /** * Record the latest decoded peak amplitude for [pubkey]. Called * from the [NestPlayer] decode loop on the same dispatcher as the - * VM, so plain map mutation is safe. The actual StateFlow emission - * is coalesced by [startLevelEmitter] so a 50 Hz packet rate - * doesn't translate into 50 Hz recompositions. + * VM, so plain map mutation is safe. Boots the coalescing emitter + * on first activity; the emitter shuts itself down once all audio + * has gone quiet, so a long-idle room costs no scheduled work. */ private fun onAudioLevel( pubkey: String, @@ -959,6 +958,7 @@ class NestViewModel( ) { if (closed) return rawAudioLevels[pubkey] = level + startLevelEmitter() } /** @@ -966,6 +966,11 @@ class NestViewModel( * per-speaker levels. Coalesces the high-frequency raw updates * into ~10 Hz UI state so the speaking-ring animation has a * smooth, lightweight signal to follow. + * + * Self-terminating: once a tick observes an empty raw map (and + * has flushed the empty snapshot to consumers) the loop exits. + * The next [onAudioLevel] restarts it. Idempotent — calling + * this while already active is a no-op. */ private fun startLevelEmitter() { if (levelEmitterJob?.isActive == true) return @@ -978,6 +983,7 @@ class NestViewModel( if (snapshot != _audioLevels.value) { _audioLevels.value = snapshot } + if (snapshot.isEmpty()) return@launch } } }