feat(audio-rooms): M6 + M7 — AudioRoomBroadcaster + NestsSpeaker API
M6 (AudioRoomBroadcaster): Inverse of AudioRoomPlayer. Pulls PCM frames
from an AudioCapture, runs them through an OpusEncoder, and pushes the
resulting Opus packets into a MoqSession.TrackPublisher as
OBJECT_DATAGRAMs. setMuted keeps the capture + encoder running so unmute
is sample-accurate; encode failures are reported via onError but don't
tear the loop down.
M7 (NestsSpeaker): Mirror of NestsListener for the host / speaker path.
- `NestsSpeaker.startBroadcasting()` — sends ANNOUNCE for the room's
namespace (`["nests", roomId]`), opens a TrackPublisher named after
this user's pubkey hex, wires an AudioRoomBroadcaster onto it.
- `BroadcastHandle.setMuted` / `close()` — speaker-side equivalents of
the listener's mute / disconnect controls.
- `NestsSpeakerState` sealed hierarchy (Idle / Connecting{step} /
Connected / Broadcasting{isMuted} / Failed / Closed).
- `connectNestsSpeaker` orchestration mirrors `connectNestsListener`
end-to-end (HTTP → WebTransport → MoQ setup), then returns a
`DefaultNestsSpeaker` ready for `startBroadcasting`.
Tests:
- AudioRoomBroadcasterTest (5 tests): pcm-flow ordering, mute behavior,
encoder warmup skip, encode-error tolerance, idempotent stop.
- NestsSpeakerTest (2 tests): start/mute/close state transitions,
double-start rejection.
Bug caught + fixed during M7 testing: `DefaultNestsSpeaker.close()`
held its `gate` mutex while calling `handle.close()` which then called
back through `parent.broadcastClosed()` → self-deadlock. Fixed by
making `broadcastClosed` lockless (StateFlow updates are atomic; the
activeHandle compare-and-set is benign under the gate's exclusion of
concurrent startBroadcasting calls).
Verified: `:nestsClient:jvmTest` (78 tests, all green) +
`./gradlew spotlessApply` clean.
This commit is contained in:
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.nestsclient
|
||||
|
||||
import com.vitorpamplona.nestsclient.audio.AudioCapture
|
||||
import com.vitorpamplona.nestsclient.audio.OpusEncoder
|
||||
import com.vitorpamplona.nestsclient.moq.MoqSession
|
||||
import com.vitorpamplona.nestsclient.moq.MoqVersion
|
||||
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
|
||||
@@ -141,6 +143,114 @@ private fun failedListener(state: MutableStateFlow<NestsListenerState>): NestsLi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker / host counterpart of [connectNestsListener]. Walks the same
|
||||
* HTTP → WebTransport → MoQ handshake; the difference is the post-setup
|
||||
* step is `announce(...)` (driven by [NestsSpeaker.startBroadcasting])
|
||||
* instead of `subscribe(...)`.
|
||||
*
|
||||
* @param speakerPubkeyHex this user's pubkey hex, used as the MoQ track
|
||||
* name when we ANNOUNCE — listeners look us up by exactly that name.
|
||||
* @param captureFactory builds an [AudioCapture] (one per broadcast).
|
||||
* Android passes `{ AudioRecordCapture() }`.
|
||||
* @param encoderFactory builds an [OpusEncoder] (one per broadcast).
|
||||
* Android passes `{ MediaCodecOpusEncoder() }`.
|
||||
*/
|
||||
suspend fun connectNestsSpeaker(
|
||||
httpClient: NestsClient,
|
||||
transport: WebTransportFactory,
|
||||
scope: CoroutineScope,
|
||||
serviceBase: String,
|
||||
roomId: String,
|
||||
signer: NostrSigner,
|
||||
speakerPubkeyHex: String,
|
||||
captureFactory: () -> AudioCapture,
|
||||
encoderFactory: () -> OpusEncoder,
|
||||
supportedMoqVersions: List<Long> = listOf(MoqVersion.DRAFT_17),
|
||||
): NestsSpeaker {
|
||||
val state =
|
||||
MutableStateFlow<NestsSpeakerState>(
|
||||
NestsSpeakerState.Connecting(NestsSpeakerState.Connecting.ConnectStep.ResolvingRoom),
|
||||
)
|
||||
|
||||
val roomInfo =
|
||||
try {
|
||||
httpClient.resolveRoom(serviceBase = serviceBase, roomId = roomId, signer = signer)
|
||||
} catch (t: NestsException) {
|
||||
state.value = NestsSpeakerState.Failed("Room resolution failed: ${t.message}", t)
|
||||
return failedSpeaker(state)
|
||||
}
|
||||
|
||||
state.value = NestsSpeakerState.Connecting(NestsSpeakerState.Connecting.ConnectStep.OpeningTransport)
|
||||
|
||||
val (authority, path) =
|
||||
try {
|
||||
parseEndpoint(roomInfo.endpoint)
|
||||
} catch (t: Throwable) {
|
||||
state.value =
|
||||
NestsSpeakerState.Failed(
|
||||
"Malformed MoQ endpoint URL '${roomInfo.endpoint}': ${t.message}",
|
||||
t,
|
||||
)
|
||||
return failedSpeaker(state)
|
||||
}
|
||||
|
||||
val webTransport =
|
||||
try {
|
||||
transport.connect(authority = authority, path = path, bearerToken = roomInfo.token)
|
||||
} catch (t: WebTransportException) {
|
||||
state.value =
|
||||
NestsSpeakerState.Failed(
|
||||
"WebTransport ${t.kind.name}: ${t.message}",
|
||||
t,
|
||||
)
|
||||
return failedSpeaker(state)
|
||||
}
|
||||
|
||||
state.value = NestsSpeakerState.Connecting(NestsSpeakerState.Connecting.ConnectStep.MoqHandshake)
|
||||
|
||||
val moq =
|
||||
try {
|
||||
MoqSession.client(webTransport, scope).also { it.setup(supportedMoqVersions) }
|
||||
} catch (t: Throwable) {
|
||||
runCatching { webTransport.close(0, "moq setup failed") }
|
||||
state.value = NestsSpeakerState.Failed("MoQ handshake failed: ${t.message}", t)
|
||||
return failedSpeaker(state)
|
||||
}
|
||||
|
||||
val negotiatedVersion =
|
||||
moq.selectedVersion ?: run {
|
||||
runCatching { moq.close() }
|
||||
state.value = NestsSpeakerState.Failed("MoQ session reported no negotiated version")
|
||||
return failedSpeaker(state)
|
||||
}
|
||||
|
||||
state.value = NestsSpeakerState.Connected(roomInfo, negotiatedVersion)
|
||||
return DefaultNestsSpeaker(
|
||||
session = moq,
|
||||
roomNamespace = TrackNamespace.of("nests", roomId),
|
||||
speakerTrackName = speakerPubkeyHex.encodeToByteArray(),
|
||||
captureFactory = captureFactory,
|
||||
encoderFactory = encoderFactory,
|
||||
scope = scope,
|
||||
mutableState = state,
|
||||
)
|
||||
}
|
||||
|
||||
/** Mirror of [failedListener] for the speaker path. */
|
||||
private fun failedSpeaker(state: MutableStateFlow<NestsSpeakerState>): NestsSpeaker =
|
||||
object : NestsSpeaker {
|
||||
override val state = state
|
||||
|
||||
override suspend fun startBroadcasting(): BroadcastHandle = error("speaker never connected: ${state.value}")
|
||||
|
||||
override suspend fun close() {
|
||||
if (state.value !is NestsSpeakerState.Closed) {
|
||||
state.value = NestsSpeakerState.Closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a typical nests endpoint URL such as `https://relay.example.com/moq`
|
||||
* or `https://relay.example.com:4443/api/v1/moq?room=abc` into the
|
||||
|
||||
Reference in New Issue
Block a user