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..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,8 +328,13 @@ 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 + // 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) /** Short messages for the user (a command failed). */ @@ -2220,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/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) + } +} 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()