Skip to content

Commit 83dbf90

Browse files
committed
feat: implement volume guard to snapshot media volume for USB exclusive audio anchors
1 parent e33dbfe commit 83dbf90

4 files changed

Lines changed: 147 additions & 5 deletions

File tree

app/src/main/java/moe/ouom/neriplayer/core/player/usb/sink/UsbExclusiveAudioSink.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import moe.ouom.neriplayer.core.player.lifecycle.stopPlaybackAfterUsbExclusiveNa
5252
import moe.ouom.neriplayer.core.player.lifecycle.tryRecoverUsbExclusivePlaybackAfterNativeTransferFailure
5353
import moe.ouom.neriplayer.core.player.usb.path.UsbExclusiveAudioPathTracker
5454
import moe.ouom.neriplayer.core.player.usb.session.UsbExclusiveSessionController
55+
import moe.ouom.neriplayer.core.player.usb.system.UsbExclusiveBackgroundAudioAnchorVolumeGuard
5556
import moe.ouom.neriplayer.core.player.usb.system.UsbExclusiveSystemSoundGuard
5657
import moe.ouom.neriplayer.core.player.usb.system.usbExclusiveEffectiveNativeVolume
5758
import moe.ouom.neriplayer.core.player.usb.system.usbExclusiveFloatSampleForNativePipeline
@@ -1498,6 +1499,9 @@ internal class UsbExclusiveAudioSink(
14981499
}
14991500

15001501
private fun readMusicVolumeFractionFromSystem(): Float {
1502+
UsbExclusiveBackgroundAudioAnchorVolumeGuard.currentVolumeFractionOrNull()?.let {
1503+
return it
1504+
}
15011505
val manager = audioManager ?: return 1f
15021506
return runCatching {
15031507
val minVolume = manager.getStreamMinVolume(AudioManager.STREAM_MUSIC)

app/src/main/java/moe/ouom/neriplayer/core/player/usb/system/UsbExclusiveBackgroundAudioAnchor.kt

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal object UsbExclusiveBackgroundAudioAnchor {
2121
val track: AudioTrack,
2222
val spec: UsbExclusiveBackgroundAudioAnchorSpec,
2323
val silence: ByteArray,
24+
val volumeGuardToken: UsbExclusiveBackgroundAudioAnchorVolumeGuardToken?,
2425
var streamWriterRunning: AtomicBoolean? = null,
2526
var streamWriter: Thread? = null
2627
)
@@ -36,7 +37,12 @@ internal object UsbExclusiveBackgroundAudioAnchor {
3637
}
3738
releaseLocked("replace_unusable:$reason")
3839

39-
val created = createAnchor(context, reason) ?: return false
40+
val volumeGuardToken = UsbExclusiveBackgroundAudioAnchorVolumeGuard.acquire(context)
41+
val created = createAnchor(context, reason, volumeGuardToken)
42+
if (created == null) {
43+
UsbExclusiveBackgroundAudioAnchorVolumeGuard.release(volumeGuardToken)
44+
return false
45+
}
4046
activeAnchor = created
4147
return resume(created, reason)
4248
}
@@ -56,9 +62,13 @@ internal object UsbExclusiveBackgroundAudioAnchor {
5662
}
5763
}
5864

