fix(reaction): Treat CA status 5 as speech-resume, not a stop

This commit is contained in:
darken
2026-06-18 12:07:19 +02:00
committed by Matthias Urhahn
parent 352e020b54
commit 246b96bfb4
7 changed files with 187 additions and 48 deletions
@@ -75,10 +75,10 @@ class AapConnectionManager @Inject constructor(
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]).
* Emits when a connected device reports a Conversational Awareness transition (START / RESUME /
* HOLD / STOP, AAP command 0x4B). Paired with the origin address so the conversation reaction
* can gate on the primary device. Every well-formed frame is classified and emitted (unknown
* status bytes map to HOLD); only structurally malformed frames are dropped (see [AapSessionEngine]).
*/
private val _conversationalAwarenessEvents =
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
@@ -323,8 +323,8 @@ 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.
// conversation reaction: START / RESUME / HOLD / STOP. 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)) }
}
@@ -105,7 +105,7 @@ sealed class AapSetting {
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` only
* for the speaking-onset statuses (`1`, `2`); every other value (`0`, `3`, `4`, `5`, `0x0B`, …)
* is `false`. START/STOP/HOLD classification for the reaction lives in [ConversationAwarenessEvent].
* is `false`. START/RESUME/HOLD/STOP classification for the reaction lives in [ConversationAwarenessEvent].
*/
data class ConversationalAwarenessState(
val speaking: Boolean,
@@ -3,36 +3,45 @@ 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 (from live AirPods Pro 3 + Pro 2 USB-C captures — both models share one
* firmware train and a byte-identical protocol — plus the librepods project):
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
* - `5`, `6`, `8`, `9` → [STOP] (wearer stopped → disengage). All four confirmed live on Pro 2
* USB-C fw `…6814`; which one terminates a given flurry varies with how speech ended.
* - any other value (`0`, `3`, `4`, `7`, `0x0B`, … and anything unrecognised) → [HOLD]: a
* transitional wind-down frame (`7` was only discovered on fw `…6814` — the set is open-ended,
* so unknown values are deliberately classified as HOLD rather than guessed at).
* Status-byte mapping, derived from a labelled capture set across AirPods Pro 3 and Pro 2 USB-C
* (`protocol-research/conversationalawareness/` — both models share one firmware train and emit
* byte-identical sequences). Each scenario was captured with a known action (normal talking,
* bursty talking, single-pod, volume-up abort, pod removal/case):
* - `1`, `2` → [START] (conversation onset / wind-up → engage the reaction). A conversation always
* opens `1` then `2`; re-onset only ever happens after a terminal.
* - `5` → [RESUME] (speech resumed after a pause; the wind-down was aborted, CA stays engaged). In
* bursty speech the pod cycles `3,5,3,5,…`; `5` is NEVER a terminal. Misreading `5` as a stop was
* the root cause of the premature-resume bug — see [ConversationReaction].
* - `8`, `9` → [STOP] (conversation ended → disengage). The terminal is always the `8`→`9` pair.
* Pod removal / case-close also emit `8`,`9` (sometimes with no prior `1`,`2`).
* - any other value (`3` pause, `4` / `0x0B` wind-down, `7` abort, `6`, and anything unrecognised)
* → [HOLD]: a transitional "possible/real wind-down" frame. The real wind-down runs `3→0x0B→4`
* then the `8,9` terminal; `7` precedes an aborted terminal. Unknown values are deliberately
* classified as HOLD (arm the safety fuse, never resume immediately) rather than guessed at.
*
* The pod sends NO frames during active speech — it stays engaged (and silent) for as long as it
* hears nearby voices, 20-30s+ observed. So frame-silence must NOT be read as "speaking ended".
* Conversely, any non-START frame means the wind-down has begun: a short flurry of transitional
* and terminal frames (e.g. `3,0xB,4,8,9` or `3,5,7,8,9`). With only ONE pod worn (other in
* case/disconnected) the terminal is deterministically dropped — the flurry ends on a transitional
* `4` (#608; reproduced on Pro 3 and Pro 2 alike) — so [ConversationReaction] treats a HOLD as
* "terminal imminent" and arms a short fuse, with a long stale backstop for a fully-dropped flurry.
* A flurry's trailing frames may arrive after its terminal; they are ignored once disengaged.
* A HOLD means the wind-down may have begun; with only ONE pod worn the `8,9` terminal is
* deterministically dropped — the flurry ends on `4` (#608, reproduced on Pro 3 and Pro 2) — so
* [ConversationReaction] treats a HOLD as "terminal imminent" and arms a short fuse, with a long
* stale backstop for a fully-dropped flurry. A RESUME (`5`) cancels that fuse and re-arms the long
* backstop; trailing frames after a terminal are ignored once disengaged.
*/
enum class ConversationAwarenessEvent {
START,
RESUME,
HOLD,
STOP,
;
companion object {
val SPEAKING_STATUSES = setOf(1, 2)
val STOPPED_STATUSES = setOf(5, 6, 8, 9)
val RESUME_STATUSES = setOf(5)
val STOPPED_STATUSES = setOf(8, 9)
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
in SPEAKING_STATUSES -> START
in RESUME_STATUSES -> RESUME
in STOPPED_STATUSES -> STOP
else -> HOLD
}
@@ -40,17 +40,23 @@ import kotlin.time.Duration.Companion.seconds
* 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.
*
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP]).
* The pod sends NO frames during active speech — it stays engaged for as long as it hears nearby
* voices (observed: CA held engaged 21-32s with zero `0x4B` frames, indefinitely against ambient
* noise). So frame-silence must NOT be read as "speaking ended"; after a START only the long
* [STALE_TIMEOUT] backstop applies, and a link drop is handled by the owner-disconnect revert.
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP],
* status `8,9`). The pod sends NO frames during active speech — it stays engaged for as long as it
* hears nearby voices (observed: CA held engaged 21-32s with zero `0x4B` frames, indefinitely
* against ambient noise). So frame-silence must NOT be read as "speaking ended"; after a START only
* the long [STALE_TIMEOUT] backstop applies, and a link drop is handled by the owner-disconnect revert.
*
* Because the pod is silent while engaged, any non-START frame is evidence the wind-down has begun.
* The wind-down is a short flurry of transitional ([ConversationAwarenessEvent.HOLD]) and terminal
* frames — but the terminal is sometimes dropped entirely (fw `…6589` emitted `3,0xB,4` then nothing,
* stranding the volume low — #608). So a HOLD frame re-arms the timer with the short
* [WIND_DOWN_TIMEOUT] fuse: if no terminal follows, speech is treated as ended anyway.
* A [ConversationAwarenessEvent.HOLD] frame (`3` pause, `0x0B`/`4` wind-down, `7` abort) is evidence
* the wind-down may have begun. The real wind-down is a short flurry `3→0x0B→4` then the `8,9`
* terminal — but the terminal is sometimes dropped entirely (single-pod wear: fw `…6589`/`…6861`
* emitted `3,0xB,4` then nothing, stranding the volume low — #608). So a HOLD frame re-arms the
* timer with the short [WIND_DOWN_TIMEOUT] fuse: if no terminal (or resume) follows, speech is
* treated as ended anyway.
*
* A [ConversationAwarenessEvent.RESUME] frame (`5`) means speech resumed after a pause — in bursty
* talking the pod cycles `3,5,3,5,…` while CA stays engaged. It is NOT a terminal: it cancels any
* armed wind-down fuse and re-arms the long backstop, so media stays paused/ducked through the whole
* conversation. (Treating `5` as a stop was the premature-resume + stuck bug.)
*
* 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.
@@ -96,6 +102,7 @@ class ConversationReaction @Inject constructor(
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
ConversationAwarenessEvent.START -> onSpeakingStart(address)
ConversationAwarenessEvent.RESUME -> onSpeakingResume(address)
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
}
@@ -164,9 +171,24 @@ class ConversationReaction @Inject constructor(
}
/**
* Transitional wind-down frame. The pod is silent during active speech, so this frame means the
* wind-down has begun and a terminal frame is imminent — but firmware sometimes drops it (#608).
* Arm the short fuse: if no terminal (or fresh START) follows, disengage anyway.
* Speech resumed (status `5`) after a pause — the wind-down was aborted, the wearer is talking
* again. Cancel any armed wind-down fuse and re-arm the long [STALE_TIMEOUT] backstop, exactly
* like a duplicate-START keep-alive, so media stays paused/ducked through the conversation. Does
* NOT engage from scratch: a RESUME with no active session is ignored (a conversation always
* opens with a START, and a stray `5` should never start a pause/duck on its own).
*/
private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
if (current.owner != address) return
armDisengageTimer(current, STALE_TIMEOUT)
log(TAG) { "RESUME from $address — speech resumed, keep-alive" }
}
/**
* Transitional wind-down frame (`3` pause, `0x0B`/`4` wind-down, `7` abort). The pod is silent
* during active speech, so this frame means the wind-down may have begun and a terminal frame is
* imminent — but firmware sometimes drops it (#608, single-pod wear). Arm the short fuse: if no
* terminal (or a fresh START / RESUME) follows, disengage anyway.
*/
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
@@ -258,9 +280,10 @@ class ConversationReaction @Inject constructor(
/**
* Must be called under [mutex]. (Re)arms the disengage timer for [record] — every frame picks
* the fuse matching its meaning: START → [STALE_TIMEOUT] (active speech, frames cease for its
* whole duration), HOLD → [WIND_DOWN_TIMEOUT] (wind-down begun, terminal imminent). On expiry
* the session is force-ended. Identity-checked so a late timer can't disengage a newer session.
* the fuse matching its meaning: START / RESUME → [STALE_TIMEOUT] (active speech, frames cease
* for its whole duration), HOLD → [WIND_DOWN_TIMEOUT] (wind-down begun, terminal imminent). On
* expiry the session is force-ended. Identity-checked so a late timer can't disengage a newer
* session.
*/
private fun armDisengageTimer(record: Active, timeout: Duration) {
staleJob?.cancel()
@@ -283,7 +306,7 @@ class ConversationReaction @Inject constructor(
private val TAG = logTag("Reaction", "Conversation")
/**
* Backstop fuse while engaged with no wind-down evidence yet (only START frames seen).
* Backstop fuse while engaged with no wind-down evidence yet (START/RESUME frames seen).
* Must stay LONG: the pod sends zero frames during active speech and stays engaged against
* ambient noise, so a short timeout here resumes media mid-conversation (the original 12s
* value did exactly that). Only recovers a session whose entire wind-down flurry was lost
@@ -299,7 +322,7 @@ class ConversationReaction @Inject constructor(
* pod deterministically drops the terminal (#608, reproduced on Pro 3 and Pro 2) — this
* fuse disengages instead of stranding the volume low for [STALE_TIMEOUT]. Must be ≥ ~5s:
* gaps up to 2.8s were observed between consecutive wind-down frames, and each HOLD re-arms
* this fuse. A fresh START re-arms the long fuse (speaking resumed).
* this fuse. A fresh START or RESUME (`5`, speech resumed) re-arms the long fuse instead.
*/
private val WIND_DOWN_TIMEOUT = 6.seconds