mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(reaction): Duck via audio focus when the volume write is ignored
ColorOS 16 accepts setStreamVolume from a backgrounded app and leaves the level where it was, so the conversation reaction ducked nothing. duckMusicVolume now classifies the outcome (Ducked / Unchanged / Skipped) instead of collapsing everything into a nullable duck, and a level that came back *higher* stays a skip: that is the user raising the volume between the two reads, not a refusal. On Unchanged the reaction requests AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK and lets the framework attenuate the other player. Teardown abandons the focus and, if the volume ended up below the pre-duck level anyway, restores it (guard for a device that applies the write asynchronously). The focus request is built by an injected factory because AudioFocusRequest.Builder is an unmocked stub in plain JVM unit tests.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioPlaybackConfiguration
|
||||
import android.os.Build
|
||||
@@ -25,6 +27,7 @@ class MediaControl @Inject constructor(
|
||||
private val audioManager: AudioManager,
|
||||
private val timeSource: TimeSource,
|
||||
@AudioCallbackHandler private val audioCallbackHandler: Handler,
|
||||
private val duckFocusRequestFactory: DuckFocusRequestFactory,
|
||||
) {
|
||||
/**
|
||||
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
|
||||
@@ -47,6 +50,11 @@ class MediaControl @Inject constructor(
|
||||
*/
|
||||
private val dispatchLock = Mutex()
|
||||
|
||||
/** The granted ducking focus request, or `null` when we don't hold focus. Guarded by `this`. */
|
||||
private var duckFocusRequest: AudioFocusRequest? = null
|
||||
|
||||
private val duckFocusListener = AudioManager.OnAudioFocusChangeListener { change -> onDuckFocusChanged(change) }
|
||||
|
||||
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
|
||||
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
|
||||
// The edge is derived from this delivery's own snapshot, not a live isMusicActive read:
|
||||
@@ -263,24 +271,25 @@ class MediaControl @Inject constructor(
|
||||
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
/**
|
||||
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
|
||||
* the prior + the volume actually applied, so the caller can later restore it and detect whether
|
||||
* the user changed the volume in the meantime.
|
||||
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and classifies
|
||||
* what actually happened, so the caller can restore the prior level later, fall back to audio
|
||||
* focus, or do nothing at all.
|
||||
*
|
||||
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, the computed
|
||||
* target wouldn't actually lower the volume, or the write didn't land. No
|
||||
* Returns [DuckOutcome.Skipped] when nothing is playing, the device has fixed volume, the computed
|
||||
* target wouldn't actually lower the volume, the level came back higher, or the write was denied.
|
||||
* Returns [DuckOutcome.Unchanged] when the write was accepted but the level did not drop. No
|
||||
* [AudioManager.FLAG_SHOW_UI] — this fires on a frequent push event and the volume panel
|
||||
* flashing would be noisy. The applied target is read back from the system because Bluetooth
|
||||
* absolute-volume routes can quantize the requested value.
|
||||
*/
|
||||
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
|
||||
fun duckMusicVolume(reductionPercent: Int): DuckOutcome {
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
|
||||
return null
|
||||
return DuckOutcome.Skipped
|
||||
}
|
||||
if (audioManager.isVolumeFixed) {
|
||||
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
|
||||
return null
|
||||
return DuckOutcome.Skipped
|
||||
}
|
||||
val percent = reductionPercent.coerceIn(0, 100)
|
||||
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
@@ -293,30 +302,45 @@ class MediaControl @Inject constructor(
|
||||
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
|
||||
if (target >= prior) {
|
||||
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
|
||||
return null
|
||||
return DuckOutcome.Skipped
|
||||
}
|
||||
return try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (applied >= prior) {
|
||||
// No attenuation happened. Either the write was accepted and dropped (ColorOS 16
|
||||
// does this while the app is in the background: no exception, volume untouched),
|
||||
// the route quantized the target back up to where it started, or the user raised
|
||||
// the volume in between. Reporting a duck would have the caller track a session
|
||||
// that never attenuated anything, and later "restore" a level it never left.
|
||||
log(TAG, WARN) {
|
||||
"duckMusicVolume($percent%): volume did not decrease, $prior -> $applied " +
|
||||
"(requested $target, min=$min, max=$max)"
|
||||
when {
|
||||
applied > prior -> {
|
||||
// The level came back HIGHER than we found it: the user raised the volume between
|
||||
// the two reads. That is not the ROM refusing the write, so it must not read as
|
||||
// one — a fallback here would fire on a device whose volume writes work fine.
|
||||
log(TAG, WARN) {
|
||||
"duckMusicVolume($percent%): volume increased, $prior -> $applied " +
|
||||
"(requested $target, min=$min, max=$max)"
|
||||
}
|
||||
DuckOutcome.Skipped
|
||||
}
|
||||
|
||||
applied == prior -> {
|
||||
// No attenuation happened even though the write was accepted: ColorOS 16 does
|
||||
// this while the app is in the background (no exception, volume untouched), and
|
||||
// a route may quantize the target back up to where it started. Reporting a duck
|
||||
// would have the caller track a session that never attenuated anything, and
|
||||
// later "restore" a level it never left.
|
||||
log(TAG, WARN) {
|
||||
"duckMusicVolume($percent%): volume did not decrease, $prior -> $applied " +
|
||||
"(requested $target, min=$min, max=$max)"
|
||||
}
|
||||
DuckOutcome.Unchanged(priorVolume = prior)
|
||||
}
|
||||
|
||||
else -> {
|
||||
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||
DuckOutcome.Ducked(priorVolume = prior, appliedVolume = applied)
|
||||
}
|
||||
null
|
||||
} else {
|
||||
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||
VolumeDuck(priorVolume = prior, appliedVolume = applied)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
|
||||
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||
null
|
||||
DuckOutcome.Skipped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,11 +355,73 @@ class MediaControl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
|
||||
data class VolumeDuck(
|
||||
val priorVolume: Int,
|
||||
val appliedVolume: Int,
|
||||
)
|
||||
/**
|
||||
* Requests transient ducking audio focus so the framework attenuates the other player for us.
|
||||
* Fallback for devices where the volume write is accepted but ignored ([DuckOutcome.Unchanged]).
|
||||
*
|
||||
* Idempotent — returns `true` when focus is held, whether this call obtained it or an earlier one
|
||||
* did. A grant only describes the instant of the request, so the held state is dropped again when
|
||||
* the system takes focus away permanently.
|
||||
*/
|
||||
@Synchronized
|
||||
fun requestDuckFocus(): Boolean {
|
||||
if (duckFocusRequest != null) {
|
||||
log(TAG, INFO) { "requestDuckFocus(): already held" }
|
||||
return true
|
||||
}
|
||||
val request = duckFocusRequestFactory.create(duckFocusListener)
|
||||
val result = audioManager.requestAudioFocus(request)
|
||||
return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
duckFocusRequest = request
|
||||
log(TAG, INFO) { "requestDuckFocus(): granted" }
|
||||
true
|
||||
} else {
|
||||
log(TAG, INFO) { "requestDuckFocus(): denied ($result)" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases the ducking focus taken by [requestDuckFocus]. No-op when we don't hold it. */
|
||||
@Synchronized
|
||||
fun abandonDuckFocus() {
|
||||
val request = duckFocusRequest ?: return
|
||||
duckFocusRequest = null
|
||||
audioManager.abandonAudioFocusRequest(request)
|
||||
log(TAG, INFO) { "abandonDuckFocus(): focus released" }
|
||||
}
|
||||
|
||||
val isDuckFocusHeld: Boolean
|
||||
@Synchronized get() = duckFocusRequest != null
|
||||
|
||||
@Synchronized
|
||||
private fun onDuckFocusChanged(change: Int) {
|
||||
// Only a PERMANENT loss ends our request. AUDIOFOCUS_LOSS_TRANSIENT leaves it in place, so
|
||||
// clearing on that would forget a request the system still tracks and let the next
|
||||
// abandon/re-request pair fight the other app over a temporary interruption.
|
||||
if (change != AudioManager.AUDIOFOCUS_LOSS) return
|
||||
log(TAG, INFO) { "Duck focus permanently lost" }
|
||||
duckFocusRequest = null
|
||||
}
|
||||
|
||||
/** What a [duckMusicVolume] call actually achieved. */
|
||||
sealed interface DuckOutcome {
|
||||
/** The level dropped: [priorVolume] is what to restore, [appliedVolume] what landed. */
|
||||
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : DuckOutcome
|
||||
|
||||
/** The write was accepted but the level did not move — the read-back proves no attenuation. */
|
||||
data class Unchanged(val priorVolume: Int) : DuckOutcome
|
||||
|
||||
/** Nothing was attempted, or the result is nothing for the caller to act on. */
|
||||
data object Skipped : DuckOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam for building the ducking focus request: [AudioFocusRequest.Builder] and
|
||||
* [AudioAttributes.Builder] are unmocked stubs that throw in this module's plain JVM unit tests.
|
||||
*/
|
||||
fun interface DuckFocusRequestFactory {
|
||||
fun create(listener: AudioManager.OnAudioFocusChangeListener): AudioFocusRequest
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MediaControl")
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
@@ -11,6 +13,7 @@ import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -43,6 +46,21 @@ class AndroidModule {
|
||||
fun audioCallbackHandler(): Handler =
|
||||
Handler(HandlerThread("CAPod-MediaControl").apply { start() }.looper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun duckFocusRequestFactory(): MediaControl.DuckFocusRequestFactory =
|
||||
MediaControl.DuckFocusRequestFactory { listener ->
|
||||
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
.setOnAudioFocusChangeListener(listener)
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Qualifier
|
||||
|
||||
+72
-14
@@ -82,6 +82,14 @@ class ConversationReaction @Inject constructor(
|
||||
private sealed interface Kind {
|
||||
data object Paused : Kind
|
||||
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
|
||||
|
||||
/**
|
||||
* Fallback for a volume write the system accepted but ignored: we hold
|
||||
* `AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK` and let the framework attenuate the other player.
|
||||
* Named for what is actually true — a granted request does not prove anything was
|
||||
* attenuated. [priorVolume] is only kept for the late-write guard at teardown.
|
||||
*/
|
||||
data class FocusHeld(val priorVolume: Int) : Kind
|
||||
}
|
||||
|
||||
private data class Active(
|
||||
@@ -155,6 +163,13 @@ class ConversationReaction @Inject constructor(
|
||||
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
||||
// A START also cancels a pending wind-down fuse / settle: the wearer is speaking again.
|
||||
armTimer(current, TimerPhase.STALE_BACKSTOP)
|
||||
// Exception: a focus session that lost focus permanently mid-conversation has to be
|
||||
// re-requested here. Every later START is treated as a keep-alive, so this is the
|
||||
// only place that recovery can happen.
|
||||
if (current.kind is Kind.FocusHeld && !mediaControl.isDuckFocusHeld) {
|
||||
val regained = mediaControl.requestDuckFocus()
|
||||
log(TAG, INFO) { "START from $address — duck focus was lost, re-requested (granted=$regained)" }
|
||||
}
|
||||
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
||||
return
|
||||
}
|
||||
@@ -185,20 +200,47 @@ class ConversationReaction @Inject constructor(
|
||||
ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION,
|
||||
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION,
|
||||
)
|
||||
val duck = mediaControl.duckMusicVolume(reduction)
|
||||
if (duck != null) {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.Ducked(duck.priorVolume, duck.appliedVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
when (val outcome = mediaControl.duckMusicVolume(reduction)) {
|
||||
is MediaControl.DuckOutcome.Ducked -> {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.Ducked(outcome.priorVolume, outcome.appliedVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) {
|
||||
"START on $address → ducked volume ${outcome.priorVolume}→${outcome.appliedVolume}"
|
||||
}
|
||||
}
|
||||
|
||||
is MediaControl.DuckOutcome.Unchanged -> {
|
||||
// The write was accepted but the level never moved (ColorOS 16 refuses a
|
||||
// backgrounded app's write). Ask the framework to duck the other player
|
||||
// instead. A grant does not prove anything was attenuated: a player whose
|
||||
// content is marked speech (podcasts) may pause instead of duck, and a
|
||||
// player that never requested focus need not be attenuated at all.
|
||||
if (mediaControl.requestDuckFocus()) {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.FocusHeld(outcome.priorVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) { "START on $address → volume write ignored, holding duck focus" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
}
|
||||
}
|
||||
|
||||
MediaControl.DuckOutcome.Skipped -> {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +343,8 @@ class ConversationReaction @Inject constructor(
|
||||
}
|
||||
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
|
||||
is Kind.FocusHeld -> revertFocus(kind, reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +387,7 @@ class ConversationReaction @Inject constructor(
|
||||
private fun revert(record: Active, reason: String) {
|
||||
when (val kind = record.kind) {
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
is Kind.FocusHeld -> revertFocus(kind, reason)
|
||||
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
|
||||
}
|
||||
}
|
||||
@@ -358,6 +403,19 @@ class ConversationReaction @Inject constructor(
|
||||
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||
}
|
||||
|
||||
private fun revertFocus(kind: Kind.FocusHeld, reason: String) {
|
||||
mediaControl.abandonDuckFocus()
|
||||
log(TAG, INFO) { "Released duck focus ($reason)" }
|
||||
// Late-write guard: a device may apply the volume write asynchronously. The read-back showed
|
||||
// equality, so we took the focus path — if the write landed afterwards, teardown would leave
|
||||
// the stream index permanently lowered. Restore ONLY when the level is below what we found.
|
||||
val current = mediaControl.currentMusicVolume()
|
||||
if (current < kind.priorVolume) {
|
||||
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (late write landed at $current, $reason)" }
|
||||
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||
}
|
||||
}
|
||||
|
||||
/** Must be called under [mutex]. Clears the active slot and cancels its pending timer. */
|
||||
private fun clearActive() {
|
||||
disengageJob?.cancel()
|
||||
|
||||
Reference in New Issue
Block a user