listen through the car's mic, fix exit on Uconnect, follow the car's night mode

This commit is contained in:
Seb3thehacker committed 2026-10-05 17:03:43 -07:00
1 parent 672a68cd1b
commit a4ab5b005e
4 files changed
+287 -12

No files matched your search

@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
import android.os.Build import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.car.CarAssistant import app.seb3thehacker.gearslip.car.CarAssistant
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys import app.seb3thehacker.gearslip.car.CarKeys
@@ -139,6 +140,9 @@ class GearslipRunner(
audioLink?.close() audioLink?.close()
audioLink = null audioLink = null
CarSensors.clear() CarSensors.clear()
CarMic.current = null
lastSensorNight = null
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
projection?.onProjectionStopped() projection?.onProjectionStopped()
SessionStatus.disconnected() SessionStatus.disconnected()
} }
@@ -172,6 +176,8 @@ class GearslipRunner(
onInputMessage(messageId, body) onInputMessage(messageId, body)
} else if (frame.channel == sensorChannelId) { } else if (frame.channel == sensorChannelId) {
onSensorMessage(messageId, body) onSensorMessage(messageId, body)
} else if (frame.channel == CarMic.current?.channelId) {
CarMic.current?.onMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) { } else if (frame.channel == audioLink?.channelId) {
// The audio channel is the one still being reverse-engineered, and its first few // The audio channel is the one still being reverse-engineered, and its first few
// replies are what tell us why. Raw bytes for those, then quiet. // replies are what tell us why. Raw bytes for those, then quiet.
@@ -346,7 +352,9 @@ class GearslipRunner(
private fun onServiceDiscoveryResponse(body: ByteArray) { private fun onServiceDiscoveryResponse(body: ByteArray) {
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)") log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
log.i("head unit describes itself as:\n" + Protobuf.describe(body)) // Field 5 is the car's serial number. Logs get shared, so it stays out of them.
val described = Protobuf.describe(body).replace(Regex("(?m)^ #5 string = .*$"), " #5 string = (car serial, left out)")
log.i("head unit describes itself as:\n" + described)
log.verdict( log.verdict(
"VIABLE", "VIABLE",
"The head unit accepted the presented phone certificate, completed TLS, and is " + "The head unit accepted the presented phone certificate, completed TLS, and is " +
@@ -405,6 +413,7 @@ class GearslipRunner(
openChannel(video.serviceId) openChannel(video.serviceId)
startAudioChannel(serviceDiscoveryResponse) startAudioChannel(serviceDiscoveryResponse)
startSensorChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse)
startMicChannel(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
if (input == null) { if (input == null) {
@@ -454,6 +463,18 @@ class GearslipRunner(
openChannel(media.serviceId) openChannel(media.serviceId)
} }
/** The car's microphone, for voice: opened now, recording only while something listens. */
private fun startMicChannel(serviceDiscoveryResponse: ByteArray) {
val mic = ServiceDiscovery.findMicService(serviceDiscoveryResponse) ?: run {
log.i("no microphone advertised - voice uses the phone's")
return
}
val config = mic.configs.first()
log.i("car microphone: channel=${mic.serviceId} ${mic.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}")
CarMic.current = CarMic(mic) { id, body -> send(id, body, encrypted = true, channel = mic.serviceId) }
openChannel(mic.serviceId)
}
/** /**
* Every sensor type the head unit advertised, subscribed to at once - there is no screen * Every sensor type the head unit advertised, subscribed to at once - there is no screen
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
@@ -500,6 +521,22 @@ class GearslipRunner(
} }
} }
/**
* The car's own day/night signal (its headlights or light sensor): NightData { bool is_night = 1; }.
* It replaces the clock guess, so maps go dark when the car does.
*/
private fun onNightSensor(body: ByteArray) {
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
if (isNight != lastSensorNight) {
lastSensorNight = isNight
log.i("car says it's ${if (isNight) "night" else "day"}")
CarEnvironment.setSensorNight(isNight)
}
}
private var lastSensorNight: Boolean? = null
private fun onSensorMessage(messageId: Int, body: ByteArray) { private fun onSensorMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> { MSG_CHANNEL_OPEN_RESPONSE -> {
@@ -527,6 +564,7 @@ class GearslipRunner(
MSG_SENSOR_EVENT_INDICATION -> { MSG_SENSOR_EVENT_INDICATION -> {
CarSensors.onEvent(body) CarSensors.onEvent(body)
logSensorEvent(body) logSensorEvent(body)
onNightSensor(body)
} }
// SensorError { required SensorType sensor_type = 1; required SensorErrorType sensor_error_type = 2; } // SensorError { required SensorType sensor_type = 1; required SensorErrorType sensor_error_type = 2; }
@@ -574,6 +612,12 @@ class GearslipRunner(
MSG_INPUT_REPORT -> { MSG_INPUT_REPORT -> {
val report = Wire.fields(body) val report = Wire.fields(body)
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
// touches, which looked like a crash. A touch means our screen is still up there.
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
log.w("the car said it took its screen back but still sends touches - showing ours again")
onVideoFocus(VIDEO_FOCUS_PROJECTED)
}
Wire.bytes(report, 4)?.let(::onKeyEvent) Wire.bytes(report, 4)?.let(::onKeyEvent)
Wire.bytes(report, 6)?.let(::onRelativeEvent) Wire.bytes(report, 6)?.let(::onRelativeEvent)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7) val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
@@ -898,21 +942,31 @@ class GearslipRunner(
inFlight = 0 inFlight = 0
ackLock.notifyAll() ackLock.notifyAll()
} }
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
Protobuf.varintField(2, (selectedConfigIndex ?: 0).toLong())
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
log.i("-> Start(session_id=$SESSION_ID)")
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) } lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
videoSource?.requestSyncFrame() videoSource?.requestSyncFrame()
} else { } else {
log.i("the car is showing its own screen - video paused, audio carries on") // A phone ends the stream when the car takes its screen. LIVI switches on the focus
// answer alone, but the 2018 Uconnect kept showing our last frame until it got Stop.
send(MSG_MEDIA_STOP, Protobuf.varintField(1, SESSION_ID.toLong()), encrypted = true, channel = videoChannelId)
log.i("the car is showing its own screen - video stopped, audio carries on")
} }
} }
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */ /** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
private fun requestNativeFocus() { private fun requestNativeFocus() {
if (!videoStarted) return if (!videoStarted) return
// VideoFocusRequestNotification { mode = 2; reason = 3 } // VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. The
val request = Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) + // display's channel is deprecated, but the 2018 Uconnect (protocol 1.3) predates that:
// without it, it agreed to show its own screen and never did. LIVI ignores the field.
val request = Protobuf.varintField(1, videoChannelId.toLong()) +
Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) +
Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong()) Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong())
send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId) send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId)
log.i("-> VideoFocusRequest(mode=NATIVE, reason=LAUNCH_NATIVE)") log.i("-> VideoFocusRequest(display=$videoChannelId, mode=NATIVE, reason=LAUNCH_NATIVE)")
} }
/** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */ /** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */
@@ -1137,6 +1191,7 @@ class GearslipRunner(
const val MSG_SENSOR_EVENT_INDICATION = 32771 const val MSG_SENSOR_EVENT_INDICATION = 32771
const val MSG_SENSOR_ERROR = 32772 const val MSG_SENSOR_ERROR = 32772
private const val SENSOR_LOCATION = 1 private const val SENSOR_LOCATION = 1
private const val SENSOR_NIGHT = 10
private const val SENSOR_LOG_EVERY_MS = 10_000L private const val SENSOR_LOG_EVERY_MS = 10_000L
const val STREAM_MEDIA = 3 const val STREAM_MEDIA = 3
@@ -110,6 +110,34 @@ object ServiceDiscovery {
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull() return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
} }
/**
* The car's microphone, from MediaSourceService (Service field 5):
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
* bool available_while_in_call = 3; }
* Null when the head unit has none.
*/
fun findMicService(response: ByteArray): AudioService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
return AudioService(
serviceId = id,
streamType = 0,
codecType = Wire.varint(source, 1)?.toInt() ?: CODEC_PCM,
configs = listOf(
AudioConfig(
sampleRate = Wire.varint(f, 1)?.toInt() ?: 16_000,
bits = Wire.varint(f, 2)?.toInt() ?: 16,
channels = Wire.varint(f, 3)?.toInt() ?: 1,
),
),
)
}
return null
}
const val STREAM_MEDIA = 3 const val STREAM_MEDIA = 3
const val CODEC_PCM = 1 const val CODEC_PCM = 1
@@ -0,0 +1,129 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
/**
* The car's own microphone, which most head units offer over the same link as the screen:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> Setup { config_index } <- Config
* -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id }
* <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack }
* -> MicrophoneRequest { open = false }
*
* The channel opens with the session, but the car only records between [start] and [stop].
* Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and
* one that doesn't isn't used, so voice falls back to the phone's microphone.
*/
class CarMic(
private val service: ServiceDiscovery.AudioService,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
val channelId get() = service.serviceId
private val config = service.configs.first()
private val step = config.sampleRate / RATE
private val usable = service.codecType == ServiceDiscovery.CODEC_PCM && config.bits == 16 &&
config.sampleRate % RATE == 0 && step >= 1 && config.channels in 1..2
@Volatile private var ready = false
@Volatile private var listening = false
@Volatile private var session = 0
private var packets = 0
private val chunks = LinkedBlockingQueue<ShortArray>()
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
GearslipLog.i("mic: <- ChannelOpenResponse: status=$status")
if (status != 0) return
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
ready = usable
if (!usable) {
GearslipLog.w(
"mic: the car records ${service.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}, " +
"which speech can't use - voice stays on the phone's microphone",
)
}
}
MSG_CONFIG -> GearslipLog.i("mic: <- Config status=${Wire.varint(Wire.fields(body), 1)}")
MSG_MIC_RESPONSE -> {
val fields = Wire.fields(body)
session = Wire.varint(fields, 2)?.toInt() ?: 0
GearslipLog.i("mic: <- MicrophoneResponse status=${Wire.varint(fields, 1)} session=$session")
}
MSG_DATA -> {
sendOnChannel(MSG_ACK, Protobuf.varintField(1, session.toLong()) + Protobuf.varintField(2, 1L))
if (!listening || body.size <= TIMESTAMP_BYTES) return
if (packets++ == 0) GearslipLog.i("mic: first audio from the car (${body.size - TIMESTAMP_BYTES} bytes)")
chunks.offer(toSpeechRate(body))
}
else -> GearslipLog.i("mic: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
/** Little-endian PCM after the timestamp, mixed to mono and averaged down to [RATE]. */
private fun toSpeechRate(body: ByteArray): ShortArray {
val frameBytes = 2 * config.channels
val frames = (body.size - TIMESTAMP_BYTES) / frameBytes
val out = ShortArray(frames / step)
for (i in out.indices) {
var sum = 0
for (s in 0 until step) {
val at = TIMESTAMP_BYTES + (i * step + s) * frameBytes
for (c in 0 until config.channels) {
val o = at + 2 * c
sum += (body[o].toInt() and 0xFF) or (body[o + 1].toInt() shl 8)
}
}
out[i] = (sum / (step * config.channels)).toShort()
}
return out
}
/** Asks the car to start recording. False when it can't, so the phone's microphone is used. */
fun start(): Boolean {
if (!ready) return false
chunks.clear()
packets = 0
listening = true
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L))
GearslipLog.i("mic: -> MicrophoneRequest(open)")
return true
}
/** The next piece of audio, or null if the car sent nothing for [timeoutMs]. */
fun read(timeoutMs: Long): ShortArray? = chunks.poll(timeoutMs, TimeUnit.MILLISECONDS)
fun stop() {
if (!listening) return
listening = false
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L))
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets")
}
companion object {
/** The connected car's microphone, or null with no car or one that has none. */
@Volatile var current: CarMic? = null
const val RATE = 16_000
private const val TIMESTAMP_BYTES = 8
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_DATA = 0
private const val MSG_SETUP = 32768
private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772
private const val MSG_MIC_REQUEST = 32773
private const val MSG_MIC_RESPONSE = 32774
}
}
@@ -14,6 +14,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.audio.CarMic
import java.util.Locale import java.util.Locale
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.log10 import kotlin.math.log10
@@ -25,7 +26,8 @@ import kotlin.math.sqrt
* none). whisper.cpp runs FUTO's ACFT Whisper model, which are tuned for short phrases: a reply * none). whisper.cpp runs FUTO's ACFT Whisper model, which are tuned for short phrases: a reply
* of a few seconds comes back in well under a second on a recent phone. * of a few seconds comes back in well under a second on a recent phone.
* *
* Whisper needs the whole phrase before it starts, so this records from the phone's microphone, * Whisper needs the whole phrase before it starts, so this records from the car's microphone (the
* phone's when the car has none or sends nothing),
* decides when the driver has stopped talking (a stretch of quiet after speech), then transcribes * decides when the driver has stopped talking (a stretch of quiet after speech), then transcribes
* once. Everything runs on one worker thread; callbacks arrive on the main thread. * once. Everything runs on one worker thread; callbacks arrive on the main thread.
*/ */
@@ -45,6 +47,9 @@ object SpeechEngine {
private const val CALIBRATE_MS = 300L private const val CALIBRATE_MS = 300L
private const val PRE_ROLL = RATE * 3 / 10 // keep 300 ms before the first word private const val PRE_ROLL = RATE * 3 / 10 // keep 300 ms before the first word
private const val CUE_VOLUME = 80 private const val CUE_VOLUME = 80
/** How long the car gets to start sending once asked, before the phone's microphone takes over. */
private const val CAR_FIRST_AUDIO_MS = 1_500L
private const val CAR_GAP_MS = 1_000L
private const val CUE_MS = 150 private const val CUE_MS = 150
/** The car plays what the phone sends a moment late; waiting this out keeps the beep off the recording. */ /** The car plays what the phone sends a moment late; waiting this out keeps the beep off the recording. */
private const val CUE_TAIL_MS = 250L private const val CUE_TAIL_MS = 250L
@@ -132,14 +137,73 @@ object SpeechEngine {
* or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a * or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a
* loud cabin needs a louder voice than a quiet one to count as talking. * loud cabin needs a louder voice than a quiet one to count as talking.
*/ */
/** Where a phrase is recorded from: [read] fills [frame] and returns how many samples it got. */
private interface Mic {
fun read(frame: ShortArray): Int
fun close()
}
@SuppressLint("MissingPermission") // checked in listen() @SuppressLint("MissingPermission") // checked in listen()
private fun record(id: Int, onSpeaking: () -> Unit): FloatArray? { private fun phoneMic(): Mic {
val min = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) val min = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val recorder = AudioRecord( val recorder = AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE, MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8), AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8),
) )
check(recorder.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord didn't initialize" } check(recorder.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord didn't initialize" }
recorder.startRecording()
return object : Mic {
override fun read(frame: ShortArray) = recorder.read(frame, 0, frame.size)
override fun close() {
runCatching { recorder.stop() }
recorder.release()
}
}
}
/**
* The car's microphone, which hears the driver better than a phone in a cup holder. The car
* sends audio in its own sized pieces; they're cut to [FRAME]s here. Null when there's no car
* microphone, or it sends nothing at first, so the phone's is used instead.
*/
private fun carMic(): Mic? {
val car = CarMic.current ?: return null
if (!car.start()) return null
val first = car.read(CAR_FIRST_AUDIO_MS)
if (first == null) {
car.stop()
GearslipLog.w("speech: the car's microphone sent nothing - using the phone's")
return null
}
GearslipLog.i("speech: listening through the car's microphone")
return object : Mic {
var pending = first
var at = 0
override fun read(frame: ShortArray): Int {
var filled = 0
while (filled < frame.size) {
val chunk = pending ?: car.read(CAR_GAP_MS) ?: break
pending = chunk
val n = minOf(frame.size - filled, chunk.size - at)
System.arraycopy(chunk, at, frame, filled, n)
filled += n
at += n
if (at == chunk.size) { pending = null; at = 0 }
}
// The car stopped sending mid-phrase: end the recording rather than wait forever.
return if (filled == 0) -1 else filled
}
override fun close() = car.stop()
}
}
/**
* Records until the driver stops talking. Returns the phrase from just before the first word,
* or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a
* loud cabin needs a louder voice than a quiet one to count as talking.
*/
private fun record(id: Int, onSpeaking: () -> Unit): FloatArray? {
val mic = carMic() ?: phoneMic()
val samples = FloatArray((RATE * MAX_MS / 1000).toInt()) val samples = FloatArray((RATE * MAX_MS / 1000).toInt())
var count = 0 var count = 0
val frame = ShortArray(FRAME) val frame = ShortArray(FRAME)
@@ -147,11 +211,11 @@ object SpeechEngine {
var noiseFrames = 0 var noiseFrames = 0
var speechStart = -1 var speechStart = -1
var lastSpeech = 0 var lastSpeech = 0
recorder.startRecording()
try { try {
while (id == run && count + FRAME <= samples.size) { while (id == run && count + FRAME <= samples.size) {
val n = recorder.read(frame, 0, FRAME) val n = mic.read(frame)
if (n <= 0) continue if (n < 0) break
if (n == 0) continue
var sum = 0.0 var sum = 0.0
for (i in 0 until n) { for (i in 0 until n) {
val v = frame[i] / 32768f val v = frame[i] / 32768f
@@ -178,8 +242,7 @@ object SpeechEngine {
if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break
} }
} finally { } finally {
runCatching { recorder.stop() } mic.close()
recorder.release()
} }
if (speechStart < 0) return null if (speechStart < 0) return null
return samples.copyOfRange(speechStart, count) return samples.copyOfRange(speechStart, count)