mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
@@ -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,
|
||||
|
||||
+75
@@ -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(
|
||||
|
||||
+31
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+42
@@ -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
|
||||
|
||||
+275
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user