From f890fcc4666874b7b8576954dc36612f83af6ba4 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Wed, 21 Oct 2026 06:31:21 +0530 Subject: [PATCH 1/2] android: include phone as speaker in core busy signal Signed-off-by: abinanthan-cg --- .../android/audio/ActivityWindow.kt | 30 +++++++++ .../android/audio/PcSpeakerPlayer.kt | 61 ++++++++++++++++++- .../app/nectarlink/android/core/Core.kt | 5 +- .../android/audio/ActivityWindowTest.kt | 41 +++++++++++++ .../android/audio/PcSpeakerPlayerTest.kt | 44 +++++++++++++ 5 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/kotlin/app/nectarlink/android/audio/ActivityWindow.kt create mode 100644 android/app/src/test/kotlin/app/nectarlink/android/audio/ActivityWindowTest.kt create mode 100644 android/app/src/test/kotlin/app/nectarlink/android/audio/PcSpeakerPlayerTest.kt diff --git a/android/app/src/main/kotlin/app/nectarlink/android/audio/ActivityWindow.kt b/android/app/src/main/kotlin/app/nectarlink/android/audio/ActivityWindow.kt new file mode 100644 index 00000000..a377dfc7 --- /dev/null +++ b/android/app/src/main/kotlin/app/nectarlink/android/audio/ActivityWindow.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app.nectarlink.android.audio + +import android.os.SystemClock + +/** + * Tracks whether activity occurred within a trailing time window. + */ +class ActivityWindow( + private val windowMs: Long, + private val now: () -> Long = { SystemClock.elapsedRealtime() }, +) { + @Volatile + private var lastActiveTime: Long = 0L + + fun markActive() { + lastActiveTime = now() + } + + fun reset() { + lastActiveTime = 0L + } + + fun isActive(): Boolean { + val last = lastActiveTime + if (last == 0L) return false + val elapsed = now() - last + return elapsed in 0 until windowMs + } +} diff --git a/android/app/src/main/kotlin/app/nectarlink/android/audio/PcSpeakerPlayer.kt b/android/app/src/main/kotlin/app/nectarlink/android/audio/PcSpeakerPlayer.kt index 1e7e47f1..cf7c4697 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/audio/PcSpeakerPlayer.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/audio/PcSpeakerPlayer.kt @@ -10,6 +10,9 @@ import android.util.Log import java.util.concurrent.LinkedBlockingDeque import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Plays live PCM-16LE audio frames streamed from a paired PC (`pc.audio.relay`) @@ -18,7 +21,9 @@ import java.util.concurrent.atomic.AtomicBoolean * dedicated `THREAD_PRIORITY_URGENT_AUDIO` worker maintains a tight ~35–45 ms * jitter cushion without ever calling `pause()`/`flush()` mid-stream. */ -class PcSpeakerPlayer { +class PcSpeakerPlayer( + private val activity: ActivityWindow = ActivityWindow(ACTIVE_WINDOW_MS), +) { private class AudioChunk( val sampleRate: Int, val channels: Int, @@ -27,12 +32,26 @@ class PcSpeakerPlayer { private val queue = LinkedBlockingDeque(MAX_QUEUE_CHUNKS) private val workerRunning = AtomicBoolean(false) + private val watchdogRunning = AtomicBoolean(false) + @Volatile + private var watchdogThread: Thread? = null + + private val _playing = MutableStateFlow(false) + + /** True while audio frames arrived within the trailing [ACTIVE_WINDOW_MS]. */ + val playing: StateFlow = _playing.asStateFlow() @Volatile private var queuedBytes: Int = 0 fun playFrame(sampleRate: Int, channels: Int, pcm: ByteArray) { if (pcm.isEmpty() || sampleRate <= 0 || channels <= 0) return + activity.markActive() + if (!_playing.value) { + _playing.value = true + } + ensureWatchdog() + val safeRate = sampleRate.coerceIn(8_000, 96_000) val safeChannels = if (channels >= 2) 2 else 1 val frameBytes = safeChannels * 2 @@ -59,6 +78,9 @@ class PcSpeakerPlayer { } fun stop() { + activity.reset() + _playing.value = false + watchdogThread?.interrupt() synchronized(queue) { queue.clear() queuedBytes = 0 @@ -67,6 +89,41 @@ class PcSpeakerPlayer { } } + private fun ensureWatchdog() { + if (watchdogRunning.compareAndSet(false, true)) { + Thread(::runWatchdogLoop, "nectarlink-pc-speaker-watchdog").apply { + isDaemon = true + start() + } + } + } + + private fun runWatchdogLoop() { + watchdogThread = Thread.currentThread() + try { + while (true) { + Thread.sleep(WATCHDOG_CHECK_INTERVAL_MS) + if (!activity.isActive()) { + _playing.value = false + break + } + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + _playing.value = false + } finally { + watchdogThread = null + watchdogRunning.set(false) + if (activity.isActive() && watchdogRunning.compareAndSet(false, true)) { + _playing.value = true + Thread(::runWatchdogLoop, "nectarlink-pc-speaker-watchdog").apply { + isDaemon = true + start() + } + } + } + } + private fun pollChunk(timeoutMs: Long): AudioChunk? { val chunk = if (timeoutMs <= 0L) { queue.pollFirst() @@ -226,5 +283,7 @@ class PcSpeakerPlayer { private const val TAG = "PcSpeakerPlayer" private const val INACTIVITY_TIMEOUT_MS = 30_000L private const val MAX_QUEUE_CHUNKS = 96 + const val ACTIVE_WINDOW_MS = 3_000L + private const val WATCHDOG_CHECK_INTERVAL_MS = 1_000L } } diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index 662857c8..d96d932a 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -328,8 +328,9 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener MirrorService.session.map { it.active }.distinctUntilChanged(), WebcamService.session.map { it.active || it.connecting }.distinctUntilChanged(), MicService.streaming.map { it != null }.distinctUntilChanged(), - ) { hasTransfers: Boolean, isMirroring: Boolean, isWebcam: Boolean, isMic: Boolean -> - hasTransfers || isMirroring || isWebcam || isMic + pcSpeakerPlayer.playing.map { it }.distinctUntilChanged(), + ) { hasTransfers: Boolean, isMirroring: Boolean, isWebcam: Boolean, isMic: Boolean, isSpeaker: Boolean -> + hasTransfers || isMirroring || isWebcam || isMic || isSpeaker }.stateIn(scope, SharingStarted.Eagerly, false) /** Short messages for the user (a command failed). */ diff --git a/android/app/src/test/kotlin/app/nectarlink/android/audio/ActivityWindowTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/audio/ActivityWindowTest.kt new file mode 100644 index 00000000..59472cd7 --- /dev/null +++ b/android/app/src/test/kotlin/app/nectarlink/android/audio/ActivityWindowTest.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app.nectarlink.android.audio + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ActivityWindowTest { + @Test + fun tracksTrailingActivityWindow() { + var currentTime = 10_000L + val window = ActivityWindow(windowMs = 3_000L, now = { currentTime }) + + // Initially inactive before any activity + assertFalse(window.isActive()) + + // Active right after markActive() + window.markActive() + assertTrue(window.isActive()) + + // Still active just inside the window (2,999 ms later) + currentTime += 2_999L + assertTrue(window.isActive()) + + // Inactive just after the window expires (3,000 ms elapsed) + currentTime += 1L + assertFalse(window.isActive()) + + // Inactive further after the window + currentTime += 1_000L + assertFalse(window.isActive()) + + // Active again after another markActive() + window.markActive() + assertTrue(window.isActive()) + + // Inactive immediately after reset() + window.reset() + assertFalse(window.isActive()) + } +} diff --git a/android/app/src/test/kotlin/app/nectarlink/android/audio/PcSpeakerPlayerTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/audio/PcSpeakerPlayerTest.kt new file mode 100644 index 00000000..05c03367 --- /dev/null +++ b/android/app/src/test/kotlin/app/nectarlink/android/audio/PcSpeakerPlayerTest.kt @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app.nectarlink.android.audio + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PcSpeakerPlayerTest { + @Test + fun invalidFramesDoNotSetPlaying() { + var currentTime = 1_000L + val window = ActivityWindow(windowMs = 3_000L, now = { currentTime }) + val player = PcSpeakerPlayer(window) + + assertFalse(player.playing.value) + + // Empty PCM + player.playFrame(44_100, 2, ByteArray(0)) + assertFalse(player.playing.value) + + // Invalid sample rate + player.playFrame(0, 2, ByteArray(16)) + assertFalse(player.playing.value) + + // Invalid channels + player.playFrame(44_100, 0, ByteArray(16)) + assertFalse(player.playing.value) + } + + @Test + fun validFrameSetsPlayingAndStopResets() { + var currentTime = 1_000L + val window = ActivityWindow(windowMs = 3_000L, now = { currentTime }) + val player = PcSpeakerPlayer(window) + + assertFalse(player.playing.value) + + player.playFrame(44_100, 2, ByteArray(16)) + assertTrue(player.playing.value) + + player.stop() + assertFalse(player.playing.value) + } +} From a696a48f9bafc717cb460e251a3ee7f8d39ccb7e Mon Sep 17 00:00:00 2001 From: Bee <302257494+thtbee@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:38:13 +0530 Subject: [PATCH 2/2] android: phone as speaker stays busy while the PC has it on The PC stops sending audio about 500 ms into silence, so a paused song let the frame window run out and, with "Run in the background" off, the core stopped and Play on the PC no longer reached the phone. Busy now also counts a connected PC whose deck state lists the phone as an output; the frame window covers the moment before the first deck state arrives. --- .../app/nectarlink/android/core/Core.kt | 12 ++++++++---- .../app/nectarlink/android/core/CoreState.kt | 13 +++++++++++++ .../nectarlink/android/core/CoreStateTest.kt | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index d96d932a..1b62be28 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -328,7 +328,11 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener MirrorService.session.map { it.active }.distinctUntilChanged(), WebcamService.session.map { it.active || it.connecting }.distinctUntilChanged(), MicService.streaming.map { it != null }.distinctUntilChanged(), - pcSpeakerPlayer.playing.map { it }.distinctUntilChanged(), + // Phone as speaker: frames arriving, or the PC still has it on + // (frames stop while the PC is silent, for example a paused song). + combine(pcSpeakerPlayer.playing, _state.map { it.pcSpeakerRelayOn }) { playing, relayOn -> + playing || relayOn + }.distinctUntilChanged(), ) { hasTransfers: Boolean, isMirroring: Boolean, isWebcam: Boolean, isMic: Boolean, isSpeaker: Boolean -> hasTransfers || isMirroring || isWebcam || isMic || isSpeaker }.stateIn(scope, SharingStarted.Eagerly, false) @@ -2221,16 +2225,16 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener val current = s.device(id)?.deckState if (current != null) { val updatedOutputs = when (volume) { - 201 -> if (current.outputDevices.any { it.id == "nectarlink:phone_speaker" }) { + 201 -> if (current.outputDevices.any { it.id == PHONE_SPEAKER_OUTPUT }) { current.outputDevices } else { current.outputDevices + app.nectarlink.core.AudioOutputDevice( - id = "nectarlink:phone_speaker", + id = PHONE_SPEAKER_OUTPUT, name = "Phone speaker (NectarLink)", isDefault = false, ) } - 200 -> current.outputDevices.filterNot { it.id == "nectarlink:phone_speaker" } + 200 -> current.outputDevices.filterNot { it.id == PHONE_SPEAKER_OUTPUT } else -> current.outputDevices } s.reduce( diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt index 159bb8ee..65bd5541 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt @@ -164,6 +164,9 @@ data class Device( } } +/** The output a PC's deck state lists while it plays its sound on this phone. */ +const val PHONE_SPEAKER_OUTPUT = "nectarlink:phone_speaker" + private val deviceOrder: Comparator = compareByDescending { it.online }.thenBy { it.pairedAt } @@ -250,6 +253,16 @@ data class CoreState( ) { fun device(id: String): Device? = devices.firstOrNull { it.id == id } + /** + * Whether a connected PC has "Phone as speaker" on: its deck state lists + * this phone as an output. The PC stops sending audio during silence, so + * this stays true across pauses where no frames arrive. + */ + val pcSpeakerRelayOn: Boolean + get() = devices.any { d -> + d.online && d.deckState?.outputDevices?.any { it.id == PHONE_SPEAKER_OUTPUT } == true + } + fun nameOf(id: String): String? = device(id)?.name ?: discovered.firstOrNull { it.id == id }?.name diff --git a/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt index 5d324a5d..4a96d285 100644 --- a/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt +++ b/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt @@ -27,6 +27,25 @@ class CoreStateTest { canWake = canWake, ) + @Test + fun phoneAsSpeakerCountsWhileAConnectedPcHasItOn() { + val speaker = app.nectarlink.core.AudioOutputDevice(PHONE_SPEAKER_OUTPUT, "Phone speaker (NectarLink)", false) + val headphones = app.nectarlink.core.AudioOutputDevice("hp", "Headphones", true) + fun deck(vararg outputs: app.nectarlink.core.AudioOutputDevice) = + app.nectarlink.core.DeckState(playing = false, volume = 50u, muted = false, micMuted = null, outputDevices = outputs.toList()) + val online = CoreState() + .reduce(Event.DeviceAdded(pc("a", "Desk", 10))) + .reduce(Event.LinkChanged("a", Link.Online(relayed = false, usb = false, rttMs = 5u))) + fun withDeck(state: CoreState, deck: app.nectarlink.core.DeckState) = + state.copy(devices = state.devices.map { it.copy(deckState = deck) }) + + assertTrue("relay on, nothing playing", withDeck(online, deck(headphones, speaker)).pcSpeakerRelayOn) + assertFalse("relay off", withDeck(online, deck(headphones)).pcSpeakerRelayOn) + assertFalse("no deck state yet", online.pcSpeakerRelayOn) + val offline = online.reduce(Event.LinkChanged("a", Link.Offline(null))) + assertFalse("a PC that is offline doesn't keep the phone busy", withDeck(offline, deck(speaker)).pcSpeakerRelayOn) + } + @Test fun devicesFollowEventsInPairingOrder() { var state = CoreState()