MediaPlayer routes USAGE_MEDIA to the earpiece on moto g stylus 5G (Android 15) — every routing API returns success
I'm stuck on a sound-recording routing issue on a specific Android device and I could really use another pair of eyes.
This is a STT / TTS (speech <-> text) chat app. When the STT transcription fails, I retain the audio in a file so I can replay it to the user and resubmit for transcription if the problem gets resolved. All TTS and supporting sound effects correctly route to the speaker, but playback of the saved file gets routed to the earpiece for some reason.
Every Android API I call that tries to route to the speaker returns success, but the audio still comes out of the earpiece instead of the main speaker. I don't have access to another physical device at this time, so I cannot honestly confirm whether this is device-specific behavior.
Summary
Device: Motorola **moto g stylus 5G (2024)**
Android 15, API level 35
React Native / Expo app, but the playback path is a native Kotlin module I own (Expo Module), so it's plain Android APIs from here on
What I'm playing: a short WAV file (~1–3 seconds) captured earlier by an audio recorder. Standard `file://` URI in the app's cache dir.
What I want: audio to come out of the main media speaker (or Bluetooth A2DP if connected), like any normal media playback
Sound effects in the same app, played via [`expo-audio`](https://docs.expo.dev/versions/latest/sdk/audio/)'s `useAudioPlayer` (which is `ExoPlayer` under the hood), route to the main speaker correctly. Same device. Same OS. Same app process. So it's not a broken speaker, and it's not silent mode.
Details of what is not working
My retained-audio playback via `MediaPlayer` (I also tried `react-native-audio-api`'s Oboe output — same result). Every knob Android gives me to say "route this to the speaker" returns success, and the audio still comes out of the earpiece.
Here's the Kotlin, trimmed to the relevant parts:
// Executed inside an Expo AsyncFunction, off the main thread. val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val player = MediaPlayer() player.setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() ) player.setOnCompletionListener { /* release + emit event */ } player.setOnErrorListener { _, what, extra -> /* log + emit */; true } player.setDataSource(uri) // "file:///.../capture.wav" player.prepare() // Request audio focus with music content (matches what expo-audio does for SFX) val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes( AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() ) .setAcceptsDelayedFocusGain(true) .setOnAudioFocusChangeListener(focusListener) .build() val focusResult = audioManager.requestAudioFocus(focusRequest) // focusResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED ← success // Explicitly pin the output device to the main speaker val speaker = audioManager .getDevices(AudioManager.GET_DEVICES_OUTPUTS) .first { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } val ok = player.setPreferredDevice(speaker) // ok == true ← success player.start() // Audio plays, but it's coming from the earpiece.
Logcat from a real run:
I ActiveInputDeviceModule: requestAudioFocus (music) granted=true I ActiveInputDeviceModule: MediaPlayer.setPreferredDevice type=BUILTIN_SPEAKER success=true I ActiveInputDeviceModule: MediaPlayer onCompletion for retained audio
What I've already tried and confirmed doesn't help
react-native-audio-api's Oboe output path with default Usage::Media — verified activePlaybackConfigurations shows usage=MEDIA, still earpiece.
AudioManager.setMode(MODE_NORMAL) — mode was already `NORMAL`; no change.
AudioManager.clearCommunicationDevice() — succeeded, flipped communicationDevice from BUILTIN_SPEAKER to BUILTIN_EARPIECE (the OS default); no effect on media routing.
AudioManager.setCommunicationDevice(BUILTIN_SPEAKER) — confirmed observation from initial state; media routing was still earpiece even when comm device was speaker.
AudioManager.setSpeakerphoneOn(true) — deprecated, but tried; no effect.
MediaPlayer.setPreferredDevice(BUILTIN_SPEAKER) — success=true; no audible change.
AudioManager.requestAudioFocus() with CONTENT_TYPE_MUSIC— AUDIOFOCUS_REQUEST_GRANTED; no audible change.
Foreground media service + setAudioModeAsync({playsInSilentMode: true, shouldPlayInBackground: true}) (via expo-audio) — service started, mode set; no audible change.
Diagnostic snapshot during playback (from AudioManager.activePlaybackConfigurations and activeRecordingConfigurations):
{ "activePlaybackConfigs": [ { "usage": "MEDIA", "contentType": "MUSIC", "audioDeviceType": null } ], "activeRecordingConfigs": [], "outputDevices": [ { "type": "BUILTIN_EARPIECE" }, { "type": "BUILTIN_SPEAKER" }, { "type": "TELEPHONY" } ], "mode": "NORMAL", "musicStreamVolume": 15, "musicStreamMaxVolume": 15, "isSpeakerphoneOn": false }
Two things I want to call out about that snapshot:
usage=MEDIA is present. So the OS sees the stream as media.
audioDeviceType=null on the playback config, even during playback. On other Android devices I've tested (Pixel emulator), this field is populated with the actual routed device. On this device it's null for both MediaPlayer and react-native-audio-api output streams. I read this as: the HAL is managing routing outside the standard AudioTrack policy, and the standard reporting API can't see where it went.
What I'm asking
Has anyone seen this pattern before on a Motorola device — every routing API returns success and the HAL routes to the earpiece anyway? Specifically:
Is there an Android API I'm missing that would actually force routing on this device? Something that a HAL implementing the audio contract properly would honor?
Is audioDeviceType: null in AudioPlaybackConfiguration a known indicator of a specific HAL routing path (MMAP? low-latency shared? offload?) that would explain why it's making its own routing decisions?
Any known firmware bugs on the moto g stylus 5G (2024) audio HAL that would produce this specific pattern?
Any tricks for figuring out why the HAL is picking the earpiece when the app owns focus, has USAGE_MEDIA on the stream, and has pinned the preferred device to the speaker?
Self answer for posterity.
Root cause
MediaPlayer.setPreferredDevice() is silently ignored on this device (and, as it turns out, on some other Android devices with quirky audio HALs) unless an AudioRouting.OnRoutingChangedListener is registered on the MediaPlayer BEFORE setPreferredDevice() is called. With no routing listener attached, the API returns true and no routing happens. With a routing listener attached, the same API returns true and the routing actually lands on the requested device.
The listener body doesn't have to do anything meaningful — it's the act of registration that engages whatever OS routing pipeline honors the preferred device. My listener body just logs the routing-change event for observability.
Working code:
// Order matters. Register the routing listener BEFORE setPreferredDevice. player.addOnRoutingChangedListener({ router -> // Body is diagnostic-only — the OS behavior we want comes from the // registration itself, not from what the callback does. val routed = router.routedDevice Log.i(TAG, "Routing changed: ${routed?.type ?: "null"}") }, /* handler = */ null) val speaker = audioManager .getDevices(AudioManager.GET_DEVICES_OUTPUTS) .first { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } player.setPreferredDevice(speaker) // now honored player.start()
How I found it:
The pointer came from Forasoft's "How to Implement Audio Output Switching on Android (2026)" playbook, which recommends registering an OnRoutingChangedListener as part of a general 2026-era Android audio-routing strategy. The article frames it as part of an "8-second timeout fallback" pattern for OEM devices with slow routing — but the timeout turned out to be irrelevant on my device. The routing-changed callback fires ~80ms *after* MediaPlayer.start(), not during any wait. What actually made routing work was just having the listener registered when setPreferredDevice() was called. I verified this empirically by shipping the fix first with a 3000ms wait, then reducing to 100ms (still worked), then removing the wait entirely (still worked). The load-bearing step is unambiguously the registration.
What this suggests about the HAL:
I can't prove it, but the pattern is consistent with the OS's routing negotiation being lazy — it only fully commits to a preferred-device request when there's a subscriber to routing-change events. Without a subscriber, setPreferredDevice() is accepted but never actually acted on, presumably as a no-op optimization by the audio policy manager. If anyone from AOSP or Motorola stumbles across this and can confirm/deny, I'd genuinely love to know.
Full working stack (in case anyone needs the whole thing rather than just the listener trick):
Set `AudioAttributes(USAGE_MEDIA, CONTENT_TYPE_MUSIC)` on the MediaPlayer.
Request music-content audio focus via `AudioManager.requestAudioFocus()`.
Register the `OnRoutingChangedListener` (the load-bearing step).
Call `setPreferredDevice()` with the target device (e.g. `BUILTIN_SPEAKER`, or an available Bluetooth A2DP / wired headset).
`player.start()`
On completion or teardown: release audio focus, remove the routing listener, release the MediaPlayer.
That entire stack is best-practice regardless of device. Steps 1, 2, and 4 are what standards-conformant devices need. Step 3 turned out to be what this device also needs.
Related evidence that this pattern isn't unique to Motorola
react-native-track-player issue #953 reports the same "audio only plays through ear speaker" symptom on Samsung Galaxy S4 and Samsung SM-A320FL, though the thread doesn't identify a fix. Worth trying this listener-registration workaround on those devices too.
Hope this saves someone a couple of days.