From 8b574e84d0d05659c1d94451f92223723f410122 Mon Sep 17 00:00:00 2001 From: SAGIRIxr Date: Tue, 20 Oct 2026 18:12:38 +0800 Subject: [PATCH] android: don't let conversation awareness overshoot the original volume The ducked levels are a fixed share of the maximum volume, so with a quiet starting volume level 1/3 actually raised it. On top of that, volume ramps were never cancelled: the AirPods step from 3 to 6 quickly, so the ramp towards the ducked level could still be running after the restore to the original volume had finished, and it won, leaving the volume above where it started. Ducking now never goes above the volume recorded when speaking started, every ramp supersedes the previous one, and level 2 also records the starting volume since it means 'started speaking' too. --- .../librepods/services/LibrePodsService.kt | 9 ++++++--- .../librepods/utils/MediaController.kt | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index b0d496eb6..4f6fd49a3 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -815,19 +815,22 @@ class LibrePodsService: Service() { when (state.conversationAwarenessState) { 1 -> { MediaController.startSpeaking() - MediaController.setVolume( + MediaController.duckVolume( deviceSettings.conversationAwarenessVolume.toInt() ) } 2 -> { - MediaController.setVolume( + // 1 and 2 both mean "started speaking"; only the first one records + // the volume to come back to, so a sequence starting at 2 still restores + MediaController.startSpeaking() + MediaController.duckVolume( deviceSettings.conversationAwarenessReducedVolume.toInt() ) } 3 -> { - MediaController.setVolume( + MediaController.duckVolume( deviceSettings.conversationAwarenessVolume.toInt() ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt index 374f00c20..581012126 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt @@ -356,6 +356,18 @@ object MediaController { ) } + /** + * Conversation awareness levels are a fixed share of the maximum volume, so on their own they + * can sit *above* what the user was listening at. Never let ducking raise the volume. + */ + @Synchronized + fun duckVolume(volumePercent: Int) { + val current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val ceiling = initialVolume ?: current + smoothVolumeTransition(current, minOf(maxVolume * volumePercent / 100, ceiling)) + } + @Synchronized fun startSpeaking() { if (initialVolume == null) { @@ -381,14 +393,21 @@ object MediaController { initialVolume = null } + // Bumped by every transition; a stale one stops instead of finishing. Without this, a ramp still + // running towards the ducked level could finish after the restore and leave the volume there. + @Volatile + private var volumeTransitionGeneration = 0 + private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) { Log.d("MediaController", "Smooth volume transition from $fromVolume to $toVolume") val step = if (fromVolume < toVolume) 1 else -1 val delay = 50L var currentVolume = fromVolume + val generation = ++volumeTransitionGeneration handler.post(object : Runnable { override fun run() { + if (generation != volumeTransitionGeneration) return if (currentVolume != toVolume) { currentVolume += step audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0)