fix(nests): platform-side audio robustness — focus, AEC, route obs, network handover
Four follow-up fixes from the post-audit review (#4 / #5 / #6 / #7). #6 AcousticEchoCanceler / NoiseSuppressor / AGC on the AudioRecord session. The VOICE_COMMUNICATION input source engages the platform echo canceller automatically on most modern Android devices, but a small set of older / OEM-customised devices only attach AEC under MODE_IN_COMMUNICATION — which an audio-room app deliberately avoids. Attaching the standalone audiofx effects to the AudioRecord's session id covers those devices without rerouting through the call audio path. All three are best-effort and a no-op on devices where the source already engages them. #4 Real audio focus handling. The previous OnAudioFocusChangeListener was a no-op based on the assumption that the OS would auto-duck us; it doesn't (CONTENT_TYPE_SPEECH streams aren't auto-ducked). Inbound phone calls were mixing on top of room audio. - New `NestAudioFocusBus` (commons) — process-wide enum signal, decoupled from android.media so commons stays platform-free. - NestForegroundService translates AUDIOFOCUS_GAIN / LOSS_TRANSIENT* / LOSS into the bus enum. - NestViewModel observes the bus and silences both the listener playback (effective listen-mute = user OR focus) and the broadcast mic (effective mic-mute = user OR focus). User-visible mute states stay the user's choice so a focus regain restores them automatically. - The pipeline keeps running while focus is lost (decoder, capture, network) so unmute is sample-accurate when the call ends. #5 AudioDeviceCallback observability. Registers a callback in NestForegroundService that logs Bluetooth / wired / USB headset attach + detach with device type + name. Doesn't drive playback decisions — Android's auto-routing handles route swaps — but makes "audio cut out when I plugged in headphones" reports correlatable with a concrete event for the first time. #7 Network-change → fast reconnect. Without this, a Wi-Fi → cellular handover left the QUIC connection sitting on a now-dead socket until its PTO fired (~30 s) before the wrapper noticed. Now: - New `NestNetworkChangeBus` (commons) — collapses bursts of onLost/onAvailable into a single recycle event. - NestsListener + NestsSpeaker grow `recycleSession()` (default no-op); the reconnecting wrappers override to close the inner session so their orchestrator opens a fresh one. - NestForegroundService registers a default-network callback; suppresses the first onAvailable (registration callback) and only publishes on actual default-network changes. - NestViewModel observes the bus and calls recycleSession on both wrappers. The SubscribeHandle re-issuance pump (listener) and the hot-swap publisher pump (speaker) cut existing subscriptions / broadcasts onto the new session as soon as it lands — same paths the JWT-refresh recycle uses. - Manifest gains ACCESS_NETWORK_STATE for registerDefaultNetworkCallback.
This commit is contained in:
@@ -116,6 +116,27 @@ interface NestsListener {
|
||||
"announces() is moq-lite-only; IETF listener has no announce-prefix flow.",
|
||||
)
|
||||
|
||||
/**
|
||||
* Force the underlying transport / MoQ session to be torn down and
|
||||
* a fresh one opened in its place — without permanently closing
|
||||
* this listener. Used by the platform layer on a network change
|
||||
* (Wi-Fi ↔ cellular handover) to skip the ~30 s QUIC PTO that
|
||||
* would otherwise have to fire before the wrapper notices the
|
||||
* old socket is dead.
|
||||
*
|
||||
* Default no-op for non-reconnecting implementations (raw
|
||||
* [DefaultNestsListener] / [MoqLiteNestsListener]) — there's no
|
||||
* orchestrator to drive a reconnect, so a force recycle would
|
||||
* just close the listener. The reconnecting wrapper
|
||||
* (`ReconnectingHandle` from [connectReconnectingNestsListener])
|
||||
* overrides to close the active inner listener so its
|
||||
* orchestrator opens a fresh session.
|
||||
*/
|
||||
suspend fun recycleSession() {
|
||||
// no-op — only the reconnecting wrapper has anywhere to
|
||||
// recycle to.
|
||||
}
|
||||
|
||||
/** Tear down the MoQ session + underlying transport. Idempotent. */
|
||||
suspend fun close()
|
||||
}
|
||||
|
||||
@@ -66,6 +66,27 @@ interface NestsSpeaker {
|
||||
*/
|
||||
suspend fun startBroadcasting(onLevel: (Float) -> Unit = { /* no-op */ }): BroadcastHandle
|
||||
|
||||
/**
|
||||
* Force the underlying transport / MoQ session to be torn down and
|
||||
* a fresh one opened in its place — without permanently closing
|
||||
* this speaker. Mirror of [NestsListener.recycleSession]; same
|
||||
* use case (network handover) and same default no-op for non-
|
||||
* reconnecting implementations.
|
||||
*
|
||||
* The reconnecting wrapper from
|
||||
* [connectReconnectingNestsSpeaker] overrides to close the
|
||||
* inner speaker so its orchestrator opens a fresh session.
|
||||
* For moq-lite speakers, the long-lived
|
||||
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster]
|
||||
* keeps capturing through the recycle and the
|
||||
* [BroadcastHandle.swapPublisher]-based pump retargets onto
|
||||
* the new session's publisher with no audible gap.
|
||||
*/
|
||||
suspend fun recycleSession() {
|
||||
// no-op — only the reconnecting wrapper has anywhere to
|
||||
// recycle to.
|
||||
}
|
||||
|
||||
/** Tear down the MoQ session + transport. Idempotent. */
|
||||
suspend fun close()
|
||||
}
|
||||
|
||||
+23
@@ -420,6 +420,29 @@ private class ReconnectingHandle(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-close the active inner listener so the orchestrator's
|
||||
* `terminalAwait` returns Closed and the next loop iteration opens
|
||||
* a fresh session. Used by the platform layer on a network change
|
||||
* (Wi-Fi ↔ cellular) — without this, the wrapper would have to
|
||||
* wait for the QUIC PTO to fire on the now-dead old socket
|
||||
* (~30 s of silence) before noticing.
|
||||
*
|
||||
* The SubscribeHandle re-issuance pump cuts existing subs over to
|
||||
* the new session as soon as it's Connected — same path the
|
||||
* JWT-refresh recycle uses — so the consumer-facing
|
||||
* [SubscribeHandle.objects] flow keeps emitting once the new
|
||||
* session lands.
|
||||
*/
|
||||
override suspend fun recycleSession() {
|
||||
val current = activeListener.value ?: return
|
||||
// Best-effort. If the close races a concurrent reconnect path
|
||||
// (extremely rare — we only call this on deliberate user /
|
||||
// platform signals), the orchestrator absorbs both via its
|
||||
// existing terminal-state handling.
|
||||
runCatching { current.close() }
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
orchestrator.cancel()
|
||||
runCatching { activeListener.value?.close() }
|
||||
|
||||
+11
@@ -358,6 +358,17 @@ private class ReconnectingSpeakerHandle(
|
||||
handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-close the active inner speaker so the orchestrator opens
|
||||
* a fresh session against the (presumably new) network. Used by
|
||||
* the platform layer on a network change. Mirror of the listener
|
||||
* wrapper's [com.vitorpamplona.nestsclient.NestsListener.recycleSession].
|
||||
*/
|
||||
override suspend fun recycleSession() {
|
||||
val current = activeSpeaker.value ?: return
|
||||
runCatching { current.close() }
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
orchestrator.cancel()
|
||||
runCatching { activeBroadcast?.close() }
|
||||
|
||||
Reference in New Issue
Block a user