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:
+61
-1
@@ -42,7 +42,7 @@ import kotlinx.coroutines.launch
|
||||
* decoder. Idempotent.
|
||||
*/
|
||||
class NestPlayer(
|
||||
private val decoder: OpusDecoder,
|
||||
initialDecoder: OpusDecoder,
|
||||
private val player: AudioPlayer,
|
||||
private val scope: CoroutineScope,
|
||||
/**
|
||||
@@ -61,11 +61,44 @@ class NestPlayer(
|
||||
* Default is `0` so existing tests stand without modification.
|
||||
*/
|
||||
private val prerollFrames: Int = 0,
|
||||
/**
|
||||
* Optional factory called on every detected publisher boundary
|
||||
* (track-alias change in the inbound [MoqObject] stream) to mint a
|
||||
* fresh [OpusDecoder]. Used by the listener wrapper's re-issuing
|
||||
* subscription pump
|
||||
* ([com.vitorpamplona.nestsclient.ReconnectingNestsListener.reissuingSubscribe]):
|
||||
* each new SUBSCRIBE through the relay produces objects with a
|
||||
* different `trackAlias`, but they're spliced into the same
|
||||
* `SharedFlow` — without a decoder reset on the boundary, Opus's
|
||||
* predictor state from the prior publisher's last frame is fed
|
||||
* into the new publisher's first frame, producing an audible
|
||||
* warble at every JWT-refresh hot-swap on the speaker side OR
|
||||
* cliff-detector recycle on the listener side.
|
||||
*
|
||||
* Default `null` keeps the legacy behaviour (no boundary
|
||||
* detection, decoder lives for the player's whole lifetime) so
|
||||
* existing tests / callers that don't care about boundaries
|
||||
* stand unchanged. Production callers in
|
||||
* [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel.openSubscription]
|
||||
* pass a closure that captures the per-subscription channel-count
|
||||
* and rebuilds via `decoderFactory(channelCount)`.
|
||||
*/
|
||||
private val decoderFactory: (() -> OpusDecoder)? = null,
|
||||
) {
|
||||
init {
|
||||
require(prerollFrames >= 0) { "prerollFrames must be >= 0, got $prerollFrames" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Active decoder. Replaced on detected publisher boundary when
|
||||
* [decoderFactory] is non-null. `var` so the boundary path can
|
||||
* release + rebuild without changing the rest of the loop's
|
||||
* decoder reference; the `private` confines mutation to this
|
||||
* class, and the decode loop runs single-coroutine so no cross-
|
||||
* thread visibility hazards.
|
||||
*/
|
||||
private var decoder: OpusDecoder = initialDecoder
|
||||
|
||||
private var job: Job? = null
|
||||
private var stopped = false
|
||||
|
||||
@@ -157,6 +190,13 @@ class NestPlayer(
|
||||
com.vitorpamplona.quartz.utils.Log
|
||||
.d("NestPlay") { "NestPlayer beginPlayback returned" }
|
||||
}
|
||||
// Track-alias of the most recently observed object.
|
||||
// A change signals a publisher boundary (re-issuing
|
||||
// subscription wrapper spliced in a new SUBSCRIBE).
|
||||
// Only consulted when [decoderFactory] is non-null;
|
||||
// legacy callers without a factory keep the prior
|
||||
// single-decoder behaviour.
|
||||
var lastTrackAlias: Long? = null
|
||||
try {
|
||||
objects.collect { obj ->
|
||||
receivedObjects += 1
|
||||
@@ -165,6 +205,26 @@ class NestPlayer(
|
||||
"NestPlayer received obj #$receivedObjects (decoded=$decodedFrames empty=$emptyDecodes enqueued=$enqueued playbackBegun=$playbackBegun)"
|
||||
}
|
||||
}
|
||||
// Publisher-boundary detection: if the trackAlias
|
||||
// changed since the last object AND we have a
|
||||
// factory to mint a fresh decoder, release the
|
||||
// current decoder + build a new one. Without
|
||||
// this, Opus's predictor state from the prior
|
||||
// publisher's last frame is fed into the new
|
||||
// publisher's first frame, producing audible
|
||||
// warble at every JWT-refresh hot-swap (speaker
|
||||
// side) or cliff-detector recycle (listener side).
|
||||
// The prior-trackAlias guard avoids a spurious
|
||||
// rebuild on the very first frame.
|
||||
val factory = decoderFactory
|
||||
if (factory != null && lastTrackAlias != null && obj.trackAlias != lastTrackAlias) {
|
||||
com.vitorpamplona.quartz.utils.Log.d("NestPlay") {
|
||||
"NestPlayer publisher boundary: trackAlias $lastTrackAlias → ${obj.trackAlias}; rebuilding decoder"
|
||||
}
|
||||
runCatching { decoder.release() }
|
||||
decoder = factory()
|
||||
}
|
||||
lastTrackAlias = obj.trackAlias
|
||||
val pcm =
|
||||
try {
|
||||
decoder.decode(obj.payload)
|
||||
|
||||
Reference in New Issue
Block a user