fix(nests): play audio-room output through STREAM_MUSIC, not STREAM_VOICE_CALL

`AudioTrackPlayer` was constructing its `AudioTrack` with
`USAGE_VOICE_COMMUNICATION` + `CONTENT_TYPE_SPEECH` so the room would
behave like a phone call (volume rocker controls call volume, ducks
notifications). The `AudioTrack` then renders on `STREAM_VOICE_CALL`,
which Android only services audibly while the device is in
`MODE_IN_COMMUNICATION`. Nests never drives `AudioManager.mode` (only
NIP-100's `CallAudioManager` does), so on most devices the playback
either dropped to the earpiece at near-zero volume or produced no audio
at all — making rooms appear silent on both phones.

Fix: route through `USAGE_MEDIA` + `CONTENT_TYPE_SPEECH` (=
`STREAM_MUSIC`) so audio-room playback comes out of the loudspeaker by
default and the volume rocker controls it any time. Same approach
Twitter/X Spaces and Clubhouse take for hands-free audio rooms. Echo
cancellation still works on the capture side via
`MediaRecorder.AudioSource.VOICE_COMMUNICATION` regardless of the
playback usage.

Also update `NestForegroundService.requestAudioFocus` to request focus
under the matching `USAGE_MEDIA` attributes so the focus claim and the
playback attributes line up.
This commit is contained in:
Claude
2026-05-01 20:50:12 +00:00
parent e6d755a7ab
commit 523e8a92ec
2 changed files with 24 additions and 9 deletions
@@ -74,6 +74,11 @@ class NestForegroundService : Service() {
* call lowers the room volume cleanly instead of mixing two
* voices on top of each other. Acquired once per service
* lifetime; released in [onDestroy].
*
* Matches the playback `AudioAttributes` we set on `AudioTrack`
* in `AudioTrackPlayer` (USAGE_MEDIA + CONTENT_TYPE_SPEECH) so
* the focus request applies to the same stream the audio
* actually renders on.
*/
private fun requestAudioFocus() {
if (audioFocusRequest != null) return
@@ -81,7 +86,7 @@ class NestForegroundService : Service() {
val attrs =
android.media.AudioAttributes
.Builder()
.setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val request =
@@ -21,23 +21,36 @@
package com.vitorpamplona.nestsclient.audio
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.AudioTrack
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.media.AudioFormat as AndroidAudioFormat
/**
* [AudioPlayer] backed by Android's [AudioTrack] in `MODE_STREAM`. Targets the
* voice-call usage stream so the OS treats audio-room playback like a phone
* call (volume rocker controls call volume, ducks notifications, etc.).
* [AudioPlayer] backed by Android's [AudioTrack] in `MODE_STREAM`.
*
* Routes through `USAGE_MEDIA` + `CONTENT_TYPE_SPEECH` (= `STREAM_MUSIC`)
* so audio-room playback comes out of the loudspeaker by default and the
* volume rocker controls media volume — same approach Twitter/X Spaces
* and Clubhouse use for hands-free audio rooms.
*
* Originally this used `USAGE_VOICE_COMMUNICATION` to get call-style
* volume / ducking, but that routes through `STREAM_VOICE_CALL`, which
* Android only services audibly while the device is in
* `MODE_IN_COMMUNICATION`. Nests doesn't drive `AudioManager.mode`
* (only the NIP-100 `CallAudioManager` does), so the playback either
* dropped to the earpiece at near-zero volume or produced no audio at
* all depending on the device — making rooms appear silent on both
* phones. Echo cancellation still works on the capture side via
* `MediaRecorder.AudioSource.VOICE_COMMUNICATION` regardless of the
* playback usage.
*
* Buffer sizing: 4× minimum so the producer can fall behind by ~80 ms before
* dropouts, which roughly matches the jitter the WebTransport datagram path
* introduces over typical mobile networks.
*/
class AudioTrackPlayer(
private val usage: Int = AudioAttributes.USAGE_VOICE_COMMUNICATION,
private val usage: Int = AudioAttributes.USAGE_MEDIA,
private val contentType: Int = AudioAttributes.CONTENT_TYPE_SPEECH,
) : AudioPlayer {
private var track: AudioTrack? = null
@@ -144,9 +157,6 @@ class AudioTrackPlayer(
runCatching { t.release() }
}
@Suppress("unused")
val voiceCallUsage: Int get() = AudioManager.STREAM_VOICE_CALL // kept for documentation
private fun applyMuteVolume(track: AudioTrack) {
// setVolume is preferred over pause(): it keeps the streaming pipeline
// running so unmute is sample-accurate and there's no AudioTrack-restart