6f649e76d8
`MoqLiteNestsSpeaker.kt` mixed three top-level concerns at 387 lines:
- The `MoqLiteNestsSpeaker` class itself — the speaker entry point
that builds a publisher + broadcaster pair on
`startBroadcasting`.
- `MoqLiteBroadcastHandle` — internal `BroadcastHandle`
implementation tying the broadcaster, audio publisher, and
catalog publisher together with a fixed-order shutdown.
- `HotSwappablePublisherSource` — internal interface that lets
`ReconnectingNestsSpeaker.runHotSwapIteration` retarget a
long-lived broadcaster onto fresh moq-lite session publishers
without restarting the AudioRecord / Opus encoder pipeline.
The handle and the interface are independently reachable from
`ReconnectingNestsSpeaker` and have no behavioural coupling to
`MoqLiteNestsSpeaker` beyond a `parent` reference (handle) or an
`as?` cast (interface). Move each to its own file:
- `MoqLiteBroadcastHandle.kt` (109 lines).
- `HotSwappablePublisherSource.kt` (62 lines).
`MoqLiteNestsSpeaker.kt` is now 276 lines focused on the speaker
class. Same package, same `internal` visibility — no call-site
changes needed elsewhere. Tests + spotless green.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
277 lines
12 KiB
Kotlin
277 lines
12 KiB
Kotlin
/*
|
|
* Copyright (c) 2025 Vitor Pamplona
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
* this software and associated documentation files (the "Software"), to deal in
|
|
* the Software without restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
|
* subject to the following conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be included in all
|
|
* copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
*/
|
|
package com.vitorpamplona.nestsclient
|
|
|
|
import com.vitorpamplona.nestsclient.audio.AudioCapture
|
|
import com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster
|
|
import com.vitorpamplona.nestsclient.audio.OpusEncoder
|
|
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteHangCatalog
|
|
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
|
|
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
|
|
import kotlinx.coroutines.CancellationException
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
|
|
/**
|
|
* Moq-lite-backed [NestsSpeaker]. Mirrors [MoqLiteNestsListener] on the
|
|
* publish side: takes a connected [MoqLiteSession] and exposes the
|
|
* existing [NestsSpeaker] API so [connectNestsSpeaker] can swap the
|
|
* framing layer without changing any downstream consumers.
|
|
*
|
|
* Wire-flow per [MoqLiteSession.publish]:
|
|
* - the session opens a publisher state when [startBroadcasting] is
|
|
* called, then services every relay-opened Announce / Subscribe
|
|
* bidi automatically.
|
|
* - frames pushed via [MoqLitePublisherHandle.send] go on a fresh
|
|
* uni stream per group, framed as `varint(size) + payload`.
|
|
*/
|
|
class MoqLiteNestsSpeaker internal constructor(
|
|
private val session: MoqLiteSession,
|
|
private val speakerPubkeyHex: String,
|
|
private val captureFactory: () -> AudioCapture,
|
|
private val encoderFactory: () -> OpusEncoder,
|
|
private val scope: CoroutineScope,
|
|
private val mutableState: MutableStateFlow<NestsSpeakerState>,
|
|
/**
|
|
* How many Opus frames to pack into one moq-lite group / QUIC uni
|
|
* stream. Forwarded to [NestMoqLiteBroadcaster.framesPerGroup] —
|
|
* see that field's kdoc for the production stream-cliff rationale.
|
|
* Defaults to [NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP].
|
|
*/
|
|
private val framesPerGroup: Int = NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP,
|
|
) : NestsSpeaker,
|
|
HotSwappablePublisherSource {
|
|
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
|
|
|
|
private val gate = Mutex()
|
|
private var activeHandle: MoqLiteBroadcastHandle? = null
|
|
|
|
override suspend fun startBroadcasting(onLevel: (Float) -> Unit): BroadcastHandle {
|
|
gate.withLock {
|
|
val current = state.value
|
|
check(current is NestsSpeakerState.Connected) {
|
|
"startBroadcasting requires Connected state, was $current"
|
|
}
|
|
check(activeHandle == null) { "speaker is already broadcasting" }
|
|
|
|
// Per the audio-rooms NIP draft + JS reference
|
|
// (`@moq/publish/screen-B680RFft.js:5641`), publishers
|
|
// claim a broadcast suffix equal to their pubkey hex and
|
|
// the audio data sits on track "audio/data". The publisher
|
|
// must be track-scoped because listeners typically open
|
|
// BOTH a catalog subscribe AND an audio subscribe per
|
|
// speaker; without the track filter the publisher would
|
|
// route Opus frames onto whichever subscribe arrived first
|
|
// (in practice the catalog one) and the audio subscription
|
|
// would silently starve.
|
|
val publisher =
|
|
session.publish(
|
|
broadcastSuffix = speakerPubkeyHex,
|
|
track = MoqLiteNestsListener.AUDIO_TRACK,
|
|
)
|
|
// From here on, the publisher is registered in the session
|
|
// and has a live announce/subscribe path. Anything that
|
|
// throws before we hand a working handle back to the caller
|
|
// must close the publisher to avoid leaking it inside the
|
|
// session's `activePublisher` slot — otherwise a subsequent
|
|
// startBroadcasting fails with "already publishing" and the
|
|
// publisher's announce state never tears down.
|
|
//
|
|
// Companion catalog publisher: the kixelated/moq browser
|
|
// watcher (and any standards-aligned moq-lite consumer)
|
|
// discovers a broadcast's tracks by subscribing to the
|
|
// `catalog.json` track and parsing the latest group's
|
|
// payload as a JSON manifest. Without this track our
|
|
// broadcasts are *invisible* to the canonical web watcher
|
|
// even though our audio frames are streaming fine — the
|
|
// watcher has nothing to subscribe to. Open it alongside
|
|
// audio so a single broadcast advertises both tracks.
|
|
val catalogPublisher =
|
|
try {
|
|
session.publish(
|
|
broadcastSuffix = speakerPubkeyHex,
|
|
track = MoqLiteNestsListener.CATALOG_TRACK,
|
|
)
|
|
} catch (t: Throwable) {
|
|
runCatching { publisher.close() }
|
|
throw t
|
|
}
|
|
val broadcaster =
|
|
try {
|
|
NestMoqLiteBroadcaster(
|
|
capture = captureFactory(),
|
|
encoder = encoderFactory(),
|
|
initialPublisher = publisher,
|
|
scope = scope,
|
|
framesPerGroup = framesPerGroup,
|
|
).also {
|
|
it.start(
|
|
onTerminalFailure = {
|
|
// Broadcaster bailed after sustained
|
|
// publisher.send failures. Flip to
|
|
// Failed so the reconnect orchestrator
|
|
// sees a terminal state and recycles
|
|
// the session — without this signal the
|
|
// outward state stays on Broadcasting
|
|
// and the room is silently mute.
|
|
reportBroadcastTerminalFailure()
|
|
},
|
|
onLevel = onLevel,
|
|
)
|
|
}
|
|
} catch (t: Throwable) {
|
|
runCatching { catalogPublisher.close() }
|
|
runCatching { publisher.close() }
|
|
throw t
|
|
}
|
|
// Catalog emit-on-subscribe: every time the relay opens a
|
|
// SUBSCRIBE bidi for catalog.json, fire the hook to write
|
|
// one group + FIN. moq-lite serves new listeners from the
|
|
// relay's per-track latest-group cache, so emitting once
|
|
// per relay-side subscribe is enough — late-joining
|
|
// watchers behind the same relay get the cached blob
|
|
// without us having to maintain a periodic re-emit loop.
|
|
// Set BEFORE the relay can race a SUBSCRIBE in; in
|
|
// practice the relay's SUBSCRIBE bidi takes a network
|
|
// round-trip after our ANNOUNCE Active, so this is safe
|
|
// even though the setter is non-suspending.
|
|
val catalogJson = MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES
|
|
catalogPublisher.setOnNewSubscriber {
|
|
runCatching {
|
|
catalogPublisher.send(catalogJson)
|
|
catalogPublisher.endGroup()
|
|
}
|
|
}
|
|
mutableState.value =
|
|
NestsSpeakerState.Broadcasting(
|
|
room = current.room,
|
|
negotiatedMoqVersion = current.negotiatedMoqVersion,
|
|
isMuted = false,
|
|
)
|
|
val handle =
|
|
MoqLiteBroadcastHandle(
|
|
broadcaster = broadcaster,
|
|
publisher = publisher,
|
|
catalogPublisher = catalogPublisher,
|
|
parent = this,
|
|
)
|
|
activeHandle = handle
|
|
return handle
|
|
}
|
|
}
|
|
|
|
/**
|
|
* [HotSwappablePublisherSource] implementation. See the interface
|
|
* kdoc — this method mints a fresh publisher on the session
|
|
* WITHOUT spinning up a broadcaster on top of it. Used by the
|
|
* reconnect wrapper's hot-swap path; not called from the
|
|
* non-reconnecting path which goes through [startBroadcasting].
|
|
*/
|
|
override suspend fun openPublisherForHotSwap(
|
|
track: String,
|
|
startSequence: Long,
|
|
): MoqLitePublisherHandle =
|
|
session.publish(
|
|
broadcastSuffix = speakerPubkeyHex,
|
|
track = track,
|
|
startSequence = startSequence,
|
|
)
|
|
|
|
/**
|
|
* Compare-and-clear that runs from inside [close] (already holds
|
|
* [gate]) and from [MoqLiteBroadcastHandle.close] (doesn't).
|
|
* Mirrors [DefaultNestsSpeaker.broadcastClosed].
|
|
*/
|
|
internal fun broadcastClosed(handle: MoqLiteBroadcastHandle) {
|
|
if (activeHandle !== handle) return
|
|
activeHandle = null
|
|
val current = mutableState.value
|
|
if (current is NestsSpeakerState.Broadcasting) {
|
|
mutableState.value =
|
|
NestsSpeakerState.Connected(current.room, current.negotiatedMoqVersion)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called from the broadcaster's `onTerminalFailure` callback (off
|
|
* the speaker's coroutine). Transitions the speaker to `Failed` so
|
|
* the reconnect orchestrator (`ReconnectingNestsSpeaker`) observes
|
|
* a terminal state and recycles the session. No-op if the speaker
|
|
* is already in a terminal state.
|
|
*
|
|
* Also exposed via [HotSwappablePublisherSource.reportBroadcastTerminalFailure]
|
|
* so the hot-swap pump (which owns its own long-lived broadcaster)
|
|
* can drive the same orchestrator-reconnect path the legacy
|
|
* `startBroadcasting` flow does.
|
|
*/
|
|
override fun reportBroadcastTerminalFailure() {
|
|
val current = mutableState.value
|
|
if (current is NestsSpeakerState.Failed || current is NestsSpeakerState.Closed) return
|
|
mutableState.value =
|
|
NestsSpeakerState.Failed(
|
|
reason = "broadcast pipeline gave up — likely transport loss",
|
|
)
|
|
}
|
|
|
|
internal fun reportMuteState(muted: Boolean) {
|
|
val current = mutableState.value
|
|
if (current is NestsSpeakerState.Broadcasting) {
|
|
mutableState.value = current.copy(isMuted = muted)
|
|
}
|
|
}
|
|
|
|
override suspend fun close() {
|
|
// Take + clear under [gate] so a concurrent `startBroadcasting`
|
|
// can't observe a half-closed state, then run the long-running
|
|
// suspends (handle.close + session.close) outside the lock.
|
|
val handle: MoqLiteBroadcastHandle?
|
|
gate.withLock {
|
|
if (state.value is NestsSpeakerState.Closed) return
|
|
handle = activeHandle
|
|
activeHandle = null
|
|
mutableState.value = NestsSpeakerState.Closed
|
|
}
|
|
// Don't `runCatching { handle.close() }` — that swallows
|
|
// CancellationException too, breaking structured cancellation
|
|
// when the parent scope is cancelling teardown.
|
|
if (handle != null) {
|
|
try {
|
|
handle.close()
|
|
} catch (ce: kotlinx.coroutines.CancellationException) {
|
|
throw ce
|
|
} catch (_: Throwable) {
|
|
// Best-effort — already cleared activeHandle.
|
|
}
|
|
}
|
|
try {
|
|
session.close()
|
|
} catch (ce: kotlinx.coroutines.CancellationException) {
|
|
throw ce
|
|
} catch (_: Throwable) {
|
|
// Best-effort.
|
|
}
|
|
}
|
|
}
|