fix(nests): T13 reset Opus decoder on publisher boundary in NestPlayer
The reissuing-subscribe wrapper splices fresh-publisher frames into the
same `SharedFlow` that NestPlayer consumes, so across a JWT-refresh
hot-swap (speaker side) or a cliff-detector recycle (listener side)
the decoder receives a discontinuous frame stream while keeping its
Opus predictor state. Result: an audible warble at every publisher
cycle. Single-stream tests don't catch it because they never present
two distinct publishers.
Detect the boundary via `MoqObject.trackAlias` — the underlying
`MoqLiteSession.subscribe` assigns a fresh subscribeId per SUBSCRIBE,
which the listener wrapper surfaces verbatim as `trackAlias` on every
emitted object. A change between consecutive objects = new publisher.
Add an optional `decoderFactory: (() -> OpusDecoder)?` to NestPlayer.
When non-null, NestPlayer tracks `lastTrackAlias` and on a change
releases the current decoder and rebuilds via the factory. The
default `null` preserves the legacy single-decoder behaviour so
existing tests / callers that don't care about boundaries stand
unchanged.
NestViewModel.openSubscription wires the factory: a closure capturing
the catalog-derived `channelCount` so a rebuild reuses the SAME
channel layout — without that capture, a rebuild after a stereo-
publisher cycle would default to mono and silently downmix.
Two new NestPlayerTest cases pin the behaviour:
- `publisher_boundary_rebuilds_decoder_when_factory_provided`:
factory invoked twice (initial + boundary), first decoder
released on boundary, second released on stop.
- `publisher_boundary_no_op_when_factory_is_null`: legacy path
holds onto the same decoder across trackAlias changes (unchanged
semantics).
NestPlayer's first constructor parameter is now `initialDecoder`
(was `decoder`); positional-arg call sites are unchanged but the
named-arg call sites in the test suite are updated accordingly.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
+95
-5
@@ -232,7 +232,7 @@ class NestPlayerTest {
|
||||
|
||||
val sut =
|
||||
NestPlayer(
|
||||
decoder = decoder,
|
||||
initialDecoder = decoder,
|
||||
player = player,
|
||||
scope = this,
|
||||
prerollFrames = 3,
|
||||
@@ -289,7 +289,7 @@ class NestPlayerTest {
|
||||
|
||||
val sut =
|
||||
NestPlayer(
|
||||
decoder = decoder,
|
||||
initialDecoder = decoder,
|
||||
player = player,
|
||||
scope = this,
|
||||
prerollFrames = 5,
|
||||
@@ -326,7 +326,7 @@ class NestPlayerTest {
|
||||
|
||||
val sut =
|
||||
NestPlayer(
|
||||
decoder = decoder,
|
||||
initialDecoder = decoder,
|
||||
player = player,
|
||||
scope = this,
|
||||
prerollFrames = 3,
|
||||
@@ -340,17 +340,107 @@ class NestPlayerTest {
|
||||
sut.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publisher_boundary_rebuilds_decoder_when_factory_provided() =
|
||||
runTest {
|
||||
// Two distinct decoders so we can prove the factory was
|
||||
// invoked. After the trackAlias change, frames should
|
||||
// route through `decoderB`, NOT `decoderA`.
|
||||
val decoderA =
|
||||
FakeOpusDecoder {
|
||||
byteArrayOf(0x0A) + it
|
||||
ShortArray(it.size) { _ -> 0xAA.toShort() }
|
||||
}
|
||||
val decoderB =
|
||||
FakeOpusDecoder {
|
||||
byteArrayOf(0x0B) + it
|
||||
ShortArray(it.size) { _ -> 0xBB.toShort() }
|
||||
}
|
||||
val factoryCallCount = atomicIntZero()
|
||||
val factory: () -> OpusDecoder = {
|
||||
if (factoryCallCount.getAndIncrement() == 0) decoderA else decoderB
|
||||
}
|
||||
val player = FakeAudioPlayer()
|
||||
|
||||
val objects =
|
||||
flowOf(
|
||||
// First subscription cycle: trackAlias = 7
|
||||
moqObject(byteArrayOf(0x01), trackAlias = 7L),
|
||||
moqObject(byteArrayOf(0x02), trackAlias = 7L),
|
||||
// Wrapper re-issued — new SUBSCRIBE produces a
|
||||
// different trackAlias. Decoder MUST be rebuilt
|
||||
// before the next decode runs.
|
||||
moqObject(byteArrayOf(0x03), trackAlias = 8L),
|
||||
moqObject(byteArrayOf(0x04), trackAlias = 8L),
|
||||
)
|
||||
|
||||
val sut =
|
||||
NestPlayer(
|
||||
initialDecoder = factory(),
|
||||
player = player,
|
||||
scope = this,
|
||||
decoderFactory = factory,
|
||||
)
|
||||
sut.play(objects)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(2, factoryCallCount.value, "factory invoked twice: initial + boundary")
|
||||
assertEquals(1, decoderA.releaseCount, "decoderA released on the boundary")
|
||||
assertEquals(0, decoderB.releaseCount, "decoderB still alive (released on stop)")
|
||||
sut.stop()
|
||||
assertEquals(1, decoderB.releaseCount, "decoderB released on stop")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publisher_boundary_no_op_when_factory_is_null() =
|
||||
runTest {
|
||||
// Without a factory, NestPlayer keeps the same decoder
|
||||
// across trackAlias changes — backwards-compat path.
|
||||
val decoder = FakeOpusDecoder { byteToShorts(it) }
|
||||
val player = FakeAudioPlayer()
|
||||
val objects =
|
||||
flowOf(
|
||||
moqObject(byteArrayOf(0x01), trackAlias = 7L),
|
||||
moqObject(byteArrayOf(0x02), trackAlias = 8L),
|
||||
)
|
||||
|
||||
val sut = NestPlayer(decoder, player, this)
|
||||
sut.play(objects)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(0, decoder.releaseCount, "no boundary-driven release without a factory")
|
||||
sut.stop()
|
||||
assertEquals(1, decoder.releaseCount, "released exactly once on stop")
|
||||
}
|
||||
|
||||
// -- helpers -----------------------------------------------------------
|
||||
|
||||
private fun moqObject(payload: ByteArray): MoqObject =
|
||||
private fun moqObject(
|
||||
payload: ByteArray,
|
||||
trackAlias: Long = 1L,
|
||||
): MoqObject =
|
||||
MoqObject(
|
||||
trackAlias = 1,
|
||||
trackAlias = trackAlias,
|
||||
groupId = 0,
|
||||
objectId = 0,
|
||||
publisherPriority = 0x80,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
/**
|
||||
* Tiny stand-in for AtomicInteger that's available in commonMain
|
||||
* (kotlin.test scope). Used by the boundary-rebuild test to count
|
||||
* factory invocations across the test scope's coroutine
|
||||
* dispatcher.
|
||||
*/
|
||||
private class IntBox {
|
||||
var value: Int = 0
|
||||
|
||||
fun getAndIncrement(): Int = value++
|
||||
}
|
||||
|
||||
private fun atomicIntZero(): IntBox = IntBox()
|
||||
|
||||
private fun byteToShorts(b: ByteArray): ShortArray = ShortArray(b.size) { b[it].toShort() }
|
||||
|
||||
private class FakeOpusDecoder(
|
||||
|
||||
Reference in New Issue
Block a user