diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index db8a52f2..ddee320c 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -75,10 +75,10 @@ class AapConnectionManager @Inject constructor( val sleepEvents: SharedFlow = _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>(extraBufferCapacity = 16) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index beb7641f..f73c3642 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -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)) } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt index 87bebb3d..ad1e9287 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt index 043fa7cf..e2077ea8 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/ConversationAwarenessEvent.kt @@ -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 } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt index ea240fed..ff58aa0f 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index 0e8e83f4..69defffb 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -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 } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt index daaa0d87..827d4ac2 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt @@ -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))