Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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,
Expand All @@ -27,12 +32,26 @@ class PcSpeakerPlayer {

private val queue = LinkedBlockingDeque<AudioChunk>(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<Boolean> = _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
Expand All @@ -59,6 +78,9 @@ class PcSpeakerPlayer {
}

fun stop() {
activity.reset()
_playing.value = false
watchdogThread?.interrupt()
synchronized(queue) {
queue.clear()
queuedBytes = 0
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
}
15 changes: 10 additions & 5 deletions android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Device> =
compareByDescending<Device> { it.online }.thenBy { it.pairedAt }

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading