diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 20acd9f..c54cb93 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -274,7 +274,12 @@ class GearslipRunner( MSG_PING_REQUEST -> onPingRequest(body) MSG_BYEBYE_REQUEST -> onByeByeRequest(body) MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId) - AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0) + AudioLink.MSG_FOCUS_RESPONSE -> { + // One answer for both sinks: it is the directions' while they wait on one. + val state = Protobuf.readInt32Field(body, 1) ?: 0 + val guidance = guidanceLink + if (guidance != null && guidance.expectsFocusAnswer()) guidance.onFocus(state) else audioLink?.onFocus(state) + } else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing") } } @@ -587,7 +592,7 @@ class GearslipRunner( tag = "guidance", sessionId = GUIDANCE_SESSION_ID, asksFocus = false, - onReady = { DirectionsVoice.attach(it) }, + onReady = { DirectionsVoice.attach(it) { audioLink?.streaming == true } }, onClose = { DirectionsVoice.detach(it) }, ) openChannel(guidance.serviceId) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt index e398b6d..675ab79 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt @@ -4,6 +4,8 @@ import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.Protobuf import app.seb3thehacker.gearslip.ServiceDiscovery import app.seb3thehacker.gearslip.Wire +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * The phone end of the head unit's media audio sink, the audio twin of the video channel: @@ -25,7 +27,10 @@ class AudioLink( /** Log name: "audio" for the media sink, "guidance" for the directions sink. */ private val tag: String = "audio", private val sessionId: Int = SESSION_ID, - /** Media asks the car for focus; directions just start, and the car ducks music under them. */ + /** + * Media asks the car for focus when it starts. Directions ask only around each announcement, + * through [askForSpeakers], and only when the music isn't already ours. + */ private val asksFocus: Boolean = true, private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) }, private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) }, @@ -58,6 +63,47 @@ class AudioLink( val channelId get() = service.serviceId + /** Sending audio now, which for media means the car has handed us the speakers. */ + val streaming get() = started + + @Volatile private var holdsSpeakers = false + @Volatile private var focusAnswer: CountDownLatch? = null + @Volatile private var answerDueBy = 0L + + /** True just after [askForSpeakers] or [handBackSpeakers], when the car's next focus answer is ours. */ + fun expectsFocusAnswer() = System.currentTimeMillis() < answerDueBy + + /** + * For directions when nothing of ours is playing, so the car's speakers belong to the radio + * or a Bluetooth phone. Android Auto asks for them before each announcement, letting the + * other source duck, and hands them back afterwards. Speaking without asking left a 2025 + * Nissan's Bosch head unit rebooting mid-drive (issue #35). + * + * Waits briefly for the car's answer and speaks anyway without one, as some cars never answer. + */ + fun askForSpeakers() { + if (holdsSpeakers) return + val answer = CountDownLatch(1) + focusAnswer = answer + answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS + holdsSpeakers = true + sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN_TRANSIENT_MAY_DUCK.toLong())) + GearslipLog.i("$tag: -> AudioFocusRequest(GAIN_TRANSIENT_MAY_DUCK)") + if (!answer.await(FOCUS_WAIT_MS, TimeUnit.MILLISECONDS)) { + GearslipLog.i("$tag: no focus answer after ${FOCUS_WAIT_MS}ms - speaking anyway") + } + focusAnswer = null + } + + /** Gives back what [askForSpeakers] took, so the other source comes back to full volume. */ + fun handBackSpeakers() { + if (!holdsSpeakers) return + holdsSpeakers = false + answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS + runCatching { sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) } + GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)") + } + fun opened(status: Int) { GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status") if (status != 0) { @@ -161,6 +207,12 @@ class AudioLink( fun onFocus(state: Int) { val name = FOCUS_STATES[state] ?: "unknown($state)" GearslipLog.i("$tag: <- AudioFocusResponse state=$name") + if (!asksFocus) { + // Directions speak whatever the answer: a lost turn is worse than a loud one. + answerDueBy = 0L + focusAnswer?.countDown() + return + } val had = focusGranted focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT || state == FOCUS_STATE_GAIN_MEDIA_ONLY @@ -286,6 +338,7 @@ class AudioLink( configTimer?.interrupt() configTimer = null stop() + handBackSpeakers() ready = false onClose(this) } @@ -303,7 +356,9 @@ class AudioLink( private const val MSG_SETUP_ANSWER = 255 private const val STATUS_READY = 2 private const val FOCUS_GAIN = 1 + private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3 private const val FOCUS_RELEASE = 4 + private const val FOCUS_WAIT_MS = 500L private const val FOCUS_STATE_GAIN = 1 private const val FOCUS_STATE_GAIN_TRANSIENT = 2 private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt index 6b9abf3..5e5960d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt @@ -27,7 +27,8 @@ import kotlin.math.roundToInt * same turn the instrument cluster gets ([NavTurn]) and says it itself: once when the turn comes * up, again a kilometre or so out on a long stretch, and once more just before it. The words are * rendered with the phone's text-to-speech to a file, never played on the phone, and streamed to - * the car, which ducks the music under them the way it does for real Android Auto. + * the car, which ducks the music under them the way it does for real Android Auto. When the music + * isn't Gearslip's, each announcement first asks the car for its speakers, as Android Auto does. * * Nothing spoken is logged: road names are where the driver is going. */ @@ -42,7 +43,10 @@ object DirectionsVoice { @Volatile private var link: AudioLink? = null @Volatile private var running = false - private val speaker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } } + @Volatile private var musicIsOurs: () -> Boolean = { false } + private val speaker = Executors.newSingleThreadScheduledExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } } + /** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */ + private var spoken = 0 @Volatile private var latest = 0 private var turnKey: String? = null @@ -62,8 +66,9 @@ object DirectionsVoice { } /** The car opened its directions sink: start following the map app's turns. */ - fun attach(link: AudioLink) { + fun attach(link: AudioLink, musicIsOurs: () -> Boolean) { this.link = link + this.musicIsOurs = musicIsOurs GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}") if (running) return running = true @@ -223,7 +228,16 @@ object DirectionsVoice { GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read") return } - stream(sink, pcm) + // Logged before speaking too, so a car that dies mid-announcement shows what it was doing. + GearslipLog.i("guidance: speaking a turn") + // Held through back-to-back prompts rather than asked for and given back each time. + if (!musicIsOurs()) sink.askForSpeakers() + val turn = ++spoken + try { + stream(sink, pcm) + } finally { + speaker.schedule({ if (turn == spoken) sink.handBackSpeakers() }, HOLD_MS, TimeUnit.MILLISECONDS) + } GearslipLog.i("guidance: spoke a turn (${pcm.size / (2 * sink.channels) * 1000 / sink.sampleRate} ms)") } @@ -321,6 +335,7 @@ object DirectionsVoice { private const val PREPARE_FROM_METERS = 2_500 private const val PREPARE_AT_METERS = 1_000 private const val LEAD_MS = 100L + private const val HOLD_MS = 2_000L private const val SYNTH_TIMEOUT_S = 10L private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth") }