Skip to content

feat(android): Auto-disable CA volume ducking when only one AirPod is worn (#616)#675

Open
QuantumBreakz wants to merge 2 commits into
librepods-org:mainfrom
QuantumBreakz:feat/616-ca-both-pods-only
Open

feat(android): Auto-disable CA volume ducking when only one AirPod is worn (#616)#675
QuantumBreakz wants to merge 2 commits into
librepods-org:mainfrom
QuantumBreakz:feat/616-ca-both-pods-only

Conversation

@QuantumBreakz

Copy link
Copy Markdown

Summary

Closes #616

Implements the app-side suppression approach suggested by @tabjy and accepted by @kavishdevar.

When the new "Only when both AirPods are worn" toggle is ON, the Conversational Awareness volume-ducking event (startSpeaking()) is silently ignored if fewer than two buds are currently in-ear. The stopSpeaking() (volume restore) path is never gated, so volume can never get stuck low.


Changes

AirPodsService.kt

  • Added conversationalAwarenessBothPodsOnly: Boolean = false to ServiceConfig
  • Seeded conversational_awareness_both_pods_only in first-run SharedPreferences defaults (false)
  • Loaded the pref in initializeConfig()
  • Added live-change case in onSharedPreferenceChanged()
  • The gate in onConversationAwarenessReceived():
    val budsInEar = earDetectionNotification.status.count { it == 0x00.toByte() }
    val blockedForSinglePod = config.conversationalAwarenessBothPodsOnly && budsInEar < 2
    if (status == 1 || status == 2) {
        if (!blockedForSinglePod) MediaController.startSpeaking()
    } else if (status == 6 || 8 || 9) {
        MediaController.stopSpeaking() // never gated
    }

AppSettingsViewModel.kt

  • Added conversationalAwarenessBothPodsOnlyEnabled: Boolean = false to AppSettingsUiState
  • Loaded the pref in loadSettings()
  • Added setConversationalAwarenessBothPodsOnlyEnabled(enabled: Boolean) setter

AppSettingsScreen.kt

  • Added a third StyledToggle inside the existing Conversational Awareness StyledList, between "Pause Music" and "Relative volume"

strings.xml

<string name="conversational_awareness_both_pods_only">Only when both AirPods are worn</string>
<string name="conversational_awareness_both_pods_only_description">Only lowers the volume when you speak if both AirPods are in your ears.</string>

Behaviour

Toggle One AirPod Both AirPods
OFF (default) Volume ducks ✅ Volume ducks ✅
ON Volume does not duck ✅ Volume ducks ✅

The stopSpeaking() path always runs regardless of toggle state — volume is always restored.


Testing

Build verified: ./gradlew assembleFossDebugBUILD SUCCESSFUL (51 tasks, 0 errors).

Manual test steps:

  1. Enable the toggle in Settings → Conversational Awareness
  2. Play music, insert one AirPod, speak → volume should not duck
  3. Insert both AirPods, speak → volume should duck
  4. Disable the toggle → always ducks (original behaviour restored)

Notes

  • Default is false in all five required places — zero behaviour change for existing users
  • Uses app-side suppression (no firmware commands) as recommended by the reporter
  • Reads ear state from the existing earDetectionNotification.status array (0x00 = in-ear)

- Add conversationalAwarenessBothPodsOnly field to ServiceConfig (default false)
- Seed 'conversational_awareness_both_pods_only' pref on first run (default false)
- Load pref in initializeConfig() and handle live changes in onSharedPreferenceChanged()
- Add conversationalAwarenessBothPodsOnlyEnabled to AppSettingsUiState
- Add setConversationalAwarenessBothPodsOnlyEnabled() setter to AppSettingsViewModel
- Add StyledToggle in AppSettingsScreen inside the Conversational Awareness list
- Add string resources: conversational_awareness_both_pods_only + _description

Default is off — no behavior change for existing users.

Fixes librepods-org#616
@QuantumBreakz

Copy link
Copy Markdown
Author

Hey @kavishdevar 👋

This PR implements the feature requested in #616 by @tabjy using the app-side suppression approach you approved.

Since this is submitted from a fork, I can't set reviewers or assignees programmatically. Could you please:

  • Assign yourself (or another maintainer) as reviewer
  • Assign @tabjy as the original reporter/assignee if appropriate

Build is verified: ./gradlew assembleFossDebugBUILD SUCCESSFUL (51 tasks, 0 errors, 0 new warnings).

Happy to make any changes based on your review! 🙏

@tabjy tabjy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR! Sorry I never got around doing this. I really appreciate you taking this issue. I haven't actually tested your code yet. Here are some pre-flight thoughts. I hope they're helpful.

I'll get back to you after I have a chance to test your branch. :)

<string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
<string name="conversational_awareness_both_pods_only">Only when both AirPods are worn</string>
<string name="conversational_awareness_both_pods_only_description">Only lowers the volume when you speak if both AirPods are in your ears.</string>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something along the line of:

Only with Both AirPods: Enables conversational awareness only if both AirPods are in ears.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the latest commit (5d4d36a). Went with "Both AirPods in Ear" as the label to keep it short and consistent with Apple's in-ear detection phrasing elsewhere in the app. Happy to adjust wording as I went with the version you suggested.

var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true,
var conversationalAwarenessPauseMusic: Boolean = false,
var conversationalAwarenessBothPodsOnly: Boolean = false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the original issue, two approaches were discussed. Your PR seems to be approach 2 (app-side suppression). @kavishdevar pointed out this approach will still let one bud to switch to transparency mode. To me this is perfectly acceptable, but @kavishdevar what's your thought?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To add context: the transparency-mode switch is firmware-driven, it happens the moment the bud detects you're speaking, independently of what the app does. App-side suppression (Approach 2) only prevents startSpeaking() from lowering the volume; it can't intercept the firmware-level transparency switch.

Approach 1 (sending a firmware command to disable CA entirely) would suppress both, but it means toggling a firmware feature on/off reactively based on ear state, which is more invasive and has different tradeoffs (latency, firmware round-trips, potential edge cases on reconnect).

I'm happy to implement either, but leaving this for @kavishdevar to call, don't want to pick the approach and have it reopened at merge time.

MediaController.startSpeaking()
} else if (conversationAwarenessNotification.status == 6.toByte() ||conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
MediaController.stopSpeaking()
if (!blockedForSinglePod) MediaController.startSpeaking()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your toggle also gates music pausing. By suppressing startSpeaking(), if user also has "Pause Music" enabled, this toggle would prevent that too. Again, this is perfectly fine to me, but I'd recommend updating UI string to reflect that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, a simple debug log on both paths printing startSpeaking() is suppressed would be helpful.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your toggle also gates music pausing. By suppressing startSpeaking(), if user also has "Pause Music" enabled, this toggle would prevent that too. Again, this is perfectly fine to me, but I'd recommend updating UI string to reflect that.

Done in the same commit. The description now reads:

"Conversational Awareness only activates when both AirPods are in your ears. This also suppresses the Pause Music action when wearing a single AirPod."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, a simple debug log on both paths printing startSpeaking() is suppressed would be helpful.

Added in 5d4d36a. Both branches now carry a Log.d("AirPodsParser", ...), the suppressed path prints the bud count, the live path prints bud count + flag state. stopSpeaking() is left without a log since it's always unconditional.

…only

- Rename label to 'Both AirPods in Ear' (shorter, Apple-style)
- Expand description to note that Pause Music is also suppressed
  when wearing a single AirPod, since the gate sits above startSpeaking()
- Add Log.d on both branches of the startSpeaking() gate so suppression
  is visible in logcat during testing; stopSpeaking() remains ungated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Automatically disable conversational awareness when only one single AirPod is inserted

3 participants