59-
private fun createAnchor(context: Context, reason: String): ActiveAnchor? {
65+
private fun createAnchor(
66+
context: Context,
67+
reason: String,
68+
volumeGuardToken: UsbExclusiveBackgroundAudioAnchorVolumeGuardToken?
69+
): ActiveAnchor? {
6070
for (spec in usbExclusiveBackgroundAudioAnchorSpecs()) {
61-
val anchor = createAnchor(context, reason, spec)
71+
val anchor = createAnchor(context, reason, spec, volumeGuardToken)
6272
if (anchor != null) return anchor
6373
}
6474
NPLogger.w(TAG, "no compatible silent media anchor reason=$reason")
@@ -68,7 +78,8 @@ internal object UsbExclusiveBackgroundAudioAnchor {
6878
private fun createAnchor(
6979
context: Context,
7080
reason: String,
71-
spec: UsbExclusiveBackgroundAudioAnchorSpec
81+
spec: UsbExclusiveBackgroundAudioAnchorSpec,
82+
volumeGuardToken: UsbExclusiveBackgroundAudioAnchorVolumeGuardToken?
7283
): ActiveAnchor? {
7384
val channelMask = when (spec.channelCount) {
7485
1 -> AudioFormat.CHANNEL_OUT_MONO
@@ -111,7 +122,12 @@ internal object UsbExclusiveBackgroundAudioAnchor {
111122
releaseTrack(track)
112123
return null
113124
}
114-
return ActiveAnchor(track = track, spec = spec, silence = silence)
125+
return ActiveAnchor(
126+
track = track,
127+
spec = spec,
128+
silence = silence,
129+
volumeGuardToken = volumeGuardToken
130+
)
115131
}
116132

117133
private fun resolveBufferBytes(
@@ -247,6 +263,7 @@ internal object UsbExclusiveBackgroundAudioAnchor {
247263
anchor.streamWriter = null
248264
anchor.streamWriterRunning = null
249265
releaseTrack(anchor.track)
266+
UsbExclusiveBackgroundAudioAnchorVolumeGuard.release(anchor.volumeGuardToken)
250267
NPLogger.i(TAG, "released silent media anchor reason=$reason spec=${anchor.spec.name}")
251268
}
252269

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package moe.ouom.neriplayer.core.player.usb.system
2+
3+
import android.content.Context
4+
import android.media.AudioManager
5+
6+
internal data class UsbExclusiveBackgroundAudioAnchorVolumeGuardToken(
7+
val generation: Long
8+
)
9+
10+
internal class UsbExclusiveBackgroundAudioAnchorVolumeGuardState {
11+
private data class ActiveSnapshot(
12+
val token: UsbExclusiveBackgroundAudioAnchorVolumeGuardToken,
13+
val volumeFraction: Float
14+
)
15+
16+
private var nextGeneration = 0L
17+
private var activeSnapshot: ActiveSnapshot? = null
18+
19+
fun acquire(volumeFraction: Float): UsbExclusiveBackgroundAudioAnchorVolumeGuardToken {
20+
val token = UsbExclusiveBackgroundAudioAnchorVolumeGuardToken(++nextGeneration)
21+
activeSnapshot = ActiveSnapshot(
22+
token = token,
23+
volumeFraction = volumeFraction.coerceIn(0f, 1f)
24+
)
25+
return token
26+
}
27+
28+
fun currentVolumeFractionOrNull(): Float? = activeSnapshot?.volumeFraction
29+
30+
fun release(token: UsbExclusiveBackgroundAudioAnchorVolumeGuardToken) {
31+
if (activeSnapshot?.token == token) {
32+
activeSnapshot = null
33+
}
34+
}
35+
}
36+
37+
internal object UsbExclusiveBackgroundAudioAnchorVolumeGuard {
38+
private val lock = Any()
39+
private val state = UsbExclusiveBackgroundAudioAnchorVolumeGuardState()
40+
41+
fun acquire(context: Context): UsbExclusiveBackgroundAudioAnchorVolumeGuardToken? {
42+
val volumeFraction = readMusicVolumeFraction(context) ?: return null
43+
return synchronized(lock) {
44+
state.acquire(volumeFraction)
45+
}
46+
}
47+
48+
fun currentVolumeFractionOrNull(): Float? {
49+
return synchronized(lock) {
50+
state.currentVolumeFractionOrNull()
51+
}
52+
}
53+
54+
fun release(token: UsbExclusiveBackgroundAudioAnchorVolumeGuardToken?) {
55+
if (token == null) return
56+
synchronized(lock) {
57+
state.release(token)
58+
}
59+
}
60+
61+
private fun readMusicVolumeFraction(context: Context): Float? {
62+
val audioManager = context.applicationContext
63+
.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
64+
?: return null
65+
return runCatching {
66+
val minVolume = audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
67+
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
68+
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
69+
val range = maxVolume - minVolume
70+
if (range <= 0) {
71+
null
72+
} else {
73+
((currentVolume - minVolume).toFloat() / range.toFloat()).coerceIn(0f, 1f)
74+
}
75+
}.getOrNull()
76+
}
77+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package moe.ouom.neriplayer.core.player.usb.system
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNull
5+
import org.junit.Test
6+
7+
class UsbExclusiveBackgroundAudioAnchorVolumeGuardTest {
8+
9+
@Test
10+
fun `anchor keeps the pre-anchor USB volume snapshot active`() {
11+
val state = UsbExclusiveBackgroundAudioAnchorVolumeGuardState()
12+
val token = state.acquire(0.92f)
13+
14+
assertEquals(0.92f, state.currentVolumeFractionOrNull() ?: -1f, 0.0001f)
15+
16+
state.release(token)
17+
18+
assertNull(state.currentVolumeFractionOrNull())
19+
}
20+
21+
@Test
22+
fun `stale anchor release cannot clear a newer volume snapshot`() {
23+
val state = UsbExclusiveBackgroundAudioAnchorVolumeGuardState()
24+
val firstToken = state.acquire(0.92f)
25+
val secondToken = state.acquire(0.67f)
26+
27+
state.release(firstToken)
28+
29+
assertEquals(0.67f, state.currentVolumeFractionOrNull() ?: -1f, 0.0001f)
30+
31+
state.release(secondToken)
32+
33+
assertNull(state.currentVolumeFractionOrNull())
34+
}
35+
36+
@Test
37+
fun `anchor snapshot is constrained to valid media volume bounds`() {
38+
val state = UsbExclusiveBackgroundAudioAnchorVolumeGuardState()
39+
40+
state.acquire(2f)
41+
42+
assertEquals(1f, state.currentVolumeFractionOrNull() ?: -1f, 0.0001f)
43+
}
44+
}

0 commit comments

Comments
 (0)