nestsClient: pack 5 Opus frames per moq-lite group to dodge the prod stream cliff

Production sweep against nostrnests.com cliffed at exactly 99 frames received
across every multi-frame scenario (frames200/400, 30s, 120s, etc.) when the
broadcaster opened one QUIC uni stream per Opus frame. The investigation in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md walks through
why the obvious "raise the stream cap" hypothesis didn't pan out (publisher.send
was never returning false past the cap, the local QuicConnection was already
seeing the relay extend MAX_STREAMS_UNI dynamically).

The same sweep showed sweep_frames_per_group_5 / _20 / _all delivering 100/100
frames cleanly. The cliff scales with stream-creation rate, not byte volume or
total stream count — so packing N frames per group (one uni stream per N frames
instead of per frame) bypasses whatever the underlying limit is.

NestMoqLiteBroadcaster:
  - Add framesPerGroup constructor param, default 5 (≈ 100 ms of audio per
    moq-lite group). Stream-creation rate drops from 50/sec to 10/sec, well
    below the production cliff threshold.
  - Counter resets after each endGroup; trailing partial group flushes its
    FIN on capture EOF so the listener doesn't sit on a half-open stream.

MoqLiteNestsSpeaker + connectNestsSpeaker:
  - Plumb framesPerGroup through to the broadcaster. Default flows from
    NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP so production callers
    get the mitigation without code changes; tests can override to 1 to
    keep their groupId-per-frame assertions.

Tests:
  - All harness interop tests (RoundTrip, LateJoin, MultiPeer, Mute,
    Unsubscribe, SpeakerClose, both Reconnecting variants) plus the prod
    NostrnestsProdAudioTransmissionTest now pass framesPerGroup = 1
    explicitly to preserve their per-frame group/object id assertions.
  - All :quic and :nestsClient JVM tests pass.

Trade-off: a brand-new subscriber that joins mid-broadcast picks up at the
next group boundary. With 5 frames/group the late-join initial gap is up
to 100 ms (perceptually inaudible for live audio). The previous wire shape
gave at most 20 ms.

