feat(audio-rooms): M2 multi-speaker subscribe + per-speaker speaking indicator

Listener side now subscribes to every host AND speaker on the stage (was
just hosts) and exposes a `speakingNow: ImmutableSet<String>` derived
from MoQ object arrival. Each on-stage avatar gets a primary-color ring
while its track is delivering audio (debounced 250 ms — ~12 Opus
frames — so packet jitter doesn't make the ring flicker).

ViewModel:
- `AudioRoomViewModel.uiState` gains `speakingNow`.
- New `onSpeakerActivity(pubkey)` is invoked once per received MoQ object
  via a `Flow.onEach` tap on `SubscribeHandle.objects` (before the
  AudioRoomPlayer consumes it). Each invocation (re)arms a per-speaker
  expiry coroutine that clears the entry after `SPEAKING_TIMEOUT_MS`.
- speakingNow is cleared whenever the underlying subscription closes
  (speaker removed from room) or the listener tears down (disconnect /
  onCleared).

Screen:
- AudioRoomViewModel is now hoisted up to AudioRoomStageContent so the
  speaker rows can read speakingNow alongside the connection UI.
- StagePeopleRow wraps each ClickableUserPicture in a 2 dp circular
  border when the participant is in `speakingNow`.
- updateSpeakers now receives `(hosts + speakers).pubKeys` so dynamic
  speaker promotion via NIP-53 event updates flows through to the
  reconcile loop already in M1.

Test:
- Adds `speakingNowClearsOnTeardown` to AudioRoomViewModelTest. The
  per-frame activity path is exercised end-to-end by the existing
  AudioRoomPlayerTest in :nestsClient (which uses the in-memory pipe).

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:amethyst:compilePlayDebugKotlin` green.
This commit is contained in:
Claude
2026-04-26 03:02:34 +00:00
parent f9aa762d9b
commit 46f693800d
3 changed files with 146 additions and 53 deletions
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
@@ -73,6 +75,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -124,6 +128,8 @@ private fun AudioRoomStageContent(
!it.role.equals(ROLE.SPEAKER.code, true) !it.role.equals(ROLE.SPEAKER.code, true)
} }
} }
val onStage = remember(hosts, speakers) { hosts + speakers }
val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() }
var handRaised by rememberSaveable(event.address().toValue()) { mutableStateOf(false) } var handRaised by rememberSaveable(event.address().toValue()) { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -148,6 +154,36 @@ private fun AudioRoomStageContent(
} }
} }
val serviceBase = event.service()
val roomId = event.address().dTag
val audioAvailable = !serviceBase.isNullOrBlank() && roomId.isNotBlank()
val viewModel: AudioRoomViewModel? =
if (audioAvailable) {
val signer = account.signer
val viewModelKey = remember(serviceBase, roomId) { "$serviceBase|$roomId" }
viewModel(
key = viewModelKey,
factory =
remember(viewModelKey, signer) {
AudioRoomViewModelFactory(
signer = signer,
serviceBase = serviceBase,
roomId = roomId,
)
},
)
} else {
null
}
LaunchedEffect(viewModel, onStageKeys) {
viewModel?.updateSpeakers(onStageKeys)
}
val ui = viewModel?.uiState?.collectAsState()?.value
val speakingNow = ui?.speakingNow ?: persistentSetOf()
Card( Card(
modifier = Modifier.fillMaxWidth().padding(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
@@ -168,11 +204,12 @@ private fun AudioRoomStageContent(
) )
} }
if (hosts.isNotEmpty() || speakers.isNotEmpty()) { if (onStage.isNotEmpty()) {
StagePeopleRow( StagePeopleRow(
label = stringRes(R.string.audio_room_stage), label = stringRes(R.string.audio_room_stage),
people = hosts + speakers, people = onStage,
avatarSize = Size40dp, avatarSize = Size40dp,
speakingNow = speakingNow,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
} }
@@ -182,15 +219,21 @@ private fun AudioRoomStageContent(
label = stringRes(R.string.audio_room_audience), label = stringRes(R.string.audio_room_audience),
people = audience, people = audience,
avatarSize = Size35dp, avatarSize = Size35dp,
speakingNow = persistentSetOf(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
} }
AudioConnectionRow( if (viewModel != null && ui != null) {
event = event, AudioConnectionRow(viewModel = viewModel, ui = ui)
hosts = hosts, } else {
accountViewModel = accountViewModel, Text(
) text = stringRes(R.string.audio_room_audio_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Row( Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
@@ -219,8 +262,10 @@ private fun StagePeopleRow(
label: String, label: String,
people: List<ParticipantTag>, people: List<ParticipantTag>,
avatarSize: androidx.compose.ui.unit.Dp, avatarSize: androidx.compose.ui.unit.Dp,
speakingNow: ImmutableSet<String>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val ringColor = MaterialTheme.colorScheme.primary
Column(modifier = Modifier.padding(top = 8.dp)) { Column(modifier = Modifier.padding(top = 8.dp)) {
Text( Text(
text = label, text = label,
@@ -231,11 +276,19 @@ private fun StagePeopleRow(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
items(items = people, key = { it.pubKey }) { items(items = people, key = { it.pubKey }) { participant ->
val isSpeaking = participant.pubKey in speakingNow
val avatarModifier =
if (isSpeaking) {
Modifier.border(2.dp, ringColor, CircleShape)
} else {
Modifier
}
ClickableUserPicture( ClickableUserPicture(
baseUserHex = it.pubKey, baseUserHex = participant.pubKey,
size = avatarSize, size = avatarSize,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = avatarModifier,
) )
} }
} }
@@ -243,52 +296,15 @@ private fun StagePeopleRow(
} }
/** /**
* Connect / state-chip / mute row. Wires [AudioRoomViewModel] for the listener * Connect / state-chip / mute row. Pure-render — the [AudioRoomViewModel] is
* audio pipeline. Hidden when the room event has no `service` tag (legacy * owned by the parent so the speaker rows can read `speakingNow` from the
* recordings or rooms hosted on non-nests servers — nothing to connect to). * same UI state.
*/ */
@Composable @Composable
private fun AudioConnectionRow( private fun AudioConnectionRow(
event: MeetingSpaceEvent, viewModel: AudioRoomViewModel,
hosts: List<ParticipantTag>, ui: com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomUiState,
accountViewModel: AccountViewModel,
) { ) {
val serviceBase = event.service()
val roomId = event.address().dTag
if (serviceBase.isNullOrBlank() || roomId.isBlank()) {
Text(
text = stringRes(R.string.audio_room_audio_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
return
}
val signer = accountViewModel.account.signer
val viewModelKey = remember(serviceBase, roomId) { "$serviceBase|$roomId" }
val viewModel: AudioRoomViewModel =
viewModel(
key = viewModelKey,
factory =
remember(viewModelKey, signer) {
AudioRoomViewModelFactory(
signer = signer,
serviceBase = serviceBase,
roomId = roomId,
)
},
)
val hostKeys = remember(hosts) { hosts.map { it.pubKey }.toSet() }
LaunchedEffect(viewModel, hostKeys) {
viewModel.updateSpeakers(hostKeys)
}
val ui by viewModel.uiState.collectAsState()
Row( Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -39,9 +39,11 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -90,6 +92,7 @@ class AudioRoomViewModel(
private var stateObserverJob: Job? = null private var stateObserverJob: Job? = null
private val activeSubscriptions = mutableMapOf<String, ActiveSubscription>() private val activeSubscriptions = mutableMapOf<String, ActiveSubscription>()
private val speakingExpiryJobs = mutableMapOf<String, Job>()
private var requestedSpeakers: Set<String> = emptySet() private var requestedSpeakers: Set<String> = emptySet()
private var closed = false private var closed = false
@@ -204,6 +207,10 @@ class AudioRoomViewModel(
*/ */
private fun closeSubscription(slot: ActiveSubscription) { private fun closeSubscription(slot: ActiveSubscription) {
val handle = slot.detach() val handle = slot.detach()
speakingExpiryJobs.remove(slot.pubkey)?.cancel()
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
}
if (handle != null) { if (handle != null) {
viewModelScope.launch { runCatching { handle.unsubscribe() } } viewModelScope.launch { runCatching { handle.unsubscribe() } }
} }
@@ -224,7 +231,10 @@ class AudioRoomViewModel(
// Apply current mute state before play() opens the device so the // Apply current mute state before play() opens the device so the
// first frame respects it. // first frame respects it.
player.setMutedSafe(isMuted) player.setMutedSafe(isMuted)
roomPlayer.play(handle.objects, onError = { /* swallow per-packet decoder errors */ }) // Tap the object flow to drive the speaking-now indicator before
// the decoder consumes it.
val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) }
roomPlayer.play(instrumented, onError = { /* swallow per-packet decoder errors */ })
slot.attach(handle, roomPlayer, player) slot.attach(handle, roomPlayer, player)
publishActiveSpeakers() publishActiveSpeakers()
} catch (ce: CancellationException) { } catch (ce: CancellationException) {
@@ -247,6 +257,8 @@ class AudioRoomViewModel(
// the listener.close() below tears down the MoQ session. // the listener.close() below tears down the MoQ session.
activeSubscriptions.values.forEach { it.detach() } activeSubscriptions.values.forEach { it.detach() }
activeSubscriptions.clear() activeSubscriptions.clear()
speakingExpiryJobs.values.forEach { it.cancel() }
speakingExpiryJobs.clear()
val l = listener val l = listener
listener = null listener = null
if (l != null) { if (l != null) {
@@ -255,7 +267,13 @@ class AudioRoomViewModel(
// underlying transport's own cleanup runs. // underlying transport's own cleanup runs.
viewModelScope.launch { runCatching { l.close() } } viewModelScope.launch { runCatching { l.close() } }
} }
_uiState.update { it.copy(connection = targetState, activeSpeakers = persistentSetOf()) } _uiState.update {
it.copy(
connection = targetState,
activeSpeakers = persistentSetOf(),
speakingNow = persistentSetOf(),
)
}
} }
private fun publishActiveSpeakers() { private fun publishActiveSpeakers() {
@@ -267,6 +285,31 @@ class AudioRoomViewModel(
_uiState.update { it.copy(activeSpeakers = active) } _uiState.update { it.copy(activeSpeakers = active) }
} }
/**
* Mark [pubkey] as currently speaking and (re)arm a [SPEAKING_TIMEOUT_MS]
* coroutine that clears it once they go quiet. Called once per
* MoQ object received on the speaker's track.
*/
private fun onSpeakerActivity(pubkey: String) {
if (closed) return
speakingExpiryJobs[pubkey]?.cancel()
if (!_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
}
speakingExpiryJobs[pubkey] =
viewModelScope.launch {
delay(SPEAKING_TIMEOUT_MS)
clearSpeaking(pubkey)
}
}
private fun clearSpeaking(pubkey: String) {
speakingExpiryJobs.remove(pubkey)
if (_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - pubkey).toPersistentSet()) }
}
}
private class ActiveSubscription private constructor( private class ActiveSubscription private constructor(
val pubkey: String, val pubkey: String,
) { ) {
@@ -362,9 +405,19 @@ private fun AudioPlayer.setMutedSafe(muted: Boolean) {
data class AudioRoomUiState( data class AudioRoomUiState(
val connection: ConnectionUiState = ConnectionUiState.Idle, val connection: ConnectionUiState = ConnectionUiState.Idle,
val isMuted: Boolean = false, val isMuted: Boolean = false,
/** Pubkeys we have an open MoQ subscription for. */
val activeSpeakers: ImmutableSet<String> = persistentSetOf(), val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
val speakingNow: ImmutableSet<String> = persistentSetOf(),
) )
/**
* How long a speaker stays "speaking" after their last received MoQ object.
* Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the
* indicator flicker.
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
/** /**
* Indirection over the top-level `connectNestsListener` so tests can drive * Indirection over the top-level `connectNestsListener` so tests can drive
* a fake [NestsListener] directly without standing up an HTTP fake + * a fake [NestsListener] directly without standing up an HTTP fake +
@@ -187,6 +187,30 @@ class AudioRoomViewModelTest {
assertEquals(ConnectionUiState.Step.OpeningTransport, ui.step) assertEquals(ConnectionUiState.Step.OpeningTransport, ui.step)
} }
@Test
fun speakingNowClearsOnTeardown() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Connected(ROOM_INFO, 0xff000011))
// Speaking-now is empty until an object arrives — exercising the
// timeout-based clearing requires a live SubscribeHandle, which is
// covered in nestsClient's pipe tests. Here we just verify the
// teardown contract: speakingNow returns to empty after disconnect.
vm.disconnect()
assertTrue(
vm.uiState.value.speakingNow
.isEmpty(),
)
assertTrue(
vm.uiState.value.activeSpeakers
.isEmpty(),
)
}
private fun newViewModel(connect: suspend (CoroutineScope) -> NestsListener): AudioRoomViewModel = private fun newViewModel(connect: suspend (CoroutineScope) -> NestsListener): AudioRoomViewModel =
AudioRoomViewModel( AudioRoomViewModel(
httpClient = NoopNestsClient, httpClient = NoopNestsClient,