feat(reaction): Add Conversational Awareness media reaction

Reacts to the AirPods speaking-detection event (AAP 0x4B): lowers media volume by a configurable amount or pauses playback when you start talking, and reverts when you stop. Per-profile, Pro-gated, opt-in (default off).

Decodes the speaking status from the last payload byte ({1,2}=start, {6,8,9}=stop, else keep-alive); engage/disengage with a frame-idle stale timeout to recover a dropped stop, plus disconnect and service-stop cleanup.
This commit is contained in:
darken
2026-05-25 23:55:55 +02:00
committed by Matthias Urhahn
parent 78790f73df
commit dbbfa75429
20 changed files with 1010 additions and 12 deletions
@@ -2,8 +2,10 @@ package eu.darken.capod.common
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay
@@ -157,6 +159,70 @@ class MediaControl @Inject constructor(
)
}
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
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.
*
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
* target wouldn't actually lower the volume. 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? {
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
return null
}
if (audioManager.isVolumeFixed) {
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
return null
}
val percent = reductionPercent.coerceIn(0, 100)
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
} else {
0
}
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
if (target >= prior) {
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
return null
}
return try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
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
}
}
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
fun restoreMusicVolume(priorVolume: Int) {
if (audioManager.isVolumeFixed) return
try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
} catch (e: SecurityException) {
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
}
}
/** 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,
)
companion object {
private val TAG = logTag("MediaControl")
}
@@ -64,6 +64,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -148,6 +149,8 @@ fun DeviceSettingsScreenHost(
onNavigateUp = { vm.navUp() },
onAncModeChange = { vm.setAncMode(it) },
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
onConversationActionChange = { vm.setConversationAction(it) },
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
onToneVolumeChange = { vm.setToneVolume(it) },
@@ -186,6 +189,8 @@ fun DeviceSettingsScreen(
onNavigateUp: () -> Unit,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {},
@@ -344,6 +349,8 @@ fun DeviceSettingsScreen(
onStartMusicOnWearChange = onStartMusicOnWearChange,
onOnePodModeChange = onOnePodModeChange,
onConversationalAwarenessChange = onConversationalAwarenessChange,
onConversationActionChange = onConversationActionChange,
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
onSleepDetectionChange = onSleepDetectionChange,
onAutoConnectChange = onAutoConnectChange,
onAutoConnectConditionChange = onAutoConnectConditionChange,
@@ -31,6 +31,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemAction
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -437,6 +438,22 @@ class DeviceSettingsViewModel @Inject constructor(
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
}
fun setConversationAction(action: ConversationAction) = launch {
log(TAG, INFO) { "setConversationAction($action)" }
// The action picker is only shown while Conversation Awareness is already enabled (the pod
// emits no speaking frames otherwise), so no need to auto-enable it here.
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(conversationAction = action) }
}
fun setConversationVolumeReduction(percent: Int) = launch {
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
}
fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" }
@@ -4,12 +4,14 @@ import android.os.Build
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.RecordVoiceOver
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -29,15 +31,20 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
internal fun ReactionsCard(
@@ -49,6 +56,8 @@ internal fun ReactionsCard(
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
@@ -60,6 +69,7 @@ internal fun ReactionsCard(
val enabled = device.isAapReady
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showConversationActionDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
@@ -125,20 +135,45 @@ internal fun ReactionsCard(
}
if (device.isAapConnected) {
val convAwareness = device.conversationalAwareness
val hasAnyAapReaction =
(features.hasConversationAwareness && convAwareness != null) ||
features.hasSleepDetection
if (features.hasConversationAwareness && convAwareness != null) {
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
if (showConversationAwareness) {
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
checked = convAwareness?.enabled == true,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
// speaking frames otherwise), so only surface it once CA is actually enabled.
if (convAwareness?.enabled == true) {
SettingsBaseItem(
icon = Icons.TwoTone.RecordVoiceOver,
title = stringResource(R.string.settings_conversation_action_label),
subtitle = stringResource(reactions.conversationAction.labelRes),
onClick = { showConversationActionDialog = true },
enabled = enabled,
requiresUpgrade = !isPro,
)
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
SettingsSliderItem(
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
title = stringResource(R.string.settings_conversation_volume_reduction_label),
value = reactions.conversationVolumeReduction.toFloat(),
onValueChange = { onConversationVolumeReductionChange(it.toInt()) },
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
enabled = enabled,
valueLabel = { "${it.toInt()}%" },
)
}
}
}
if (features.hasSleepDetection) {
if (showConversationAwareness) ReactionsDivider()
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
@@ -213,6 +248,17 @@ internal fun ReactionsCard(
}
}
if (showConversationActionDialog) {
ConversationActionDialog(
current = reactions.conversationAction,
onSelect = {
onConversationActionChange(it)
showConversationActionDialog = false
},
onDismiss = { showConversationActionDialog = false },
)
}
if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
@@ -0,0 +1,75 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
internal fun ConversationActionDialog(
current: ConversationAction,
onSelect: (ConversationAction) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.settings_conversation_action_label)) },
text = {
Column(Modifier.selectableGroup()) {
ConversationAction.entries.forEach { action ->
val isSelected = action == current
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onSelect(action) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = stringResource(action.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
@Preview2
@Composable
private fun ConversationActionDialogPreview() = PreviewWrapper {
ConversationActionDialog(
current = ConversationAction.LOWER_VOLUME,
onSelect = {},
onDismiss = {},
)
}
@@ -42,6 +42,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.core.conversation.ConversationReaction
import eu.darken.capod.reaction.core.sleep.SleepReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
@@ -78,6 +79,7 @@ class MonitorService : Service() {
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var sleepReaction: SleepReaction
@Inject lateinit var conversationReaction: ConversationReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager
@@ -331,6 +333,11 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
conversationReaction.monitor()
.setupCommonEventHandlers(TAG) { "conversationReaction" }
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -73,6 +74,17 @@ class AapConnectionManager @Inject constructor(
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
/**
* Emits when a connected device reports a Conversational Awareness speaking transition
* (START/STOP, AAP command 0x4B). Paired with the origin address so the conversation
* reaction can gate on the primary device. Only the known 0x01/0x04 markers reach here —
* the engine drops unknown raw values (see [AapSessionEngine]).
*/
private val _conversationalAwarenessEvents =
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
val conversationalAwarenessEvents: SharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>> =
_conversationalAwarenessEvents.asSharedFlow()
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
@@ -140,6 +152,13 @@ class AapConnectionManager @Inject constructor(
}
}
// Forward conversational-awareness speaking transitions from this connection (child coroutine).
launch {
connection.conversationalAwarenessEvents.collect { event ->
_conversationalAwarenessEvents.tryEmit(address to event)
}
}
// Forward OFF-rejection events from this connection (child coroutine)
launch {
connection.offRejected.collect {
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CompletableDeferred
@@ -53,6 +54,7 @@ internal class AapConnection(
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> get() = engine.conversationalAwarenessEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -54,6 +55,11 @@ internal class AapSessionEngine(
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
private val _conversationalAwarenessEvents =
MutableSharedFlow<ConversationAwarenessEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> =
_conversationalAwarenessEvents.asSharedFlow()
private val _offRejected =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
@@ -301,6 +307,13 @@ internal class AapSessionEngine(
)
}
}
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
// conversation reaction: START / STOP / HOLD (keep-alive). The decoder already dropped
// malformed frames (rawValue stays null only in that case). The raw payload remains logged.
if (value is AapSetting.ConversationalAwarenessState) {
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
}
}
private fun handleTimerFired(key: EngineTimerKey) {
@@ -99,9 +99,17 @@ sealed class AapSetting {
val level: Int,
) : AapSetting()
/** Push-only from device — reports speaking detection state (command 0x4B). */
/**
* Push-only from device — reports speaking detection state (command 0x4B).
*
* [rawValue] is the first payload byte, preserved so consumers can distinguish the known
* speaking-start (0x01) and speaking-stop (0x04) markers from other values (e.g. 0x00, seen
* in captures with unclear meaning). [speaking] collapses everything non-0x01 to false for
* storage/UI; reaction logic must gate on [rawValue] to avoid acting on unknown values.
*/
data class ConversationalAwarenessState(
val speaking: Boolean,
val rawValue: Int? = null,
) : AapSetting()
data class MicrophoneMode(
@@ -0,0 +1,31 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
*
* Status-byte mapping (confirmed against a live AirPods Pro 3 capture and the librepods project):
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
* - `6`, `8`, `9` → [STOP] (wearer stopped → disengage)
* - any other value (`3`, `4`, `0x0B`, …) → [HOLD] (intermediate "still in session" frame; the pod
* streams these while speaking — they act as a keep-alive and must NOT disengage the reaction)
*
* The pod emits no `0x4B` frames at all during silence, so [HOLD] frames ceasing is itself a
* reliable "speaking ended" signal (used as a stale-timeout fallback for a missed [STOP]).
*/
enum class ConversationAwarenessEvent {
START,
HOLD,
STOP,
;
companion object {
val SPEAKING_STATUSES = setOf(1, 2)
val STOPPED_STATUSES = setOf(6, 8, 9)
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
in SPEAKING_STATUSES -> START
in STOPPED_STATUSES -> STOP
else -> HOLD
}
}
}
@@ -183,12 +183,24 @@ class DefaultAapDeviceProfile(
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
}
// Conversation Awareness State is a separate command type (push-only)
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
// are seen: a legacy single byte, and the 4-byte `02 00 01 <status>` form on Pro 3 fw 81.x.
// The status (speaking marker) is the last byte in both — but we validate the shape rather
// than blindly taking payload.last(), so a truncated/garbled frame can't be misread as a
// speaking transition. Unrecognised shapes return null → logged as an unhandled message.
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
if (message.payload.isEmpty()) return null
val value = message.payload[0].toInt() and 0xFF
val speaking = value == 0x01
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
val p = message.payload
val status = when {
p.size == 1 -> p[0].toInt() and 0xFF
p.size == 4 &&
p[0] == 0x02.toByte() && p[1] == 0x00.toByte() && p[2] == 0x01.toByte() ->
p[3].toInt() and 0xFF
else -> return null
}
// speaking = the "started/active speaking" statuses (1,2); see ConversationAwarenessEvent.
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES
return AapSetting.ConversationalAwarenessState::class to
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
}
if (message.commandType != AapMessageType.CONTROL.value) return null
@@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
@@ -31,6 +32,8 @@ data class AppleDeviceProfile(
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
/**
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
@@ -56,6 +59,8 @@ data class AppleDeviceProfile(
autoConnectCondition = autoConnectCondition,
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
showPopUpOnConnection = showPopUpOnConnection,
conversationAction = conversationAction,
conversationVolumeReduction = conversationVolumeReduction,
)
override fun toString(): String = "AppleDeviceProfile(" +
@@ -69,6 +74,8 @@ data class AppleDeviceProfile(
"autoConnectCondition=$autoConnectCondition, " +
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
"showPopUpOnConnection=$showPopUpOnConnection, " +
"conversationAction=$conversationAction, " +
"conversationVolumeReduction=$conversationVolumeReduction, " +
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
"stemActions=$stemActions" +
@@ -1,6 +1,7 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
data class ReactionConfig(
val autoPause: Boolean = false,
@@ -11,7 +12,18 @@ data class ReactionConfig(
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
val showPopUpOnCaseOpen: Boolean = false,
val showPopUpOnConnection: Boolean = false,
)
val conversationAction: ConversationAction = ConversationAction.NOTHING,
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
) {
companion object {
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
/** 100% lowers media volume all the way to 0 — i.e. full mute while speaking. */
const val MAX_CONVERSATION_VOLUME_REDUCTION = 100
}
}
interface HasReactionConfig {
val reactionConfig: ReactionConfig
@@ -0,0 +1,28 @@
package eu.darken.capod.reaction.core.conversation
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* What CAPod does when the pods report that the wearer started speaking (Conversational
* Awareness). On Android the firmware does not duck audio on its own, so CAPod performs the
* reaction itself. The action is reverted when speaking stops.
*
* Extend by adding entries — the persisted [SerialName] strings are stable identifiers.
*/
@Serializable
enum class ConversationAction(
val identifier: String,
@StringRes val labelRes: Int,
) {
@SerialName("conversation.action.nothing")
NOTHING("conversation.action.nothing", R.string.settings_conversation_action_nothing_label),
@SerialName("conversation.action.lower_volume")
LOWER_VOLUME("conversation.action.lower_volume", R.string.settings_conversation_action_lower_volume_label),
@SerialName("conversation.action.pause")
PAUSE("conversation.action.pause", R.string.settings_conversation_action_pause_label),
}
@@ -0,0 +1,281 @@
package eu.darken.capod.reaction.core.conversation
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.profiles.core.ReactionConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
*
* The pod streams classified frames while you talk ([ConversationAwarenessEvent.START] at onset,
* then [ConversationAwarenessEvent.HOLD] keep-alives) and an explicit [ConversationAwarenessEvent.STOP]
* when you finish; no frames at all during silence. Disengage happens on STOP, or — if a STOP is
* dropped — via a stale timeout that fires once frames stop arriving. Each frame (START or HOLD)
* resets that timer, so a long conversation stays engaged.
*
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
* Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped
* session never strands the user at low volume.
*/
@Singleton
class ConversationReaction @Inject constructor(
private val aapManager: AapConnectionManager,
private val deviceMonitor: DeviceMonitor,
private val mediaControl: MediaControl,
@AppScope private val appScope: CoroutineScope,
private val timeSource: TimeSource,
) {
private sealed interface Kind {
data object Paused : Kind
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
}
private data class Active(
val id: Long,
val owner: BluetoothAddress,
val kind: Kind,
val at: Long,
)
private val mutex = Mutex()
private var active: Active? = null
private var staleJob: Job? = null
private var idCounter = 0L
fun monitor(): Flow<Unit> = merge(
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
// Reverts a stranded duck when the owning device leaves the AAP state map for any reason
// (intentional or not) — disconnectEvents only fires for unintentional drops.
aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) },
)
.map { }
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
.onCompletion { withContext(NonCancellable) { onMonitorCompleted() } }
.setupCommonEventHandlers(TAG) { "conversationReaction" }
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
ConversationAwarenessEvent.START -> onSpeakingStart(address)
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
}
private suspend fun onSpeakingStart(address: BluetoothAddress) {
val primary = deviceMonitor.primaryDevice().first()
if (primary?.address != address) {
log(TAG) { "START from $address ignored — not primary device (primary=${primary?.address})" }
return
}
val action = primary.reactions.conversationAction
if (action == ConversationAction.NOTHING) return
mutex.withLock {
val current = active
if (current != null && current.owner == address) {
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
restartStaleTimer(current)
log(TAG) { "START from $address — already active ($action), keep-alive" }
return
}
// A different device started speaking while we were active — undo the old one first.
if (current != null) revert(current, "superseded by $address")
when (action) {
ConversationAction.PAUSE -> {
val paused = mediaControl.sendPause(rememberForResume = false)
if (paused) {
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
active = record
restartStaleTimer(record)
log(TAG, INFO) { "START on $address → paused media" }
} else {
active = null
log(TAG) { "START on $address → nothing playing, no pause" }
}
}
ConversationAction.LOWER_VOLUME -> {
val reduction = primary.reactions.conversationVolumeReduction
.coerceIn(
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
restartStaleTimer(record)
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}${duck.appliedVolume}" }
} else {
active = null
log(TAG) { "START on $address → duck no-op" }
}
}
ConversationAction.NOTHING -> Unit
}
}
}
/** Intermediate keep-alive frame: the wearer is still speaking — refresh the stale timer. */
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
if (current.owner != address) return
restartStaleTimer(current)
}
private suspend fun onSpeakingStop(address: BluetoothAddress) {
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
val current = active ?: return
if (current.owner != address) {
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
return
}
clearActive()
disengage(current, primary, "STOP on $address")
}
}
/** Graceful disengage (STOP event or stale timeout). Must be called under [mutex]. */
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String) {
when (val kind = record.kind) {
is Kind.Paused -> {
// Resume the pause WE caused, regardless of the current action setting. Gating on
// "action still == PAUSE" would strand media paused if the user switched the action
// (or set NOTHING) mid-conversation — undoing our own side effect is the least
// surprising behaviour. The remaining guards are about real device/playback state.
val age = timeSource.elapsedRealtime() - record.at
when {
age > PAUSE_RESUME_WINDOW_MS ->
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
primary?.address != record.owner ->
log(TAG) { "$reason — resume skipped (primary switched)" }
primary.isBeingWorn == false ->
log(TAG) { "$reason — resume skipped (not worn)" }
mediaControl.isPlaying ->
log(TAG) { "$reason — resume skipped (already playing)" }
else -> {
mediaControl.sendPlay()
log(TAG, INFO) { "$reason → resumed media" }
}
}
}
is Kind.Ducked -> revertDuck(kind, reason)
}
}
private suspend fun onActiveDevicesChanged(addresses: Set<BluetoothAddress>) = mutex.withLock {
val current = active ?: return
if (current.owner !in addresses) {
clearActive()
revert(current, "owner ${current.owner} gone")
}
}
private suspend fun onMonitorCompleted() = mutex.withLock {
val current = active ?: return
clearActive()
revert(current, "monitor completed")
}
/** Forced revert (disconnect / supersede / shutdown): undo volume ducks; leave pauses as-is. */
private fun revert(record: Active, reason: String) {
when (val kind = record.kind) {
is Kind.Ducked -> revertDuck(kind, reason)
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
}
}
private fun revertDuck(kind: Kind.Ducked, reason: String) {
// Restore to the pre-duck volume unconditionally — we do NOT gate on
// currentMusicVolume() == appliedVolume. Such a guard skips the restore whenever anything
// else moved the volume meanwhile (e.g. a concurrent volume-manager app), which lets the
// baseline ratchet down across conversations. Restoring deterministically to the level we
// saved at engage avoids that; the trade-off is overriding a deliberate mid-conversation
// volume change (rare). Matches librepods' unconditional restore.
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (ducked to ${kind.appliedVolume}, now ${mediaControl.currentMusicVolume()}, $reason)" }
mediaControl.restoreMusicVolume(kind.priorVolume)
}
/** Must be called under [mutex]. Clears the active slot and cancels its stale timer. */
private fun clearActive() {
staleJob?.cancel()
staleJob = null
active = null
}
/**
* Must be called under [mutex]. (Re)starts the stale timer for [record]. Reset on every frame
* (START/HOLD); if no frame arrives for [STALE_TIMEOUT_MS] the speaking session is treated as
* ended (recovers from a dropped STOP). Identity-checked so a late timer can't disengage a newer
* session.
*/
private fun restartStaleTimer(record: Active) {
staleJob?.cancel()
staleJob = appScope.launch {
delay(STALE_TIMEOUT_MS)
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
if (active?.id == record.id) {
val current = active!!
clearActive()
disengage(current, primary, "stale timeout (frames ceased)")
}
}
}
}
private fun nextId(): Long = ++idCounter
companion object {
private val TAG = logTag("Reaction", "Conversation")
/**
* Disengage if no `0x4B` frame arrives for this long while engaged. The pod streams frames
* (~1/s) throughout active speech and none during silence, so this both recovers a dropped
* STOP and bounds how long a duck can linger. Long enough not to disengage mid-conversation
* between frames.
*/
private const val STALE_TIMEOUT_MS = 12L * 1000L
/** A disengage older than this no longer auto-resumes a pause — unexpected late playback is worse. */
private const val PAUSE_RESUME_WINDOW_MS = 2L * 60L * 1000L
}
}
+5
View File
@@ -278,6 +278,11 @@
<string name="anc_mode_transparency">Transparency</string>
<string name="anc_mode_adaptive">Adaptive</string>
<string name="conversation_awareness_label">Conversation Awareness</string>
<string name="settings_conversation_action_label">When you start speaking</string>
<string name="settings_conversation_action_nothing_label">Do nothing</string>
<string name="settings_conversation_action_lower_volume_label">Lower volume</string>
<string name="settings_conversation_action_pause_label">Pause media</string>
<string name="settings_conversation_volume_reduction_label">Volume reduction</string>
<string name="signal_badge_ble_cd">BLE scan active</string>
<string name="signal_badge_key_irk_cd">Identity verified</string>
<string name="signal_badge_key_encrypted_cd">Encrypted connection</string>
@@ -148,6 +148,48 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
@Test fun `decode unknown value returns null`() { profile.decodeSetting(settingsMessage(0x28, 0x00)).shouldBeNull() }
}
// ── Conversational Awareness State (push-only 0x4B) ──────
@Nested
inner class ConversationalAwarenessStateTests {
@Test fun `4-byte frame status 1 is speaking`() {
decodeSetting<AapSetting.ConversationalAwarenessState>("04 00 04 00 4B 00 02 00 01 01").let {
it.speaking shouldBe true
it.rawValue shouldBe 1
}
}
@Test fun `4-byte frame status 2 is speaking`() {
decodeSetting<AapSetting.ConversationalAwarenessState>("04 00 04 00 4B 00 02 00 01 02").speaking shouldBe true
}
@Test fun `4-byte frame stop status 9 is not speaking`() {
decodeSetting<AapSetting.ConversationalAwarenessState>("04 00 04 00 4B 00 02 00 01 09").let {
it.speaking shouldBe false
it.rawValue shouldBe 9
}
}
@Test fun `4-byte frame intermediate status 4 is not speaking`() {
decodeSetting<AapSetting.ConversationalAwarenessState>("04 00 04 00 4B 00 02 00 01 04").speaking shouldBe false
}
@Test fun `legacy single-byte frame status 0 is not speaking`() {
decodeSetting<AapSetting.ConversationalAwarenessState>("04 00 04 00 4B 00 00").let {
it.speaking shouldBe false
it.rawValue shouldBe 0
}
}
@Test fun `truncated 3-byte payload returns null`() {
profile.decodeSetting(aapMessage("04 00 04 00 4B 00 02 00 01")).shouldBeNull()
}
@Test fun `invalid 4-byte prefix returns null`() {
profile.decodeSetting(aapMessage("04 00 04 00 4B 00 02 00 02 01")).shouldBeNull()
}
}
// ── Press Speed ──────────────────────────────────────────
@Nested
@@ -8,6 +8,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldBeNull
@@ -497,6 +498,50 @@ class AapSessionEngineTest : BaseTest() {
}
}
// ── Conversational Awareness ─────────────────────────────
@Nested
inner class ConversationalAwarenessTests {
private fun caProfile(status: Int) = mockProfile {
every { decodeSetting(any()) } returns settingPair(
AapSetting.ConversationalAwarenessState(
speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES,
rawValue = status,
),
)
}
private suspend fun TestScope.firstEventFor(status: Int): ConversationAwarenessEvent {
val engine = AapSessionEngine(caProfile(status), timeSource)
engine.start(this)
var emitted: ConversationAwarenessEvent? = null
val job = launch { emitted = engine.conversationalAwarenessEvents.first() }
engine.processMessage(dummyMessage(commandType = 0x004B))
job.join()
return emitted!!
}
@Test
fun `status 1 and 2 emit START`() = runTest(UnconfinedTestDispatcher()) {
firstEventFor(1) shouldBe ConversationAwarenessEvent.START
firstEventFor(2) shouldBe ConversationAwarenessEvent.START
}
@Test
fun `status 6, 8, 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) {
firstEventFor(6) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(8) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(9) shouldBe ConversationAwarenessEvent.STOP
}
@Test
fun `intermediate status emits HOLD (keep-alive)`() = runTest(UnconfinedTestDispatcher()) {
firstEventFor(3) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0x0B) shouldBe ConversationAwarenessEvent.HOLD
}
}
// ── Inference ───────────────────────────────────────────
@Nested
@@ -0,0 +1,275 @@
package eu.darken.capod.reaction.core.conversation
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.profiles.core.ReactionConfig
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
class ConversationReactionTest : BaseTest() {
private val primaryAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private val otherAddress: BluetoothAddress = "11:22:33:44:55:66"
// Mirror of ConversationReaction.STALE_TIMEOUT_MS (private there).
private val staleTimeoutMs = 12_000L
private lateinit var eventsFlow: MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>
private lateinit var statesFlow: MutableStateFlow<Map<BluetoothAddress, AapPodState>>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var aapManager: AapConnectionManager
private lateinit var deviceMonitor: DeviceMonitor
private lateinit var mediaControl: MediaControl
private lateinit var timeSource: TestTimeSource
private fun mockPodDevice(
address: BluetoothAddress,
action: ConversationAction,
reduction: Int = 50,
worn: Boolean = true,
): PodDevice = mockk(relaxed = true) {
every { profileId } returns address
every { this@mockk.address } returns address
every { reactions } returns ReactionConfig(
conversationAction = action,
conversationVolumeReduction = reduction,
)
every { isBeingWorn } returns worn
}
@BeforeEach
fun setup() {
eventsFlow = MutableSharedFlow(extraBufferCapacity = 16)
statesFlow = MutableStateFlow(mapOf(primaryAddress to mockk(relaxed = true)))
devicesFlow = MutableStateFlow(listOf(mockPodDevice(primaryAddress, ConversationAction.LOWER_VOLUME)))
aapManager = mockk(relaxed = true) {
every { conversationalAwarenessEvents } returns eventsFlow
every { allStates } returns statesFlow
}
deviceMonitor = mockk(relaxed = true) {
every { devices } returns devicesFlow
}
mediaControl = mockk(relaxed = true) {
coEvery { sendPause(any()) } returns true
every { isPlaying } returns false
every { duckMusicVolume(any()) } returns MediaControl.VolumeDuck(priorVolume = 10, appliedVolume = 5)
every { currentMusicVolume() } returns 5
}
timeSource = TestTimeSource()
}
// appScope = the test scope so the stale timer runs on the controllable virtual clock.
private fun TestScope.launchReaction() = ConversationReaction(
aapManager = aapManager,
deviceMonitor = deviceMonitor,
mediaControl = mediaControl,
appScope = this,
timeSource = timeSource,
).monitor().launchIn(this)
private suspend fun TestScope.emit(address: BluetoothAddress, event: ConversationAwarenessEvent) {
eventsFlow.emit(address to event)
runCurrent()
}
@Test
fun `LOWER_VOLUME start ducks, stop restores`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
verify(exactly = 1) { mediaControl.duckMusicVolume(50) }
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
emit(primaryAddress, ConversationAwarenessEvent.STOP)
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
job.cancel()
}
@Test
fun `LOWER_VOLUME restores unconditionally even if the volume was moved meanwhile`() = runTest(UnconfinedTestDispatcher()) {
// Something else (e.g. another volume-manager app) moved the volume during the duck, so the
// current reading no longer matches what we applied — we must still restore to the saved prior.
every { mediaControl.currentMusicVolume() } returns 7
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.STOP)
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
job.cancel()
}
@Test
fun `LOWER_VOLUME owner leaving the AAP state map restores volume`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
statesFlow.value = emptyMap() // device disconnected before STOP arrived
runCurrent()
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
job.cancel()
}
@Test
fun `LOWER_VOLUME missed STOP restores via stale timeout`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
verify(exactly = 1) { mediaControl.duckMusicVolume(50) }
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
// No STOP arrives; frames cease. After the stale timeout, volume must be restored.
advanceTimeBy(staleTimeoutMs + 500)
runCurrent()
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
job.cancel()
}
@Test
fun `HOLD keep-alive resets the stale timer`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
advanceTimeBy(8_000)
runCurrent()
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // resets the timer
advanceTimeBy(8_000) // 8s since the HOLD — still within the window
runCurrent()
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
advanceTimeBy(5_000) // now >12s since the last frame
runCurrent()
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
job.cancel()
}
@Test
fun `HOLD without a prior START does not engage`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.HOLD)
verify(exactly = 0) { mediaControl.duckMusicVolume(any()) }
job.cancel()
}
@Test
fun `PAUSE start pauses, stop resumes when worn and idle`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
coVerify(exactly = 1) { mediaControl.sendPause(false) }
emit(primaryAddress, ConversationAwarenessEvent.STOP)
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `PAUSE stop does not resume when pods not worn`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE, worn = false))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.STOP)
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `PAUSE stop does not resume when something is already playing`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
every { mediaControl.isPlaying } returns true // user/app restarted playback during the talk
emit(primaryAddress, ConversationAwarenessEvent.STOP)
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `PAUSE resumes on stop even if the action was switched away mid-talk`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
coVerify(exactly = 1) { mediaControl.sendPause(false) }
// User changes the action mid-conversation; we must still undo the pause WE caused.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.LOWER_VOLUME))
emit(primaryAddress, ConversationAwarenessEvent.STOP)
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `NOTHING action ignores speaking`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.NOTHING))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
verify(exactly = 0) { mediaControl.duckMusicVolume(any()) }
coVerify(exactly = 0) { mediaControl.sendPause(any()) }
job.cancel()
}
@Test
fun `non-primary device start is ignored`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(otherAddress, ConversationAwarenessEvent.START)
verify(exactly = 0) { mediaControl.duckMusicVolume(any()) }
job.cancel()
}
@Test
fun `stop without a prior start is a no-op`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.STOP)
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `duplicate start does not duck twice`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.START) // status 1 then 2 both classify as START
verify(exactly = 1) { mediaControl.duckMusicVolume(any()) }
job.cancel()
}
}