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
@@ -562,21 +562,32 @@ class AapSessionEngineTest : BaseTest() {
}
@Test
fun `terminal statuses 5, 6, 8, 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) {
// 5 is the terminal wind-down value on fw …6861 (never reaches 6/8/9); 6/8/9 on fw …6503.
firstEventFor(5) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(6) shouldBe ConversationAwarenessEvent.STOP
fun `status 5 emits RESUME`() = runTest(UnconfinedTestDispatcher()) {
// 5 = speech resumed after a pause (bursty talking cycles 3,5,3,5,…); NOT a terminal.
// Labelled captures across Pro 3 + Pro 2 USB-C show 5 only ever inside an active
// conversation, never ending one. Misreading it as STOP was the premature-resume bug.
firstEventFor(5) shouldBe ConversationAwarenessEvent.RESUME
}
@Test
fun `terminal statuses 8 and 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) {
// The conversation terminal is always the 8→9 pair (both pods); also emitted by pod
// removal / case-close. 6 is never observed as a terminal, so it falls through to HOLD.
firstEventFor(8) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(9) shouldBe ConversationAwarenessEvent.STOP
}
@Test
fun `transitional and unknown statuses emit HOLD (stay engaged)`() = runTest(UnconfinedTestDispatcher()) {
// Must never disengage on these — only an explicit terminal STOP does.
// Must never resume immediately on these — they arm the short wind-down fuse instead.
// 3 = pause, 0x0B/4 = wind-down, 7 = abort; 6 and any unknown value default to HOLD.
firstEventFor(3) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(4) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0x0B) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(7) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(6) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0xFF) shouldBe ConversationAwarenessEvent.HOLD
}
}
@@ -338,9 +338,9 @@ class ConversationReactionTest : BaseTest() {
@Test
fun `PAUSE stays paused through frame silence, resumes only on explicit STOP, then re-engages`() =
runTest(UnconfinedTestDispatcher()) {
// Regression for the fw …6861 bug: the pod sends an onset, then NO frames for ~20s while
// the wearer keeps talking, then a terminal STOP. The old 12s stale timeout resumed media
// mid-speech; the backstop must not, and a fresh talk must re-arm.
// The pod sends NO frames during continuous speech (29s silent gaps observed), then a
// terminal STOP. The old 12s stale timeout resumed media mid-speech; the long backstop
// must not, and a fresh talk must re-arm.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
@@ -377,6 +377,102 @@ class ConversationReactionTest : BaseTest() {
job.cancel()
}
@Test
fun `RESUME keeps media paused through a bursty conversation, resumes only at the real terminal`() =
runTest(UnconfinedTestDispatcher()) {
// Regression for the fw …6861 status-5 bug. Bursty talking emits 1,2 then 3,5 (pause,
// resume) pairs while CA stays engaged, ending with the real wind-down 3,0xB,4,8,9.
// Status 5 was misclassified as a terminal STOP, so media resumed on the first burst
// pause and — with no fresh 1/2 onset mid-conversation — never paused again. RESUME must
// keep media paused until the genuine terminal.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START) // 1
emit(primaryAddress, ConversationAwarenessEvent.START) // 2
coVerify(exactly = 1) { mediaControl.sendPause(false) }
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 resume
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 resume
coVerify(exactly = 0) { mediaControl.sendPlay() } // stayed paused through the bursts
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 0x0B
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 4
emit(primaryAddress, ConversationAwarenessEvent.STOP) // 8 terminal
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `RESUME cancels the wind-down fuse`() = runTest(UnconfinedTestDispatcher()) {
// A pause (3) arms the short fuse; a resume (5) must cancel it and switch back to the long
// backstop — otherwise media resumes ~6s into renewed speech.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 — wind-down fuse armed
advanceTimeBy(windDownTimeoutMs * 2 / 3)
runCurrent()
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 — speech resumed, cancel fuse
advanceTimeBy(windDownTimeoutMs * 2) // well past the original fuse
runCurrent()
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `wind-down after a RESUME still disengages via the fuse (dropped terminal)`() =
runTest(UnconfinedTestDispatcher()) {
// After a resume re-arms the long backstop, a later genuine wind-down (0xB,4 with the
// 8,9 terminal dropped — single-pod) must still disengage via the short fuse. Proves the
// RESUME keep-alive doesn't permanently disable #608 recovery.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5 resume → long backstop
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3 pause again
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 0x0B wind-down
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 4 — terminal dropped
coVerify(exactly = 0) { mediaControl.sendPlay() }
advanceTimeBy(windDownTimeoutMs + 500)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `RESUME without a prior start is a no-op`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // stray 5, nothing engaged
coVerify(exactly = 0) { mediaControl.sendPause(any()) }
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `LOWER_VOLUME RESUME keeps the volume ducked`() = runTest(UnconfinedTestDispatcher()) {
// Same status-5 bug seen on the default action: it restored volume on the first 3→5 pause.
val job = launchReaction() // devicesFlow default = LOWER_VOLUME
emit(primaryAddress, ConversationAwarenessEvent.START)
verify(exactly = 1) { mediaControl.duckMusicVolume(any()) }
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3
emit(primaryAddress, ConversationAwarenessEvent.RESUME) // 5
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
job.cancel()
}
@Test
fun `STOP from a non-owner does not disengage the active owner`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))