Files
amethyst/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt
T
Claude 6f649e76d8 refactor(nests): extract MoqLiteBroadcastHandle + HotSwappablePublisherSource (Audit-10)
`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
2026-05-06 19:47:08 +00:00

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.
}
}
}