fix(nests): self-audit pass — two real bugs + robustness + tests

Audit of the four prior commits found two genuine regressions and two
robustness gaps in the new code paths.

Bug A: NestForegroundService.networkCallback dropped the publish for
the most important scenario it was added to handle. The earlier guard
`if (previous != null && previous != network)` suppressed both the
registration-time first onAvailable AND the legitimate WiFi-loss-
then-cellular-available path. On a WiFi → cellular handover the
sequence is `onLost(wifi)` (clears currentDefaultNetwork to null)
followed by `onAvailable(cellular)` — which then looks identical to
the registration callback to the guard, so no publish fires and the
QUIC session sits on the dead socket until PTO. Replaced the implicit
"previous == null" suppression with an explicit `seenInitialNetwork`
flag that's set true on the first onAvailable and never cleared, so
post-onLost reconnects publish correctly.

Bug B: requestAudioFocus result handling was too permissive. The
shape `if (result == AUDIOFOCUS_REQUEST_FAILED) TransientLoss else
Granted` falls through to Granted on the runCatching exception path
(`result == null`) and on AUDIOFOCUS_REQUEST_DELAYED (= 2) — meaning
audio plays over an active call when the OS hasn't actually released
focus. Switched to a strict `if (result == AUDIOFOCUS_REQUEST_GRANTED)`
check; everything else (FAILED, DELAYED, exception) starts the VM
muted.

Robustness: NestViewModel.openSubscription's onError callback used to
swallow every AudioException, which fit the per-packet decoder-error
case but turned PlaybackFailed and DeviceUnavailable from a deferred
beginPlayback into a permanent "Connecting…" spinner on the speaker
tile. Now we discriminate by AudioException.Kind: decoder/encoder
errors stay swallowed (Opus PLC papers them over), but PlaybackFailed
and DeviceUnavailable roll the slot back so a future reconcile can
retry.

Pre-roll ordering swap + tests: NestPlayer.play used to call
beginPlayback BEFORE flushing the pre-roll buffer, leaving a
microsecond window where the AudioTrack hardware was playing against
an empty buffer. AudioTrack MODE_STREAM explicitly supports write()
before play(), so flush-then-beginPlayback is the textbook pattern
and what the fix now does. Three regression tests cover:
  - pre-roll defers beginPlayback until threshold is met (and the
    flush-then-begin ordering is observable)
  - partial pre-roll flushes when the upstream flow ends early
  - empty flow doesn't begin playback at all
