diff --git a/app/src/main/java/me/iscle/aaplus/AndroidAutoHooker.kt b/app/src/main/java/me/iscle/aaplus/AndroidAutoHooker.kt index 4483427..bab8fa5 100644 --- a/app/src/main/java/me/iscle/aaplus/AndroidAutoHooker.kt +++ b/app/src/main/java/me/iscle/aaplus/AndroidAutoHooker.kt @@ -36,18 +36,6 @@ class AndroidAutoHooker( Log.e(TAG, "onCreate: hookMusicStreamVolume failed", e) } } - - if (settings.getBoolean( - Constants.SETTING_ANDROID_AUTO_FREQUENCY_DUCK, - Constants.SETTING_ANDROID_AUTO_FREQUENCY_DUCK_DEFAULT - )) { - try { - Log.d(TAG, "onCreate: hookFrequencyDuck") - hookFrequencyDuck(applicationContext) - } catch (e: Exception) { - Log.e(TAG, "onCreate: hookFrequencyDuck failed", e) - } - } } } ) diff --git a/app/src/main/java/me/iscle/aaplus/Constants.kt b/app/src/main/java/me/iscle/aaplus/Constants.kt index 7a1ba08..a6e7e3e 100644 --- a/app/src/main/java/me/iscle/aaplus/Constants.kt +++ b/app/src/main/java/me/iscle/aaplus/Constants.kt @@ -7,9 +7,10 @@ object Constants { const val SETTING_ANDROID_AUTO_HOOK_MUSIC_STREAM_VOLUME = "hook_music_stream_volume" const val SETTING_ANDROID_AUTO_HOOK_MUSIC_STREAM_VOLUME_DEFAULT = false - // Replaces Android Auto's volume-based ducking (music goes quiet while navigation speaks) - // with a frequency-selective duck: high frequencies are attenuated while the low end is kept, - // the way clubs and radio stations lower a track under an announcement. + // Replaces the OS's volume-based ducking (music goes quiet while navigation speaks) with a + // frequency-selective duck: high frequencies are attenuated while the low end is kept, the way + // clubs and radio stations lower a track under an announcement. Applied by SystemAudioHooker + // inside system_server, since that is where the duck actually happens (see that class). const val SETTING_ANDROID_AUTO_FREQUENCY_DUCK = "frequency_selective_duck" const val SETTING_ANDROID_AUTO_FREQUENCY_DUCK_DEFAULT = false diff --git a/app/src/main/java/me/iscle/aaplus/FrequencyDuckHooker.kt b/app/src/main/java/me/iscle/aaplus/FrequencyDuckHooker.kt deleted file mode 100644 index dc65fd7..0000000 --- a/app/src/main/java/me/iscle/aaplus/FrequencyDuckHooker.kt +++ /dev/null @@ -1,204 +0,0 @@ -package me.iscle.aaplus - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioPlaybackConfiguration -import android.media.AudioRecord -import android.media.MediaRecorder -import android.os.Handler -import android.os.HandlerThread -import android.util.Log -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedHelpers -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.Collections -import java.util.WeakHashMap -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt - -private const val TAG = "FrequencyDuckHooker" - -// How the duck sounds. A high-shelf filter leaves everything below [SHELF_FREQUENCY_HZ] untouched -// and attenuates everything above it by [SHELF_GAIN_DB], so the bass keeps driving while vocals and -// highs step back — the "club" duck instead of a flat volume drop. -private const val SHELF_FREQUENCY_HZ = 220.0 -private const val SHELF_GAIN_DB = -16.0 - -// Seconds the effect takes to fade fully in or out, so engaging/releasing the duck is a smooth -// sweep rather than an audible click. -private const val RAMP_SECONDS = 0.15 - -/** - * Turns Android Auto's ducking (music drops in volume while navigation is speaking) into a - * frequency-selective duck that keeps the low end. - * - * Everything here rides on stable framework APIs — [AudioManager.registerAudioPlaybackCallback] to - * learn when navigation guidance is playing, and [AudioRecord.read] on the remote-submix capture - * Android Auto uses to stream music to the head unit — so it does not depend on Android Auto's - * (obfuscated, per-release) internals and keeps working across versions. - * - * The captured music stream is stereo while the separate navigation-voice capture is mono, so we - * only shape stereo submix captures; the spoken guidance itself is never filtered. - */ -fun hookFrequencyDuck(context: Context) { - val duckState = DuckState() - - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val callbackThread = HandlerThread("aaplus-duck-watcher").apply { start() } - val callbackHandler = Handler(callbackThread.looper) - - val playbackCallback = object : AudioManager.AudioPlaybackCallback() { - override fun onPlaybackConfigChanged(configs: MutableList) { - // The framework hands us only the currently-audible players, so a navigation-guidance - // usage being present means navigation is speaking right now. - val navigating = configs.any { - it.audioAttributes?.usage == AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE - } - if (duckState.engaged != navigating) { - duckState.engaged = navigating - Log.d(TAG, "onPlaybackConfigChanged: navigation guidance active = $navigating") - } - } - } - audioManager.registerAudioPlaybackCallback(playbackCallback, callbackHandler) - - XposedHelpers.findAndHookMethod( - AudioRecord::class.java, - "read", - ByteArray::class.java, - Int::class.java, - Int::class.java, - object : XC_MethodHook() { - override fun afterHookedMethod(param: MethodHookParam?) { - if (param == null) return - val audioRecord = param.thisObject as AudioRecord - val read = param.result as Int - - // Only the music Android Auto captures from other apps, and only in stereo so the - // mono navigation-voice capture is left alone. - if (audioRecord.audioSource != MediaRecorder.AudioSource.REMOTE_SUBMIX) return - if (read <= 0 || audioRecord.channelCount < 2) return - - val filter = filterFor(audioRecord, duckState) - val buffer = param.args[0] as ByteArray - val offset = param.args[1] as Int - val byteBuffer = ByteBuffer.wrap(buffer, offset, read).order(ByteOrder.nativeOrder()) - - when (val audioFormat = audioRecord.audioFormat) { - AudioFormat.ENCODING_PCM_16BIT -> { - if (read % 2 != 0) return - filter.processShorts(byteBuffer.asShortBuffer(), read / 2) - } - - AudioFormat.ENCODING_PCM_FLOAT -> { - if (read % 4 != 0) return - filter.processFloats(byteBuffer.asFloatBuffer(), read / 4) - } - - else -> Log.d(TAG, "afterHookedMethod: unsupported audio format: $audioFormat") - } - } - } - ) -} - -/** Whether navigation is currently speaking. Shared between the watcher thread and the audio path. */ -private class DuckState { - @Volatile - var engaged: Boolean = false -} - -// One filter per capture, since each carries its own sample rate, channel count and filter memory. -private val filters = Collections.synchronizedMap(WeakHashMap()) - -private fun filterFor(audioRecord: AudioRecord, duckState: DuckState): ShelfDuckFilter = - filters.getOrPut(audioRecord) { - ShelfDuckFilter(audioRecord.sampleRate, audioRecord.channelCount, duckState) - } - -/** - * A per-channel biquad high-shelf whose wet/dry balance ramps toward "fully ducked" while - * navigation is speaking and back to "untouched" afterwards. Interleaved samples in, in place. - */ -private class ShelfDuckFilter( - sampleRate: Int, - private val channels: Int, - private val duckState: DuckState, -) { - // Transfer-function coefficients (normalised so a0 == 1), shared across channels. - private val b0: Double - private val b1: Double - private val b2: Double - private val a1: Double - private val a2: Double - - // Per-channel Direct Form I delay lines. - private val x1 = DoubleArray(channels) - private val x2 = DoubleArray(channels) - private val y1 = DoubleArray(channels) - private val y2 = DoubleArray(channels) - - // 0 = dry (bypassed), 1 = fully filtered. Stepped one increment per frame toward the target. - private var mix = 0.0 - private val mixStep = 1.0 / (RAMP_SECONDS * sampleRate).coerceAtLeast(1.0) - - init { - // RBJ audio-EQ-cookbook high-shelf. - val a = Math.pow(10.0, SHELF_GAIN_DB / 40.0) - val w0 = 2.0 * Math.PI * SHELF_FREQUENCY_HZ / sampleRate - val cosW0 = cos(w0) - val alpha = sin(w0) / 2.0 * sqrt(2.0) - val twoSqrtAAlpha = 2.0 * sqrt(a) * alpha - - val a0 = (a + 1) - (a - 1) * cosW0 + twoSqrtAAlpha - b0 = a * ((a + 1) + (a - 1) * cosW0 + twoSqrtAAlpha) / a0 - b1 = -2 * a * ((a - 1) + (a + 1) * cosW0) / a0 - b2 = a * ((a + 1) + (a - 1) * cosW0 - twoSqrtAAlpha) / a0 - a1 = 2 * ((a - 1) - (a + 1) * cosW0) / a0 - a2 = ((a + 1) - (a - 1) * cosW0 - twoSqrtAAlpha) / a0 - } - - fun processShorts(buffer: java.nio.ShortBuffer, count: Int) { - val frames = count / channels - var i = 0 - for (frame in 0 until frames) { - val target = if (duckState.engaged) 1.0 else 0.0 - mix += (target - mix).coerceIn(-mixStep, mixStep) - for (ch in 0 until channels) { - val dry = buffer.get(i).toDouble() - val wet = step(ch, dry) - val out = dry + (wet - dry) * mix - buffer.put(i, out.toInt().coerceIn(-32768, 32767).toShort()) - i++ - } - } - } - - fun processFloats(buffer: java.nio.FloatBuffer, count: Int) { - val frames = count / channels - var i = 0 - for (frame in 0 until frames) { - val target = if (duckState.engaged) 1.0 else 0.0 - mix += (target - mix).coerceIn(-mixStep, mixStep) - for (ch in 0 until channels) { - val dry = buffer.get(i).toDouble() - val wet = step(ch, dry) - buffer.put(i, (dry + (wet - dry) * mix).toFloat()) - i++ - } - } - } - - private fun step(ch: Int, x0: Double): Double { - val y0 = b0 * x0 + b1 * x1[ch] + b2 * x2[ch] - a1 * y1[ch] - a2 * y2[ch] - x2[ch] = x1[ch] - x1[ch] = x0 - y2[ch] = y1[ch] - y1[ch] = y0 - return y0 - } -} diff --git a/app/src/main/java/me/iscle/aaplus/Hooker.kt b/app/src/main/java/me/iscle/aaplus/Hooker.kt index d8b5c40..095d94f 100644 --- a/app/src/main/java/me/iscle/aaplus/Hooker.kt +++ b/app/src/main/java/me/iscle/aaplus/Hooker.kt @@ -7,6 +7,7 @@ class Hooker : IXposedHookLoadPackage { override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam?) { if (lpparam == null) return when (lpparam.packageName) { + "android" -> SystemAudioHooker(lpparam.classLoader) "com.google.android.projection.gearhead" -> AndroidAutoHooker(lpparam.classLoader) "com.waze" -> WazeHooker(lpparam.classLoader) } diff --git a/app/src/main/java/me/iscle/aaplus/SystemAudioHooker.kt b/app/src/main/java/me/iscle/aaplus/SystemAudioHooker.kt new file mode 100644 index 0000000..63da1ab --- /dev/null +++ b/app/src/main/java/me/iscle/aaplus/SystemAudioHooker.kt @@ -0,0 +1,178 @@ +package me.iscle.aaplus + +import android.media.AudioAttributes +import android.media.audiofx.Equalizer +import android.util.Log +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XSharedPreferences +import de.robv.android.xposed.XposedHelpers + +private const val TAG = "SystemAudioHooker" + +// Android usage/state constants, inlined so we don't depend on hidden framework fields. +private const val USAGE_ASSISTANCE_NAVIGATION_GUIDANCE = AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE +private const val USAGE_MEDIA = AudioAttributes.USAGE_MEDIA +private const val USAGE_GAME = AudioAttributes.USAGE_GAME +private const val PLAYER_STATE_STARTED = 2 + +// Shape of the duck: keep everything below this untouched, pull the rest down by this much. +private const val SHELF_FREQUENCY_HZ = 250 +private const val SHELF_CUT_MILLIBEL = -1400 + +/** + * Runs inside `system_server`. Turns the OS's volume-based audio ducking into a bass-preserving one + * for navigation: when a navigation app takes transient "may duck" focus, instead of letting the + * framework fade the music down in volume we leave its level alone and hang a high-cut equalizer on + * the music player's audio session, so the low end keeps driving while the mids/highs step back for + * the spoken directions — the way a club or radio lowers a track under an announcement. + * + * This is where the duck is actually applied. Doing it from inside Android Auto is impossible: by + * the time Android Auto captures the car audio the OS has already ducked the music (and mixed the + * navigation voice into the same stream), so the volume drop can only be changed here. + * + * Everything is best-effort and falls back to the stock volume duck: if the app is not a navigation + * app, the feature is off, the player has no session id (Android < 12), or attaching the effect + * fails, the original method runs untouched. Audio is never left un-ducked by accident. + */ +class SystemAudioHooker( + private val classLoader: ClassLoader +) { + private val settings = XSharedPreferences( + Constants.PACKAGE_NAME, + Constants.ANDROID_AUTO_SETTINGS + ) + + // Active high-cut effects keyed by audio session id, so restore can tear them down. + private val activeEffects = HashMap() + + init { + val monitor = XposedHelpers.findClass( + "com.android.server.audio.PlaybackActivityMonitor", + classLoader + ) + val focusRequester = XposedHelpers.findClass( + "com.android.server.audio.FocusRequester", + classLoader + ) + + // Replace the volume duck with a frequency duck for navigation. + XposedHelpers.findAndHookMethod( + monitor, + "duckPlayers", + focusRequester, + focusRequester, + Boolean::class.javaPrimitiveType, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + try { + if (frequencyDuck(param.thisObject, param.args[0], param.args[1])) { + // We handled the duck via an effect; skip the framework's volume duck. + param.result = true + } + } catch (e: Throwable) { + // Never let our code break audio focus handling — fall through to stock. + Log.e(TAG, "duckPlayers hook failed; using stock ducking", e) + clearEffects() + } + } + } + ) + + // Undo our effects whenever the framework restores/unducks. Method was renamed across + // versions, so hook whichever name exists; both take a single FocusRequester. + for (name in arrayOf("restoreVShapedPlayers", "unduckPlayers")) { + try { + XposedHelpers.findAndHookMethod( + monitor, + name, + focusRequester, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + clearEffects() + } + } + ) + } catch (e: NoSuchMethodError) { + // Not present on this version; the other name covers it. + } + } + } + + /** + * Applies the high-cut effect to the loser's music players when [winner] is a navigation app. + * Returns true only if at least one effect was attached, meaning the caller should skip the + * framework's own volume duck. + */ + private fun frequencyDuck(monitor: Any, winner: Any, loser: Any): Boolean { + settings.reload() + if (!settings.getBoolean( + Constants.SETTING_ANDROID_AUTO_FREQUENCY_DUCK, + Constants.SETTING_ANDROID_AUTO_FREQUENCY_DUCK_DEFAULT + )) { + return false + } + + if (usageOf(winner) != USAGE_ASSISTANCE_NAVIGATION_GUIDANCE) return false + + val loserUid = XposedHelpers.callMethod(loser, "getClientUid") as Int + + @Suppress("UNCHECKED_CAST") + val players = (XposedHelpers.getObjectField(monitor, "mPlayers") as Map<*, *>) + .values.toList() + + var attached = false + for (apc in players) { + if (apc == null) continue + if (XposedHelpers.callMethod(apc, "getClientUid") as Int != loserUid) continue + if (XposedHelpers.callMethod(apc, "getPlayerState") as Int != PLAYER_STATE_STARTED) continue + + val usage = (XposedHelpers.callMethod(apc, "getAudioAttributes") as AudioAttributes).usage + if (usage != USAGE_MEDIA && usage != USAGE_GAME) continue + + // getSessionId() only exists from Android 12; on older versions this throws and we bail + // out for the whole request, so the stock volume duck runs instead. + val sessionId = XposedHelpers.callMethod(apc, "getSessionId") as Int + if (sessionId <= 0 || activeEffects.containsKey(sessionId)) continue + + if (attachHighCut(sessionId)) attached = true + } + return attached + } + + private fun usageOf(focusRequester: Any): Int = + (XposedHelpers.callMethod(focusRequester, "getAudioAttributes") as AudioAttributes).usage + + private fun attachHighCut(sessionId: Int): Boolean { + return try { + val equalizer = Equalizer(0, sessionId) + val bands = equalizer.numberOfBands.toInt() + val minLevel = equalizer.bandLevelRange[0].toInt() + val cut = maxOf(minLevel, SHELF_CUT_MILLIBEL).toShort() + for (band in 0 until bands) { + val hz = equalizer.getCenterFreq(band.toShort()) / 1000 + equalizer.setBandLevel(band.toShort(), if (hz >= SHELF_FREQUENCY_HZ) cut else 0) + } + equalizer.enabled = true + synchronized(activeEffects) { activeEffects[sessionId] = equalizer } + Log.d(TAG, "attachHighCut: engaged on session $sessionId ($bands bands)") + true + } catch (e: Throwable) { + Log.e(TAG, "attachHighCut: could not attach on session $sessionId", e) + false + } + } + + private fun clearEffects() { + synchronized(activeEffects) { + for (equalizer in activeEffects.values) { + try { + equalizer.enabled = false + equalizer.release() + } catch (e: Throwable) { + Log.e(TAG, "clearEffects: release failed", e) + } + } + activeEffects.clear() + } + } +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index cc860e7..24e196c 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,6 +1,7 @@ + android com.google.android.projection.gearhead com.waze