Route guidance audio through a selectable navigation stream type

CarPlay marks guidance audio apart from media, but the sink always built
its AudioTrack from usage/content-type attributes. On head units that
expose navigation on a dedicated mixer output, guidance shares the media
channels, so the next turn is announced over the music in every seat.
BYD puts navigation on stream type 14, the driver-side speaker.

Add an "Audio routing" section with a 0-20 stream type grid and a test
tone, persist the selection, and build the AudioTrack on that stream
type. When the ROM rejects the stream type the track is rebuilt from
usage attributes, so devices without such an output keep today's
behavior (the diagnostic log reports route=usage or
route=streamType=N(fallback=usage)).

The advanced audio channel mapping switch is now visible on the mobile
flavor too, still off by default, so usage/content-type routing can be
picked at runtime instead of by editing resources, and the automotive
map routes default and compatibility to the navigation bus rather than
sharing media.
This commit is contained in:
baileileicn committed 2026-09-29 01:00:43 +00:00
1 parent e6135d36e6
commit e85285e03d
8 files changed
+262 -32

No files matched your search

@@ -39,6 +39,7 @@ object AirPlayPersistence {
private const val KEY_HEVC_ENABLED = "hevc_enabled"
private const val KEY_HEVC_SOFTWARE_DECODER = "hevc_software_decoder"
private const val KEY_ADVANCED_AUDIO_CHANNEL_MAPPING = "advanced_audio_channel_mapping"
private const val KEY_NAVIGATION_STREAM_TYPE = "navigation_stream_type"
private const val KEY_WIRELESS_ENABLED = "wireless_enabled"
private const val KEY_WIRELESS_HOTSPOT_MODE = "wireless_hotspot_mode"
private const val KEY_MANUAL_HOTSPOT_SSID = "manual_hotspot_ssid"
@@ -131,6 +132,16 @@ object AirPlayPersistence {
.apply()
}
fun loadNavigationStreamType(context: Context): Int =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getInt(KEY_NAVIGATION_STREAM_TYPE, 14)
fun saveNavigationStreamType(context: Context, streamType: Int) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putInt(KEY_NAVIGATION_STREAM_TYPE, streamType)
.apply()
}
fun loadWirelessEnabled(context: Context): Boolean =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_WIRELESS_ENABLED, true)
@@ -275,6 +275,7 @@ class CarPlayHostActivity : ComponentActivity() {
private var hevcSoftwareDecoderEnabled = false
private var advancedAudioChannelMappingSupported = false
private var advancedAudioChannelMapping = false
private var navigationStreamType = 14
private var debugLogsEnabled = false
private var autoStartOnBoot = false
private var manufacturer = AirPlayPersistence.DEFAULT_MANUFACTURER
@@ -437,6 +438,7 @@ class CarPlayHostActivity : ComponentActivity() {
advancedAudioChannelMapping =
advancedAudioChannelMappingSupported &&
AirPlayPersistence.loadAdvancedAudioChannelMapping(this)
navigationStreamType = AirPlayPersistence.loadNavigationStreamType(this)
debugLogsEnabled = AirPlayPersistence.loadDebugLogsEnabled(this)
autoStartOnBoot = AirPlayPersistence.loadAutoStartOnBoot(this)
manufacturer = AirPlayPersistence.loadManufacturer(this)
@@ -561,6 +563,9 @@ class CarPlayHostActivity : ComponentActivity() {
requestLocationPermission()
}
wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions()
advancedAudioChannelMapping =
advancedAudioChannelMappingSupported &&
AirPlayPersistence.loadAdvancedAudioChannelMapping(this)
if (DiLink51ClusterLayout.automatic(this) && clusterMonitor == null) {
clusterMonitor = DiLink51ClusterMonitor(this, ::onClusterActivityState).also { it.start() }
} else if (!DiLink51ClusterLayout.automatic(this)) {
@@ -960,7 +965,7 @@ class CarPlayHostActivity : ComponentActivity() {
settingsSwitchRow(
label = "Advanced audio channel mapping",
checked = advancedAudioChannelMapping,
description = "Route AAOS audio buses by CarPlay audio type",
description = "Use usage / content-type routing instead of stream type",
) { checked ->
advancedAudioChannelMapping = checked
appendLog(
@@ -2913,6 +2918,7 @@ class CarPlayHostActivity : ComponentActivity() {
videoHeight = videoHeight,
preferSoftwareHevcDecoder = hevcSoftwareDecoderEnabled,
advancedAudioChannelMapping = advancedAudioChannelMapping,
navigationStreamType = navigationStreamType,
onScreenStreamActiveChanged = { type, active ->
onScreenStreamStateChanged(controllerGeneration, type, active)
},
@@ -12,12 +12,15 @@ import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.media.AudioFormat
import android.media.AudioTrack
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
@@ -50,6 +53,9 @@ class DiPlayActivity : ComponentActivity() {
private var initialLaunch = true
private var notificationTransport = true
private var exportInProgress = false
private var navigationStreamType = 14
private var testToneTrack: AudioTrack? = null
private var toneStop: Runnable? = null
private var exportButton: Button? = null
private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
connect(notificationTransport)
@@ -243,6 +249,42 @@ class DiPlayActivity : ComponentActivity() {
AirPlayPersistence.saveHideTopBar(this, it); AirPlayPersistence.saveHideBottomBar(this, it)
}
}
section(content, "Audio routing") { card ->
val channelTitle = label("Navigation stream type", 18, TEXT, true)
val channelHint = label("Tap a number to test. 14 = driver speaker on BYD.", 14, MUTED)
val grid = channelSelector()
val saveButton = button("Save", true) {
AirPlayPersistence.saveNavigationStreamType(this, navigationStreamType)
toast("Saved: $navigationStreamType")
}
fun applyChannelEnabled(enabled: Boolean) {
val alpha = if (enabled) 1f else 0.4f
listOf(channelTitle, channelHint, saveButton).forEach {
it.isEnabled = enabled
it.alpha = alpha
}
for (i in 0 until grid.childCount) {
grid.getChildAt(i).let { child ->
child.isEnabled = enabled
child.alpha = alpha
}
}
}
val aaosSupported = resources.getBoolean(R.bool.config_advanced_audio_channel_mapping)
if (aaosSupported) {
toggle(card, "Advanced audio channel mapping",
"Use usage / content-type routing instead of stream type",
AirPlayPersistence.loadAdvancedAudioChannelMapping(this)) {
AirPlayPersistence.saveAdvancedAudioChannelMapping(this, it)
applyChannelEnabled(!it)
}
}
card.addView(channelTitle)
card.addView(channelHint)
card.addView(grid)
card.addView(saveButton, matchButton(12, 56))
if (aaosSupported) applyChannelEnabled(!AirPlayPersistence.loadAdvancedAudioChannelMapping(this))
}
if (com.shilapi.xcertplay.hud.BydOutputSettings.available(this)) section(content, "BYD navigation", R.drawable.ic_dp_navigation) { card ->
toggle(card, "Navigation on HUD and instrument cluster",
"Show phone navigation arrows, distance and street names on supported BYD displays. Vehicle compatibility varies.",
@@ -799,6 +841,90 @@ class DiPlayActivity : ComponentActivity() {
}
private fun openSystem(intent: Intent) { runCatching { startActivity(intent) }.onFailure { toast("Open this setting from your car’s Settings app.") } }
private fun toast(message: String) { Toast.makeText(this, message, Toast.LENGTH_LONG).show() }
private fun playTestTone(streamType: Int) {
toneStop?.let { handler.removeCallbacks(it) }
toneStop = null
testToneTrack?.let { runCatching { it.stop(); it.release() } }
testToneTrack = null
val pcm = assets.open("navigation_test.pcm").use { it.readBytes() }
val track = AudioTrack(
streamType,
44100,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
pcm.size,
AudioTrack.MODE_STREAM
)
if (track.state != AudioTrack.STATE_INITIALIZED) {
Log.w("DiPlay", "playTestTone streamType=$streamType state=${track.state}")
track.release()
toast("Stream type $streamType is unavailable here (state=${track.state})")
return
}
track.write(pcm, 0, pcm.size)
track.play()
Log.i("DiPlay", "playTestTone streamType=$streamType state=${track.state} playState=${track.playState}")
testToneTrack = track
val stop = Runnable {
track.stop()
track.release()
if (testToneTrack === track) testToneTrack = null
toneStop = null
}
toneStop = stop
handler.postDelayed(stop, 4500)
}
private val channelButtons = mutableListOf<Button>()
private fun paintChannel(index: Int, selected: Boolean) {
val target = channelButtons.getOrNull(index) ?: return
target.isSelected = selected
target.setTextColor(if (selected) BG else TEXT)
target.background = android.graphics.drawable.RippleDrawable(
ColorStateList.valueOf(0x336F9FD9),
rounded(if (selected) ACCENT else SURFACE, if (selected) ACCENT else BORDER),
null
)
}
private fun channelSelector(): ViewGroup {
channelButtons.clear()
val grid = GridLayout(this).apply {
columnCount = 7
rowCount = 3
setPadding(0, dp(8), 0, dp(8))
}
for (i in 0..20) {
val btn = Button(this).apply {
text = i.toString()
isAllCaps = false
textSize = 16f
minHeight = dp(48)
stateListAnimator = null
setOnClickListener {
val previous = navigationStreamType
navigationStreamType = i
if (previous != i) {
paintChannel(previous, false)
paintChannel(i, true)
}
playTestTone(i)
}
}
val params = GridLayout.LayoutParams().apply {
width = 0
height = dp(48)
columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
setMargins(dp(4), dp(4), dp(4), dp(4))
}
grid.addView(btn, params)
channelButtons.add(btn)
paintChannel(i, i == navigationStreamType)
}
return grid
}
private fun version() = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.1.0-beta.1"
private fun section(parent: LinearLayout, title: String, icon: Int? = null, build: (LinearLayout) -> Unit) {
val card = card()
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<bool name="config_advanced_audio_channel_mapping">false</bool>
<bool name="config_advanced_audio_channel_mapping">true</bool>
</resources>
Binary file not shown.
@@ -36,6 +36,7 @@ class AndroidMediaSink(
private val videoHeight: Int = 720,
private val preferSoftwareHevcDecoder: Boolean = false,
private val advancedAudioChannelMapping: Boolean = false,
private val navigationStreamType: Int = AudioChannelMapper.DEFAULT_NAVIGATION_STREAM_TYPE,
onScreenStreamActiveChanged: ((Int, Boolean) -> Unit)? = null,
private val mediaBufferMillis: Int = MediaAudioBuffer.DEFAULT_MILLIS,
private val onAudioDiagnostic: (String) -> Unit = {},
@@ -173,7 +174,7 @@ class AndroidMediaSink(
val existing = audioRenderers[type]
if (existing?.format == format) return existing
existing?.close()
return AudioRenderer(format, advancedAudioChannelMapping, mediaBufferMillis, onAudioDiagnostic).also { audioRenderers[type] = it }
return AudioRenderer(format, advancedAudioChannelMapping, navigationStreamType, mediaBufferMillis, onAudioDiagnostic).also { audioRenderers[type] = it }
}
}
@@ -497,6 +498,7 @@ private fun MediaFormat.intOrNull(key: String): Int? =
private class AudioRenderer(
val format: AudioFormat,
private val advancedAudioChannelMapping: Boolean,
private val navigationStreamType: Int,
private val mediaBufferMillis: Int,
private val report: (String) -> Unit,
) : Closeable {
@@ -637,33 +639,78 @@ private class AudioRenderer(
val plan = MediaAudioBuffer.plan(format.audioType, format.sampleRate, format.channels, minBuffer, mediaBufferMillis)
val frameBytes = if (format.channels >= 2) 4 else 2
bytesPerSecond = format.sampleRate * frameBytes
val built = AudioTrack.Builder()
.setAudioAttributes(audioAttributes())
.setAudioFormat(
AndroidAudioFormat.Builder()
.setEncoding(encoding)
.setSampleRate(format.sampleRate)
.setChannelMask(channelMask)
.build(),
val built: AudioTrack
var routeLabel: String
if (advancedAudioChannelMapping) {
val attributes = audioAttributes()
routeLabel = "usage"
built = AudioTrack.Builder()
.setAudioAttributes(attributes)
.setAudioFormat(pcmFormat(encoding, channelMask))
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(plan.trackBufferBytes)
.build()
} else {
val streamType = streamType()
routeLabel = "streamType=$streamType"
var candidate = AudioTrack(
streamType,
format.sampleRate,
channelMask,
encoding,
plan.trackBufferBytes,
AudioTrack.MODE_STREAM,
)
.setBufferSizeInBytes(plan.trackBufferBytes)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
if (candidate.state != AudioTrack.STATE_INITIALIZED) {
candidate.release()
candidate = AudioTrack.Builder()
.setAudioAttributes(audioAttributes())
.setAudioFormat(pcmFormat(encoding, channelMask))
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(plan.trackBufferBytes)
.build()
routeLabel = "streamType=$streamType(fallback=usage)"
Log.w(TAG, "streamType=$streamType rejected by this ROM; falling back to usage-based track")
}
built = candidate
}
track = built
val capacityBytes = built.bufferSizeInFrames * frameBytes
startThresholdBytes = MediaAudioBuffer.startBytesFor(plan.startBytes, capacityBytes, PREBUFFER_WRITE_CHUNK_BYTES)
report("Audio: ready audioType=${format.audioType} codec=${format.codec} " +
"rate=${format.sampleRate} channels=${format.channels} " +
"route=$routeLabel " +
"bufferMs=${capacityBytes * 1000L / bytesPerSecond} startMs=${startThresholdBytes * 1000L / bytesPerSecond}")
Log.i(
TAG,
"audio track prepared type=${format.payloadType} audioType=${format.audioType} " +
"codec=${format.codec} " +
"rate=${format.sampleRate} channels=${format.channels} " +
"buffer=${capacityBytes * 1000L / bytesPerSecond}ms start=${startThresholdBytes * 1000L / bytesPerSecond}ms",
"route=$routeLabel " +
"buffer=${capacityBytes * 1000L / bytesPerSecond}ms start=${startThresholdBytes * 1000L / bytesPerSecond}",
)
}
private fun pcmFormat(encoding: Int, channelMask: Int) = AndroidAudioFormat.Builder()
.setSampleRate(format.sampleRate)
.setChannelMask(channelMask)
.setEncoding(encoding)
.build()
private fun streamType(): Int {
val mode = if (advancedAudioChannelMapping) {
AudioChannelMappingMode.AUTOMOTIVE_BUS
} else {
AudioChannelMappingMode.MOBILE_COMPATIBLE
}
return AudioChannelMapper.map(
audioType = format.audioType,
payloadType = format.payloadType,
mode = mode,
navigationStreamType = navigationStreamType,
).streamType
}
private fun aacAudioSpecificConfig(): ByteArray {
val frequencyIndex = MediaCodecSupport.aacFrequencyIndex(format.sampleRate)
val value = (AAC_OBJECT_TYPE_LC shl 11) or
@@ -1,5 +1,7 @@
package com.shilapi.xcertplay.media
import android.media.AudioManager
internal enum class AudioChannelMappingMode {
MOBILE_COMPATIBLE,
AUTOMOTIVE_BUS,
@@ -20,59 +22,67 @@ internal enum class AudioContentType {
internal data class AudioChannelSelection(
val channel: AudioChannel,
val contentType: AudioContentType,
val streamType: Int = AudioManager.STREAM_MUSIC,
)
/**
* Maps CarPlay stream metadata to Android AudioAttributes values.
* Maps CarPlay stream metadata to Android audio routing values.
*
* AAOS routes AudioTrack instances by usage, so the automotive mode keeps generic media streams
* on the media bus instead of reusing mobile's navigation fallback for default/compatibility.
* Each selection carries the [AudioChannel] / [AudioContentType] pair used for usage-based
* AudioAttributes, plus the stream type used when the stream-type AudioTrack path is active.
* Guidance audio always resolves to [AudioChannel.NAVIGATION] so it can be routed away from the
* media bus, and `default` / `compatibility` follow it in the automotive mode rather than
* sharing the media bus.
*/
internal object AudioChannelMapper {
const val STREAM_TYPE_MAIN_HIGH_AUDIO = 102
const val DEFAULT_NAVIGATION_STREAM_TYPE = 14
fun map(
audioType: String,
payloadType: Int,
mode: AudioChannelMappingMode,
navigationStreamType: Int = DEFAULT_NAVIGATION_STREAM_TYPE,
): AudioChannelSelection {
val normalized = audioType.lowercase()
return when (mode) {
AudioChannelMappingMode.MOBILE_COMPATIBLE -> mapMobileCompatible(normalized, payloadType)
AudioChannelMappingMode.AUTOMOTIVE_BUS -> mapAutomotiveBus(normalized, payloadType)
AudioChannelMappingMode.MOBILE_COMPATIBLE -> mapMobileCompatible(normalized, payloadType, navigationStreamType)
AudioChannelMappingMode.AUTOMOTIVE_BUS -> mapAutomotiveBus(normalized, payloadType, navigationStreamType)
}
}
private fun mapMobileCompatible(
audioType: String,
payloadType: Int,
navigationStreamType: Int,
): AudioChannelSelection = when (audioType) {
"telephony" -> AudioChannelSelection(AudioChannel.PHONE, AudioContentType.SPEECH)
"speechrecognition" ->
AudioChannelSelection(AudioChannel.ASSISTANT, AudioContentType.SPEECH)
"media" -> AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC)
"default", "alert", "compatibility" ->
AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH)
else -> mainHighAudioOrNavigation(payloadType)
AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH, navigationStreamType)
else -> mainHighAudioOrNavigation(payloadType, navigationStreamType)
}
private fun mapAutomotiveBus(
audioType: String,
payloadType: Int,
navigationStreamType: Int,
): AudioChannelSelection = when (audioType) {
"telephony" -> AudioChannelSelection(AudioChannel.PHONE, AudioContentType.SPEECH)
"speechrecognition" ->
AudioChannelSelection(AudioChannel.ASSISTANT, AudioContentType.SPEECH)
"media", "default", "compatibility" ->
AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC)
"alert" -> AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH)
else -> mainHighAudioOrNavigation(payloadType)
"media" -> AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC)
"alert", "default", "compatibility" ->
AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH, navigationStreamType)
else -> mainHighAudioOrNavigation(payloadType, navigationStreamType)
}
private fun mainHighAudioOrNavigation(payloadType: Int): AudioChannelSelection =
private fun mainHighAudioOrNavigation(payloadType: Int, navigationStreamType: Int): AudioChannelSelection =
if (payloadType == STREAM_TYPE_MAIN_HIGH_AUDIO) {
AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC)
} else {
AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH)
AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH, navigationStreamType)
}
}
@@ -1,5 +1,6 @@
package com.shilapi.xcertplay.media
import android.media.AudioManager
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -34,19 +35,28 @@ class AudioChannelMappingTest {
payloadType = 100,
channel = AudioChannel.NAVIGATION,
contentType = AudioContentType.SPEECH,
streamType = AudioChannelMapper.DEFAULT_NAVIGATION_STREAM_TYPE,
)
}
}
@Test
fun automotiveMappingUsesTheBusSpecificCarPlayTypes() {
listOf("media", "default", "compatibility").forEach { audioType ->
assertMapped(
mode = AudioChannelMappingMode.AUTOMOTIVE_BUS,
audioType = "media",
payloadType = 100,
channel = AudioChannel.MEDIA,
contentType = AudioContentType.MUSIC,
)
listOf("default", "compatibility").forEach { audioType ->
assertMapped(
mode = AudioChannelMappingMode.AUTOMOTIVE_BUS,
audioType = audioType,
payloadType = 100,
channel = AudioChannel.MEDIA,
contentType = AudioContentType.MUSIC,
channel = AudioChannel.NAVIGATION,
contentType = AudioContentType.SPEECH,
streamType = AudioChannelMapper.DEFAULT_NAVIGATION_STREAM_TYPE,
)
}
assertMapped(
@@ -69,6 +79,7 @@ class AudioChannelMappingTest {
payloadType = 100,
channel = AudioChannel.NAVIGATION,
contentType = AudioContentType.SPEECH,
streamType = AudioChannelMapper.DEFAULT_NAVIGATION_STREAM_TYPE,
)
}
@@ -87,6 +98,24 @@ class AudioChannelMappingTest {
payloadType = 100,
channel = AudioChannel.NAVIGATION,
contentType = AudioContentType.SPEECH,
streamType = AudioChannelMapper.DEFAULT_NAVIGATION_STREAM_TYPE,
)
}
@Test
fun navigationStreamTypeIsOnlyAppliedToGuidanceChannels() {
val custom = 7
assertEquals(
AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH, custom),
AudioChannelMapper.map("alert", 100, AudioChannelMappingMode.MOBILE_COMPATIBLE, custom),
)
assertEquals(
AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH, custom),
AudioChannelMapper.map("default", 100, AudioChannelMappingMode.AUTOMOTIVE_BUS, custom),
)
assertEquals(
AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC, AudioManager.STREAM_MUSIC),
AudioChannelMapper.map("media", 100, AudioChannelMappingMode.AUTOMOTIVE_BUS, custom),
)
}
@@ -96,9 +125,10 @@ class AudioChannelMappingTest {
payloadType: Int,
channel: AudioChannel,
contentType: AudioContentType,
streamType: Int = AudioManager.STREAM_MUSIC,
) {
assertEquals(
AudioChannelSelection(channel, contentType),
AudioChannelSelection(channel, contentType, streamType),
AudioChannelMapper.map(audioType, payloadType, mode),
)
}