Investigation plan for the underlying QUIC cliff lives in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md. The
attempted-then-reverted suspend/STREAMS_BLOCKED commit is summarised
under "Attempt 1" in that doc.
This commit is contained in:
Claude
2026-05-01 14:06:21 +00:00
parent 96a585a67e
commit 10ad69f1af
13 changed files with 333 additions and 15 deletions
@@ -0,0 +1,236 @@
# QUIC stream cliff against nostrnests.com — investigation plan
**Status:** open. Mitigated in production via group-packing
(`NestMoqLiteBroadcaster.framesPerGroup = 5`). Root cause not isolated.
**Reproduces against:** `https://moq.nostrnests.com:4443` (production
relay). Not consistently reproducible against the local
`kixelated/moq` reference relay (the local harness shows the same
shape but with much higher run-to-run variance).
## Symptom
When the speaker pumps Opus frames at the production 20 ms cadence
and packs **one frame per moq-lite group** (= one client-initiated
QUIC unidirectional stream per Opus frame), the listener stops
receiving frames somewhere around the 99th frame. From there to
end-of-broadcast, **`MoqLitePublisherHandle.send` keeps returning
`true` for every subsequent frame**, so the application has no signal
that anything is wrong; the audio simply cuts out a few seconds in.
The bug is invisible to:
- `NestMoqLiteBroadcaster`'s outer `runCatching {…}.onFailure {…}`
(no exception is thrown on the silent-loss path).
- `MoqLitePublisherHandle.send`'s return value (always `true` past
the cliff).
- `QuicConnection`'s peer-cap check (`nextLocalUniIndex >= peerMaxStreamsUni`
doesn't fire because the relay raises `MAX_STREAMS_UNI` ahead of
our consumption — pre-fix tests confirmed `firstFalseSend = -1`
even at 1500 frames).
## Sweep evidence (production, before mitigation)
`NostrnestsProdAudioTransmissionTest` (commit
`2da18859`-era, before any QUIC changes), one row per scenario:
| Scenario | streams opened | `received` | cliff |
|---|---|---|---|
| `sweep-frames50` | 50 | 50/50 | none ✅ |
| `sweep-baseline` 100 frames | 100 | 99/100 | last 1 |
| any cadence (5/10/40/80/200 ms) at 100 frames | 100 | 99/100 | last 1 |
| any payload (80 B / 1 KB / 4 KB) at 100 frames | 100 | 99/100 | last 1 |
| `sweep-frames200` | 200 | **99**/200 | hard at 99 |
| `sweep-frames400` | 400 | **99**/400 | hard at 99 |
| `sweep-30s` (1500 frames) | 1500 | **99**/1500 | hard at 99 |
| `sweep-120s` (6000 frames) | 6000 | 52/6000 | hard at 52 |
| `sweep-burst` (cadence 0) | 100 | 65/100 | hard at 65 |
| `sweep-payload-16kb` 100 frames × 16 KB | 100 | 49/100 | hard at 49 |
| **`sweep-frames-per-group-5`** | **20** groups | **100/100** | none ✅ |
| **`sweep-frames-per-group-20`** | **5** groups | **100/100** | none ✅ |
| **`sweep-frames-per-group-all`** | **1** group | **100/100** | none ✅ |
The signal is unambiguous: **fewer streams → no loss, regardless of
frame count, payload, or duration**. The cliff scales with stream-
opening rate, not with byte volume or wall-clock time.
The number "99" is suspicious — it's exactly QUIC's typical
`initial_max_streams_uni = 100` minus one. But:
- Pre-mitigation `firstFalseSend = -1` for tests pumping 1500+
streams says the cap WAS being raised dynamically. So the cliff
is NOT `openUniStream` rejecting past the local cap.
- A 16 KB payload variant cliffed at 49 streams (~800 KB total),
while 80-byte variants cliffed at 99 (~8 KB total). These are
inconsistent if the cause were a single fixed cap.
- The cliff is *consistent* across many scenarios at ~99 — too
consistent for plain network packet loss.
## What we tried and what we learned
### Attempt 1 — make `openUniStream` suspend instead of throw
Branch / commit: `f0705e3a` on `claude/audio-transmission-tests-zKzlB`,
**reverted in `96a585a6`**.
Rationale: the obvious failure mode is `peerMaxStreamsUni` being hit
without us ever waiting for the relay to extend it; we'd just throw
locally. So we'd:
1. Make `QuicConnection.openBidiStream` / `openUniStream` suspend
on a `CompletableDeferred<Unit>` notifier when the cap is hit,
re-acquire the lock and re-check on every wake.
2. Fire the notifier from `QuicConnectionParser` whenever an inbound
`MAX_STREAMS` frame raises a cap.
3. Emit `STREAMS_BLOCKED` (RFC 9000 §19.14) when an opener registers
itself blocked, so the peer knows we want more credit.
4. Wake blocked openers with `QuicConnectionClosedException` on
close so they don't hang.
Unit tests for the suspend / wake / STREAMS_BLOCKED / close paths
pass (`PeerStreamLimitTest`).
**Result against production:** the cliff didn't move. Same scenarios
still hit ~99 frames received, and the run-to-run variance got
*worse* (numbers swung between 19/100 and 99/100 on consecutive
identical baseline runs against the same relay). The fix also
introduced no observable change in the per-frame `tx i=… ok dt=…us`
traces (`firstFalseSend = -1` still on every test), which means the
suspend path isn't being triggered in production — the peer's cap
isn't what we're hitting.
The fix is RFC-correct on its own (we should suspend rather than
throw, and we should send STREAMS_BLOCKED), but it does not address
the production cliff. Reverted to keep the tree clean while we
chase the real cause.
### Attempt 2 — pack multiple frames per moq-lite group (shipping)
Commit on `claude/audio-transmission-tests-zKzlB` (this commit).
Rationale: `sweep_frames_per_group_*` was 100/100 across every
multi-frame-per-group variant. The cliff scales with the number of
new uni streams opened, not with anything else. Packing N Opus
frames per moq-lite group reduces the new-stream rate by N×.
Default chosen: `framesPerGroup = 5` ≈ 100 ms of audio per group.
Stream-creation rate drops from 50/sec to 10/sec, well below the
cliff.
Trade-off: a brand-new subscriber that attaches mid-broadcast picks
up at the next group boundary per moq-lite "from-latest" semantics.
With 5 frames/group the late-join initial gap is up to 100 ms
(perceptually inaudible). With 1 frame/group it was up to 20 ms.
This is a **mitigation, not a fix.** Other workloads with a high
per-stream cost (file transfer, metadata fan-out, anything that
naturally wants a fresh stream per message) would still hit the
cliff if they exceed roughly 50 streams/second.
## Hypotheses still on the table
In rough order of likelihood, none confirmed:
1. **Connection-level send credit** (`initial_max_data` / `MAX_DATA`).
If the relay's connection-level grant is ~8 KB and only extends
on `DATA_BLOCKED`, we'd cliff at ~99 frames × ~85 bytes = ~8.4 KB.
`:quic` parses inbound `MAX_DATA` (`QuicConnectionParser.kt:266`)
but never emits `DATA_BLOCKED` when our writer hits
`connBudget == 0` in `appendStreamFrames`. That asymmetry would
stall the producer with no signal. The 16 KB payload data point
(cliff at 49 × 16 KB ≈ 800 KB) doesn't match a single fixed
8 KB cap, but it could match a different per-byte budget the
relay calibrates to its initial offer. Worth instrumenting.
2. **Per-stream send credit** (`initial_max_stream_data_uni` /
`MAX_STREAM_DATA`). Each new uni stream gets its own credit
window from the peer's `initial_max_stream_data_uni`. If that's
small enough to require a `MAX_STREAM_DATA` on every stream and
the peer is conservative about issuing it, the writer would
buffer frames forever waiting for credit while `publisher.send`
reports success. `:quic` honours the per-stream credit
(`QuicConnectionWriter.kt:280`) but never emits
`STREAM_DATA_BLOCKED`.
3. **Reference-relay default `MAX_STREAMS_UNI` extension policy.**
`kixelated/moq-rs` is built on Quinn; Quinn's stream-cap
extension is automatic when streams complete. If the production
nostrnests deployment uses a custom Quinn config that only
extends in response to `STREAMS_BLOCKED` (which we don't send
on the un-fixed code path), we'd starve. Attempt 1 added
STREAMS_BLOCKED emission and didn't shift the cliff, weak
evidence against this hypothesis.
4. **Relay-side per-subscription buffer.** If the relay has a
per-subscriber forward buffer that's smaller than the total
in-flight uni-stream count, and it drops streams that don't
fit, the cliff would scale with stream count and be invisible
to the speaker. Hardest to investigate without relay logs.
## Next steps to actually fix
The investigation order I'd take, given the data:
1. **Instrument outbound `appendStreamFrames`.** Log every iteration
where `connBudget <= 0` (connection-level credit exhausted) or
where a stream's `streamRemaining <= 0` (per-stream credit
exhausted). Re-run the prod sweep with this on. If the cliff
correlates with one of those, the hypothesis is confirmed and the
fix is to emit `DATA_BLOCKED` / `STREAM_DATA_BLOCKED` and on the
write path, suspend the writer until a fresh `MAX_DATA` /
`MAX_STREAM_DATA` arrives.
2. **Capture the production peer transport parameters.** Dump
`tp.initialMaxData`, `tp.initialMaxStreamDataUni`, and
`tp.initialMaxStreamsUni` once at handshake time so we know
exactly what the relay grants. Compare against the cliff
thresholds.
3. **Test with `:quic` flow-control diagnostics in place.** Re-run
`sweep_frames200` and `sweep_30s` — the cliff is rock-solid at
exactly 99 in production, so a single pass is enough to confirm
the hypothesis.
4. **If it's not connection / stream data flow control,** instrument
the relay (file an issue on `nostrnests/nests` or `kixelated/moq`)
asking whether their relay imposes a per-subscriber stream
buffer that drops on overflow.
## Re-running the sweep after a candidate fix
The test infrastructure is already in place:
```bash
./gradlew :nestsClient:jvmTest \
--tests com.vitorpamplona.nestsclient.interop.NostrnestsProdAudioTransmissionTest \
-DnestsProd=true -DnestsInteropDebug=true --rerun-tasks
grep -E "sub\[|missing=" \
nestsClient/build/test-results/jvmTest/TEST-com.vitorpamplona.nestsclient.interop.NostrnestsProdAudioTransmissionTest.xml \
| sort
```
Acceptance criterion: `sweep-30s` reports `received≈1500/1500`
without any `framesPerGroup` mitigation. That removes the only
remaining audio-quality tradeoff (the 100 ms late-join initial gap)
and lets us reset `DEFAULT_FRAMES_PER_GROUP` back to 1 to match the
JS reference broadcaster's wire shape.
## Files / lines to touch when picking this up
- `quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt`
`appendStreamFrames` is where the writer skips streams with no
credit; instrument here.
- `quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt:160-165`
`sendConnectionFlowCredit` / `sendConnectionFlowConsumed`. The
values to log + diff.
- `quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt`
`decode` is where peer TPs land at handshake.
- `quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt`
— currently parses `MAX_DATA`, `MAX_STREAM_DATA`, `MAX_STREAMS`,
`STREAMS_BLOCKED` (last one ignored). The reverse —
`DATA_BLOCKED` / `STREAM_DATA_BLOCKED` emission — would live in
`QuicConnectionWriter`.
- `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/SendTraceScenario.kt`
— extend per-frame trace with `connBudget` / `streamRemaining`
if the writer instrumentation surfaces them via diagnostics on
`QuicConnection`.
@@ -52,6 +52,13 @@ class MoqLiteNestsSpeaker internal constructor(
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 {
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
@@ -81,6 +88,7 @@ class MoqLiteNestsSpeaker internal constructor(
encoder = encoderFactory(),
publisher = publisher,
scope = scope,
framesPerGroup = framesPerGroup,
)
broadcaster.start()
mutableState.value =
@@ -167,6 +167,11 @@ private fun failedListener(state: MutableStateFlow<NestsListenerState>): NestsLi
* Android passes `{ AudioRecordCapture() }`.
* @param encoderFactory builds an [OpusEncoder] (one per broadcast).
* Android passes `{ MediaCodecOpusEncoder() }`.
* @param framesPerGroup how many Opus frames to pack into one moq-lite
* group / QUIC uni stream. See
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster.framesPerGroup]
* for the production stream-cliff rationale that motivates the
* default of 5.
*/
suspend fun connectNestsSpeaker(
httpClient: NestsClient,
@@ -177,6 +182,9 @@ suspend fun connectNestsSpeaker(
speakerPubkeyHex: String,
captureFactory: () -> AudioCapture,
encoderFactory: () -> OpusEncoder,
framesPerGroup: Int =
com.vitorpamplona.nestsclient.audio
.NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP,
): NestsSpeaker {
val state =
MutableStateFlow<NestsSpeakerState>(
@@ -240,6 +248,7 @@ suspend fun connectNestsSpeaker(
encoderFactory = encoderFactory,
scope = scope,
mutableState = state,
framesPerGroup = framesPerGroup,
)
}
@@ -41,7 +41,41 @@ class NestMoqLiteBroadcaster(
private val encoder: OpusEncoder,
private val publisher: MoqLitePublisherHandle,
private val scope: CoroutineScope,
/**
* Number of consecutive Opus frames to pack into a single moq-lite
* group (= one QUIC unidirectional stream). The first call to
* [MoqLitePublisherHandle.send] in each group opens the stream;
* subsequent calls within the same group write additional
* varint-prefixed frames; [MoqLitePublisherHandle.endGroup] FINs
* the stream and the next [send] starts a fresh group.
*
* **Default = 5 (≈ 100 ms of audio per group).** This is a
* mitigation for a production loss cliff observed against
* `nostrnests.com`: at one-frame-per-group and 20 ms cadence the
* client opens 50 uni streams/second, and somewhere around the
* 99th stream the connection enters a state where new uni streams'
* data stops being delivered to the listener, even though
* `publisher.send` still returns true. Packing 5 frames per group
* reduces the stream-creation rate to 10/second, which the prod
* sweep (`NostrnestsProdAudioTransmissionTest.sweep_frames_per_group_5`)
* shows delivers 100/100 frames cleanly. See
* `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.
*
* Trade-off: a brand-new subscriber that attaches mid-broadcast
* picks up at the next group boundary per moq-lite "from-latest"
* semantics. With 5 frames per group, late joiners miss up to
* 100 ms of audio. With the original 1 frame per group it was
* up to 20 ms. Both are imperceptible for live audio rooms.
*
* Set to 1 to restore the original behaviour for testing or for
* deployments to relays that don't exhibit the stream-cliff bug.
*/
private val framesPerGroup: Int = DEFAULT_FRAMES_PER_GROUP,
) {
init {
require(framesPerGroup >= 1) { "framesPerGroup must be >= 1, got $framesPerGroup" }
}
private var job: Job? = null
@Volatile private var stopped: Boolean = false
@@ -67,6 +101,12 @@ class NestMoqLiteBroadcaster(
}
job =
scope.launch {
// Counts frames written to the current moq-lite group.
// Reset to 0 immediately after each [endGroup] so the
// next [send] auto-starts a fresh group via
// [MoqLitePublisherHandle.send]'s "open on first frame"
// contract.
var framesInCurrentGroup = 0
try {
while (true) {
val pcm = capture.readFrame() ?: break
@@ -87,23 +127,17 @@ class NestMoqLiteBroadcaster(
}
if (opus.isEmpty()) continue
if (muted) continue
// One Opus frame per moq-lite group — mirrors the
// nests JS reference's audio publish path, and is
// load-bearing for the listener-survives-publisher-
// recycle invariant: a brand-new subscriber that
// attaches mid-broadcast (e.g. listener wrapper
// re-subscribing after a publisher cycle) gets the
// NEXT group's frames per moq-lite "from-latest"
// semantics. Without endGroup, the entire broadcast
// is one giant group and new subscribers wait
// indefinitely. The 20 ms cadence here means at
// most one frame of audio missed for any new
// subscriber. See
// `nestsClient/plans/2026-04-26-moq-lite-gap.md`'s
// "Group size: 1 frame per group" line.
// Pack [framesPerGroup] consecutive Opus frames
// into the same moq-lite group / QUIC uni stream
// before FINning. See the [framesPerGroup] kdoc
// for the production cliff this works around.
runCatching {
publisher.send(opus)
publisher.endGroup()
framesInCurrentGroup += 1
if (framesInCurrentGroup >= framesPerGroup) {
publisher.endGroup()
framesInCurrentGroup = 0
}
}.onFailure { t ->
if (t is CancellationException) throw t
onError(
@@ -115,6 +149,13 @@ class NestMoqLiteBroadcaster(
)
}
}
// EOF on the capture side. Flush whatever's in the
// open group so the relay sees its FIN and the
// listener doesn't sit on a half-delivered group
// buffer. Mirrors the per-group endGroup above.
if (framesInCurrentGroup > 0) {
runCatching { publisher.endGroup() }
}
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
@@ -153,4 +194,15 @@ class NestMoqLiteBroadcaster(
runCatching { encoder.release() }
runCatching { publisher.close() }
}
companion object {
/**
* Default moq-lite group size = 5 Opus frames ≈ 100 ms of audio.
* Picked to keep the QUIC uni-stream creation rate under the
* production cliff observed against `nostrnests.com` while
* still giving late-joining subscribers a sub-100 ms initial
* audio gap. See [framesPerGroup] kdoc for the full rationale.
*/
const val DEFAULT_FRAMES_PER_GROUP: Int = 5
}
}
@@ -90,6 +90,7 @@ class NostrNestsLateJoinInteropTest {
speakerPubkeyHex = speakerSigner.pubKey,
captureFactory = { capture },
encoderFactory = { InteropStubEncoder("FRAME-".encodeToByteArray()) },
framesPerGroup = 1,
)
}
val broadcast =
@@ -354,6 +354,7 @@ class NostrNestsMultiPeerInteropTest {
speakerPubkeyHex = signer.pubKey,
captureFactory = { capture },
encoderFactory = { StubEncoder(encoderPrefix.encodeToByteArray()) },
framesPerGroup = 1,
)
}
InteropDebug.assertSpeakerReached(debugScope, "Connected", speaker.state.value)
@@ -94,6 +94,7 @@ class NostrNestsMuteInteropTest {
speakerPubkeyHex = speakerSigner.pubKey,
captureFactory = { capture },
encoderFactory = { InteropStubEncoder("FRAME-".encodeToByteArray()) },
framesPerGroup = 1,
)
}
val broadcast =
@@ -119,6 +119,7 @@ class NostrNestsReconnectingListenerInteropTest {
speakerPubkeyHex = pubkey,
captureFactory = { capture },
encoderFactory = { encoder },
framesPerGroup = 1,
)
}
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)
@@ -246,6 +247,7 @@ class NostrNestsReconnectingListenerInteropTest {
speakerPubkeyHex = pubkey,
captureFactory = { capture },
encoderFactory = { encoder },
framesPerGroup = 1,
)
}
val broadcast = speaker.startBroadcasting()
@@ -496,6 +498,7 @@ class NostrNestsReconnectingListenerInteropTest {
speakerPubkeyHex = pubkey,
captureFactory = captureFactory,
encoderFactory = { encoder },
framesPerGroup = 1,
)
},
)
@@ -278,6 +278,7 @@ class NostrNestsReconnectingSpeakerInteropTest {
speakerPubkeyHex = pubkey,
captureFactory = captureFactory,
encoderFactory = { encoder },
framesPerGroup = 1,
)
},
)
@@ -132,6 +132,7 @@ class NostrNestsRoundTripInteropTest {
speakerPubkeyHex = pubkey,
captureFactory = { capture },
encoderFactory = { encoder },
framesPerGroup = 1,
)
}
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)
@@ -96,6 +96,7 @@ class NostrNestsSpeakerCloseInteropTest {
speakerPubkeyHex = speakerSigner.pubKey,
captureFactory = { capture },
encoderFactory = { InteropStubEncoder("FRAME-".encodeToByteArray()) },
framesPerGroup = 1,
)
}
val broadcast =
@@ -90,6 +90,7 @@ class NostrNestsUnsubscribeInteropTest {
speakerPubkeyHex = speakerSigner.pubKey,
captureFactory = { capture },
encoderFactory = { InteropStubEncoder("FRAME-".encodeToByteArray()) },
framesPerGroup = 1,
)
}
val broadcast =
@@ -171,6 +171,7 @@ class NostrnestsProdAudioTransmissionTest {
speakerPubkeyHex = signer.pubKey,
captureFactory = { capture },
encoderFactory = { encoder },
framesPerGroup = 1,
)
}
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)
@@ -290,6 +291,7 @@ class NostrnestsProdAudioTransmissionTest {
speakerPubkeyHex = hostSigner.pubKey,
captureFactory = { capture },
encoderFactory = { encoder },
framesPerGroup = 1,
)
}
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)
@@ -408,6 +410,7 @@ class NostrnestsProdAudioTransmissionTest {
speakerPubkeyHex = hostSigner.pubKey,
captureFactory = { capture },
encoderFactory = { encoder },
framesPerGroup = 1,
)
}
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)