Android's volume slider has a floor, and on a lot of devices that floor is louder than it should be. The first step above mute is a fixed fraction of the device's maximum output, not a fixed loudness, so a phone or tablet with a loud maximum can leave you with "off" and "too loud" as your only two options.
This is a common enough complaint that there's a small ecosystem of apps claiming to fix it. Most of them don't, and the reason why is a genuinely interesting piece of Android audio API trivia. This post is about the one approach that actually works, why the popular alternative doesn't, and a separate engineering problem I hit while making the app that implements it buildable reproducibly for F-Droid.
I'm the author of Granular Volume, a small open source Android app that does exactly this. Everything below is drawn from actually shipping it.
Why "more volume steps" doesn't lower the floor
The most common suggestion for this problem (apps like Precise Volume, or Samsung's Sound Assistant on Good Lock) subdivides Android's existing volume range into more, finer steps. Stock Android gives you roughly 15 steps between mute and maximum; these apps give you 100, or 1000.
That's a real feature, and it's useful for smoother transitions. But it doesn't touch the floor. If step 1 of 15 was already too loud, step 1 of 1000 covering the exact same output range lands at essentially the same loudness. You've just added more clicks to get there. These apps are remapping AudioManager.setStreamVolume() onto a finer scale; they never emit output quieter than the device's original minimum step.
This is worth being explicit about because it's the single most common piece of bad advice for this exact search query, and it comes from a real misunderstanding of what "more steps" actually changes.
What actually lowers the floor
The mechanism that works is not a volume control at all in the AudioManager sense. It's an audio effect with negative gain, applied on top of whatever the system volume is already doing.
Android exposes two APIs for this:
-
DynamicsProcessing(API 28+): a flexible multi-band audio effect that includes a post-EQ gain stage. You can drive that gain negative. -
LoudnessEnhancer(API 19+): normally used to boost loudness via dynamic range compression, but it also accepts negative target gain, which functions as attenuation.
Granular Volume uses DynamicsProcessing as the primary path, with LoudnessEnhancer as a fallback for cases where DynamicsProcessing isn't well-supported on a given OEM's audio stack. Both attach to the audio session the same way an equalizer effect would. They process the signal after the app's output but before it reaches the device's output stage. Critically, this happens underneath wherever the stock volume slider already sits. If the slider is at its minimum non-mute step, the effect chain applies further gain reduction on top of that step, producing output genuinely quieter than the hardware minimum allows.
A simplified version of the setup:
val params = DynamicsProcessing.Config.Builder(
DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
channelCount, /* preEqInUse */ false, /* mbcInUse */ false,
/* postEqInUse */ true, bandCount, /* limiterInUse */ false
).build()
val processor = DynamicsProcessing(0, audioSessionId, params)
val postEq = processor.getPostEqBandAllChannelsTo(bandIndex)
postEq.gain = attenuationDb // negative value, e.g. -18f
processor.setPostEqBandAllChannelsTo(bandIndex, postEq)
processor.enabled = true
The app exposes seven attenuation steps from 0 dB down to -30 dB, applied globally rather than per-app, through a small floating overlay (TYPE_APPLICATION_OVERLAY) that stays on top of whatever's playing. It runs as a foreground service so the effect and the overlay both survive the screen locking or the launcher app changing.
A few practical constraints worth knowing if you're building something similar:
- It's a clean gain-reduction stage, not a limiter or compressor. No added distortion or hiss, because it's not reshaping the waveform, just scaling it down further than the OS already was.
- It doesn't touch the call audio stream. Call audio in Android routes through a separate voice-call session that most third-party audio-effect-based tools, this one included, don't currently reach.
- It can't boost above 100%. This is a one-directional tool by design: negative gain only. A "volume booster" that pushes gain up risks distortion and, at high output, actual hearing damage; this approach has no equivalent failure mode because it only ever subtracts.
-
No root, no Accessibility Service, no Device Admin. The permission set is genuinely minimal: display-over-other-apps for the overlay, modify-audio-settings for the effect, foreground-service, and boot-completed so the tile can relaunch it. No
INTERNETpermission at all; there's no network code in the app.
The second problem: making the F-Droid build reproducible
Shipping to Google Play and F-Droid from the same codebase turned out to have a dependency conflict I hadn't planned for. Play Store submissions get an in-app review prompt (Google Play's com.google.android.play:review library) as a UX nicety, but that's a proprietary Google Play Services dependency, and F-Droid's build policy requires zero non-free dependencies anywhere in the build graph, including ones that are only exercised at runtime and never actually called during the F-Droid build itself.
An F-Droid maintainer flagged this correctly on the project's issue tracker: the dependency existed in the build graph regardless of whether the review flow ever fired on that build variant. The fix wasn't a runtime if (isPlayBuild) check: that still leaves the class reference (and hence the dependency) in the compiled output. The actual fix is a Gradle product flavor split, so the dependency doesn't exist in the F-Droid variant's build graph at all:
flavorDimensions += "distribution"
productFlavors {
create("play") { dimension = "distribution" }
create("fdroid") { dimension = "distribution" }
}
dependencies {
"playImplementation"("com.google.android.play:review:2.0.2")
"playImplementation"("com.google.android.play:review-ktx:2.0.2")
}
With a source-set split for the one class that used the dependency:
src/play/java/.../ReviewHelper.kt // real implementation, uses ReviewManagerFactory
src/fdroid/java/.../ReviewHelper.kt // no-op: fun maybeRequestReview(...) = onDone()
Both implementations expose the same method signature, so the call site (MainActivity) doesn't know or care which flavor it's compiled against.
To actually verify this worked, rather than assume it, I cold-verified the compiled output three ways:
-
Dex string scan for any
com.google.android.playreference in the F-Droid variant's compiled classes: zero matches. -
aapt2 dump xmltreeon the merged manifest, comparing permission sets between the pre-split baseline build and both new flavors: byte-for-byte identical, confirming the flavor split changed nothing else about the app's declared capabilities. -
A GitHub Actions CI build on Linux producing an unsigned APK via
./gradlew assembleFdroidRelease, which is what F-Droid's own build infrastructure will independently reproduce and compare against a locally-signed reference build, to confirm the build is genuinely reproducible bit-for-bit from source rather than just "probably fine."
applicationId and the signing configuration stayed untouched throughout. Flavors only affect which source sets and dependencies get compiled in, not the app's identity or its release key.
Why bother with F-Droid at all
The honest answer is that it's a small fraction of an app's total install base compared to Google Play. But an app whose entire value proposition is "does exactly one thing, no ads, no tracking, no account, open source" has an obligation to actually be buildable the way F-Droid expects, not just claim to be open source while quietly depending on a proprietary SDK. The flavor split cost about a day of work; not doing it would have made the "open source" claim on the store listing meaningfully less true.
If you're building an Android app that wants to ship to both stores cleanly, the pattern above (flavor-scoped dependencies, source-set-split implementations behind a shared interface, and cold verification of the actual compiled output rather than just reading the Gradle config) is the version of this I'd recommend. It's more work than a runtime flag, and it's the only version that's actually correct.
Granular Volume is open source on GitHub under GPL-3.0, and available on Google Play and F-Droid. Full writeup of every other approach to this problem, including the ones that don't work, is in the companion guide.
Top comments (0)