The FakeAudioPlayer grows beginPlaybackCount + queuedAtBeginPlayback
fields so the tests can assert ordering directly.
This commit is contained in:
Claude
2026-05-05 13:04:18 +00:00
parent 6237c02c6f
commit 003cf42564
4 changed files with 222 additions and 16 deletions
@@ -138,9 +138,20 @@ class NestForegroundService : Service() {
* default network just changed under us" (publish). Volatile because * default network just changed under us" (publish). Volatile because
* the callback fires on a binder thread; the publish path is * the callback fires on a binder thread; the publish path is
* lock-free via [NestNetworkChangeBus]. * lock-free via [NestNetworkChangeBus].
*
* `seenInitialNetwork` is the registration-time suppression flag —
* set true on the first onAvailable and never cleared. The earlier
* shape used `previous != null` as the suppression check, which
* also suppressed the WiFi-loss-then-cellular-available case
* (`onLost` clears [currentDefaultNetwork] back to null, then the
* follow-up `onAvailable` looks identical to the registration
* callback). That broke the most important scenario this whole
* code path exists for — a clean Wi-Fi ↔ cellular handover.
*/ */
@Volatile private var currentDefaultNetwork: Network? = null @Volatile private var currentDefaultNetwork: Network? = null
@Volatile private var seenInitialNetwork: Boolean = false
/** /**
* Listens for the device's default-network changing (Wi-Fi ↔ * Listens for the device's default-network changing (Wi-Fi ↔
* cellular handover, plane mode toggle, hotspot swap) and signals * cellular handover, plane mode toggle, hotspot swap) and signals
@@ -159,7 +170,14 @@ class NestForegroundService : Service() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
val previous = currentDefaultNetwork val previous = currentDefaultNetwork
currentDefaultNetwork = network currentDefaultNetwork = network
if (previous != null && previous != network) { if (!seenInitialNetwork) {
// Registration-time callback — fires once with the
// currently-active default network. Suppress so the
// VM doesn't recycle on every service start.
seenInitialNetwork = true
return
}
if (previous != network) {
Log.i("NestNet") { Log.i("NestNet") {
"default network changed ($previous$network), recycling QUIC sessions" "default network changed ($previous$network), recycling QUIC sessions"
} }
@@ -193,6 +211,7 @@ class NestForegroundService : Service() {
mgr.unregisterNetworkCallback(networkCallback) mgr.unregisterNetworkCallback(networkCallback)
} }
currentDefaultNetwork = null currentDefaultNetwork = null
seenInitialNetwork = false
} }
/** /**
@@ -232,19 +251,20 @@ class NestForegroundService : Service() {
.setOnAudioFocusChangeListener { focusChange -> .setOnAudioFocusChangeListener { focusChange ->
NestAudioFocusBus.publish(translateFocusChange(focusChange)) NestAudioFocusBus.publish(translateFocusChange(focusChange))
}.build() }.build()
// Best-effort: a refused request just means the OS will // Strict GRANTED check: anything else — FAILED (active call
// duck/pause us based on its own policy. Don't fail the // blocking us), DELAYED (we set [setAcceptsDelayedFocusGain]
// service start over a focus denial. // false so this shouldn't happen but treat it as not-granted
val granted = runCatching { mgr.requestAudioFocus(request) }.getOrNull() // defensively), or a swallowed exception from runCatching —
// If the OS refused outright (rare — typically only when an // means we don't actually own playback yet, so the VM must
// active call is already in progress at the moment we start), // start muted. The earlier shape used `!= FAILED` which fell
// publish TransientLoss immediately so the VM mutes from t=0 // through to Granted on `null` (exception path) and on
// rather than playing for ~50 ms before the listener fires. // DELAYED (= 2), playing audio over a call that the OS hadn't
// [AudioManager.AUDIOFOCUS_REQUEST_FAILED] = 0; granted = 1. // released to us.
if (granted == AudioManager.AUDIOFOCUS_REQUEST_FAILED) { val result = runCatching { mgr.requestAudioFocus(request) }.getOrNull()
NestAudioFocusBus.publish(NestAudioFocusState.TransientLoss) if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
} else {
NestAudioFocusBus.publish(NestAudioFocusState.Granted) NestAudioFocusBus.publish(NestAudioFocusState.Granted)
} else {
NestAudioFocusBus.publish(NestAudioFocusState.TransientLoss)
} }
audioFocusRequest = request audioFocusRequest = request
} }
@@ -34,6 +34,7 @@ import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.NestsSpeaker import com.vitorpamplona.nestsclient.NestsSpeaker
import com.vitorpamplona.nestsclient.NestsSpeakerState import com.vitorpamplona.nestsclient.NestsSpeakerState
import com.vitorpamplona.nestsclient.audio.AudioCapture import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.AudioException
import com.vitorpamplona.nestsclient.audio.AudioPlayer import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.NestPlayer import com.vitorpamplona.nestsclient.audio.NestPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder import com.vitorpamplona.nestsclient.audio.OpusDecoder
@@ -1015,7 +1016,32 @@ class NestViewModel(
val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) } val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) }
roomPlayer.play( roomPlayer.play(
instrumented, instrumented,
onError = { /* swallow per-packet decoder errors */ }, onError = { err ->
// Per-packet decoder errors are noise — Opus PLC
// papers over a single bad frame. Swallow those.
// PlaybackFailed (or DeviceUnavailable from a
// deferred [AudioPlayer.beginPlayback], or the
// audio-priority dispatcher dying mid-stream) is
// terminal: the device is wedged for THIS
// subscription. Roll the slot back so a future
// reconcile can retry rather than leave a
// perma-spinning speaker tile.
when (err.kind) {
AudioException.Kind.DecoderError,
AudioException.Kind.EncoderError,
-> {
Unit
}
AudioException.Kind.PlaybackFailed,
AudioException.Kind.DeviceUnavailable,
-> {
if (activeSubscriptions[pubkey] === slot) {
activeSubscriptions.remove(pubkey)?.let { closeSubscription(it) }
}
}
}
},
onLevel = { onAudioLevel(pubkey, it) }, onLevel = { onAudioLevel(pubkey, it) },
) )
slot.attach(handle, roomPlayer, player) slot.attach(handle, roomPlayer, player)
@@ -126,11 +126,22 @@ class NestPlayer(
suspend fun beginAndFlushIfNeeded() { suspend fun beginAndFlushIfNeeded() {
if (playbackBegun) return if (playbackBegun) return
if (preroll.isEmpty()) return if (preroll.isEmpty()) return
player.beginPlayback() // Flush BEFORE [beginPlayback] so the device starts
playbackBegun = true // playing against an already-populated buffer rather
// than emitting silence for the microseconds it
// takes the flush loop to fill. AudioTrack
// MODE_STREAM explicitly supports write() before
// play() per the Android docs — that's the
// textbook pre-roll pattern. Order matters
// because [beginPlayback] is the moment the
// hardware starts pulling samples; getting
// [enqueue] in first means the very first sample
// pulled is from our pre-rolled audio, not silence.
while (preroll.isNotEmpty()) { while (preroll.isNotEmpty()) {
player.enqueue(preroll.removeFirst()) player.enqueue(preroll.removeFirst())
} }
player.beginPlayback()
playbackBegun = true
} }
try { try {
objects.collect { obj -> objects.collect { obj ->
@@ -216,6 +216,130 @@ class NestPlayerTest {
sut.stop() sut.stop()
} }
/**
* Pre-roll: with `prerollFrames=3`, [AudioPlayer.beginPlayback]
* must NOT fire until the third decoded frame has arrived, and
* once it fires the AudioPlayer's queue must already contain
* the buffered pre-roll (i.e. flushed atomically with playback
* start, not lazily on the next enqueue).
*/
@Test
fun preroll_defers_beginPlayback_until_threshold_is_met() =
runTest {
val channel = Channel<MoqObject>(capacity = 8)
val decoder = FakeOpusDecoder { byteToShorts(it) }
val player = FakeAudioPlayer()
val sut =
NestPlayer(
decoder = decoder,
player = player,
scope = this,
prerollFrames = 3,
)
sut.play(channel.receiveAsFlow())
testScheduler.runCurrent()
// Frames 1 and 2: pre-roll buffer fills, beginPlayback NOT
// called yet, AudioPlayer hasn't seen a single enqueue.
channel.send(moqObject(byteArrayOf(0x01)))
channel.send(moqObject(byteArrayOf(0x02)))
testScheduler.advanceUntilIdle()
assertEquals(0, player.beginPlaybackCount, "beginPlayback before threshold")
assertEquals(0, player.queued.size, "no enqueue before threshold")
// Frame 3 trips the threshold: beginPlayback fires exactly
// once, and the pre-rolled frames must ALREADY be sitting
// in the AudioPlayer queue at that moment (otherwise the
// device starts playback against an empty buffer and
// pre-roll's whole point is defeated). [NestPlayer.play]
// implements this by flushing-then-beginPlayback —
// AudioTrack MODE_STREAM explicitly supports write()
// before play().
channel.send(moqObject(byteArrayOf(0x03)))
testScheduler.advanceUntilIdle()
assertEquals(1, player.beginPlaybackCount)
// The fake records the queue size at the moment
// beginPlayback fires. With flush-then-begin ordering,
// all 3 pre-rolled frames are already in the queue.
assertEquals(3, player.queuedAtBeginPlayback, "pre-roll flushed before beginPlayback")
assertEquals(3, player.queued.size, "buffer populated when device starts")
// Subsequent frames bypass the buffer and go directly
// through enqueue.
channel.send(moqObject(byteArrayOf(0x04)))
testScheduler.advanceUntilIdle()
assertEquals(1, player.beginPlaybackCount, "beginPlayback only fires once")
assertEquals(4, player.queued.size)
sut.stop()
}
/**
* Pre-roll: a flow that ends BEFORE the pre-roll threshold fires
* must still flush its partial buffer to the AudioPlayer.
* Otherwise a fast-cycling publisher could leave already-decoded
* frames stranded forever.
*/
@Test
fun preroll_flushes_partial_buffer_when_flow_ends_early() =
runTest {
val decoder = FakeOpusDecoder { byteToShorts(it) }
val player = FakeAudioPlayer()
val sut =
NestPlayer(
decoder = decoder,
player = player,
scope = this,
prerollFrames = 5,
)
// Only 2 frames — pre-roll never reaches its 5-frame floor,
// but the upstream Flow ends so the loop's flush hook must
// begin playback and drain whatever's queued.
sut.play(
flowOf(
moqObject(byteArrayOf(0x01)),
moqObject(byteArrayOf(0x02)),
),
)
testScheduler.advanceUntilIdle()
assertEquals(1, player.beginPlaybackCount)
assertEquals(2, player.queued.size)
assertContentEquals(byteToShorts(byteArrayOf(0x01)), player.queued[0])
assertContentEquals(byteToShorts(byteArrayOf(0x02)), player.queued[1])
sut.stop()
}
/**
* Pre-roll edge case: an empty flow shouldn't start playback at
* all. The AudioTrack stays in its allocated-but-not-playing
* state until [stop] tears it down.
*/
@Test
fun preroll_does_not_begin_playback_when_flow_emits_no_pcm() =
runTest {
val decoder = FakeOpusDecoder { ShortArray(0) }
val player = FakeAudioPlayer()
val sut =
NestPlayer(
decoder = decoder,
player = player,
scope = this,
prerollFrames = 3,
)
sut.play(flowOf(moqObject(byteArrayOf(0x01)), moqObject(byteArrayOf(0x02))))
testScheduler.advanceUntilIdle()
assertEquals(0, player.beginPlaybackCount, "no PCM → no playback")
assertEquals(0, player.queued.size)
sut.stop()
}
// -- helpers ----------------------------------------------------------- // -- helpers -----------------------------------------------------------
private fun moqObject(payload: ByteArray): MoqObject = private fun moqObject(payload: ByteArray): MoqObject =
@@ -246,6 +370,26 @@ class NestPlayerTest {
private class FakeAudioPlayer : AudioPlayer { private class FakeAudioPlayer : AudioPlayer {
var started = false var started = false
private set private set
/**
* Counts the [AudioPlayer.beginPlayback] invocations. Used by
* the pre-roll regression tests to assert that playback only
* begins AFTER `prerollFrames` decoded frames have arrived (or
* after the upstream flow ends with a partial buffer). The
* default no-op `beginPlayback` in the interface lets fakes
* skip overriding when they don't care; we override here so
* the tests can verify the pre-roll wiring.
*
* Also tracks the size of `queued` at the moment beginPlayback
* fired pre-roll's contract is that the buffer is flushed
* IN A TIGHT LOOP after beginPlayback, so we can read the
* snapshot to verify the flush ordering.
*/
var beginPlaybackCount = 0
private set
var queuedAtBeginPlayback: Int = -1
private set
var stopCount = 0 var stopCount = 0
private set private set
val stopped: Boolean get() = stopCount > 0 val stopped: Boolean get() = stopCount > 0
@@ -257,6 +401,11 @@ class NestPlayerTest {
started = true started = true
} }
override fun beginPlayback() {
beginPlaybackCount++
queuedAtBeginPlayback = queued.size
}
override suspend fun enqueue(pcm: ShortArray) { override suspend fun enqueue(pcm: ShortArray) {
queued.add(pcm) queued.add(pcm)
} }