fix silent audio on cars with an AAC sink, keep location out of logs

Cars like the 2022 Dacia Jogger list a compressed audio channel before the raw one, and Gearslip was sending raw audio to it. The Exit tile no longer shows as running, and the log leaves out the weather town and the car's GPS.
This commit is contained in:
Seb3thehacker committed 2026-10-05 01:21:16 -07:00
1 parent cf0c324d80
commit 7d197bf021
5 files changed
+72 -8

No files matched your search

@@ -357,6 +357,7 @@ class GearslipRunner(
val info = ServiceDiscovery.findHeadUnitInfo(body)
log.i("head unit: $info")
SessionReport.headUnit(info.toString())
SessionReport.headUnitInfo = info
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile
SessionReport.profile(profile?.name)
@@ -430,13 +431,16 @@ class GearslipRunner(
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
)
}
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
val media = ServiceDiscovery.pickMediaSink(sinks) ?: run {
log.w("no media audio sink advertised - media apps will play on the phone only")
SessionReport.audio(
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
)
return
}
if (media.codecType != ServiceDiscovery.CODEC_PCM) {
log.w("the car offers no PCM media sink, only ${media.codecName}; it may play our PCM as silence")
}
val first = media.configs.firstOrNull()
SessionReport.audio(
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
@@ -481,7 +485,11 @@ class GearslipRunner(
private fun logSensorEvent(body: ByteArray) {
val types = Wire.fields(body).map { it.number }.toSet()
if (sensorTypesLogged.addAll(types)) log.i("<- SensorEvent (first of its kind):\n" + Protobuf.describe(body))
if (sensorTypesLogged.addAll(types)) {
// Type 1 is the car's GPS position; logs get shared, so say it arrived and nothing more.
if (SENSOR_LOCATION in types) log.i("<- SensorEvent (first of its kind): types=$types, location left out")
else log.i("<- SensorEvent (first of its kind):\n" + Protobuf.describe(body))
}
sensorEventsSinceLog++
val now = System.currentTimeMillis()
if (sensorLoggedAt == 0L) sensorLoggedAt = now
@@ -763,6 +771,7 @@ class GearslipRunner(
log.e("unsupported resolution ${config.resolutionName}")
return
}
config.visibleSize()?.let { (w, h) -> SessionReport.carScreen("${w}x$h", config.density) }
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
Protobuf.varintField(2, index.toLong())
@@ -1127,6 +1136,7 @@ class GearslipRunner(
const val MSG_SENSOR_START_RESPONSE = 32770
const val MSG_SENSOR_EVENT_INDICATION = 32771
const val MSG_SENSOR_ERROR = 32772
private const val SENSOR_LOCATION = 1
private const val SENSOR_LOG_EVERY_MS = 10_000L
const val STREAM_MEDIA = 3
@@ -99,6 +99,20 @@ object ServiceDiscovery {
}
}
/**
* The media sink to stream to. Gearslip sends raw PCM, so a PCM sink wins: the 2022 Dacia
* Jogger (LGE ULC 4.5) lists an AAC media sink on channel 3 before a PCM one on channel 5,
* and PCM pushed into the AAC sink was acked but played as silence. With no PCM sink, the
* first media sink is the best there is.
*/
fun pickMediaSink(sinks: List<AudioService>): AudioService? {
val media = sinks.filter { it.streamType == STREAM_MEDIA }
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
}
const val STREAM_MEDIA = 3
const val CODEC_PCM = 1
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
private val RESOLUTIONS = mapOf(
@@ -47,7 +47,6 @@ import androidx.compose.ui.graphics.vector.PathParser
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
internal const val BUILT_IN_PREFIX = "gearslip:"
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
/** Material's "directions car", drawn here because only the core icon set is bundled. */
private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath(
PathParser().parsePathString(
@@ -60,11 +59,15 @@ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f
fill = SolidColor(Color.Black),
).build()
/**
* One of Gearslip's own apps: a screen, with nothing running behind it once you leave. [screen] is
* null for an action like Exit, which has no screen and so never shows as open.
*/
internal class BuiltInApp(
val id: String,
val label: String,
val glyph: ImageVector,
val screen: CarScreen,
val screen: CarScreen?,
val open: (CarNavigator) -> Unit,
)
@@ -76,7 +79,7 @@ internal object BuiltInApps {
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
val Exit = BuiltInApp("exit", "Exit", CarGlyph, CarScreen.Home) { CarFocus.exitToCar() }
val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
@@ -128,7 +131,7 @@ internal fun rememberRunningApps(): Map<String, RunningSlot> {
else -> false
}
mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) }
BuiltInApps.all.firstOrNull { it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
BuiltInApps.all.firstOrNull { it.screen != null && it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
(current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
}
}
@@ -84,11 +84,13 @@ object Weather {
return
}
val data = runCatching { fetch(context, location) }
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
// Only the kind of error: some messages carry the request URL, and that holds the location.
.onFailure { GearslipLog.w("weather fetch failed: ${it.javaClass.simpleName}") }
.getOrNull()
if (data != null) {
flow.value = WeatherState.Ready(data)
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
// No place name: logs get shared in bug reports.
GearslipLog.i("weather: loaded")
}
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
}
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
@@ -12,6 +13,10 @@ import org.junit.Test
* video sink on channel 1 and a microphone source on channel 3. Picking the right one out of that
* set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent:
* audio simply never arrives.
*
* The 2022 Dacia Jogger (LGE ULC 4.5, protocol 1.5) offers four: AAC media on channel 3, AAC
* guidance on 4, PCM media on 5 and PCM guidance on 6. Gearslip sends PCM, so it has to pick
* channel 5 even though channel 3 comes first; PCM pushed into the AAC sink plays as silence.
*/
class ServiceDiscoveryAudioTest {
@@ -91,4 +96,34 @@ class ServiceDiscoveryAudioTest {
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId)
}
private fun daciaResponse(): ByteArray =
audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2)) +
audioSink(id = 4, codec = 4, audioType = 1, config = audioConfig(16_000, 16, 1)) +
audioSink(id = 5, codec = 1, audioType = 3, config = audioConfig(48_000, 16, 2)) +
audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1))
@Test
fun `the Uconnect's only media sink is picked`() {
assertEquals(5, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(uconnectResponse()))!!.serviceId)
}
@Test
fun `a PCM media sink wins over an earlier AAC one`() {
val sink = ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(daciaResponse()))!!
assertEquals(5, sink.serviceId)
assertEquals("AUDIO_PCM", sink.codecName)
}
@Test
fun `with only an AAC media sink, that one is used`() {
val aacOnly = audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2))
assertEquals(3, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(aacOnly))!!.serviceId)
}
@Test
fun `no media sink means none is picked`() {
val guidanceOnly = audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1))
assertNull(ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(guidanceOnly)))
}
}