Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ae19758e4 | ||
|
|
10f62d798c |
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android Continuous voice waits for barge-in microphone teardown before listening again.** Multi-turn hands-free conversations no longer lose the microphone after a response finishes with barge-in enabled. (#464)
|
||||
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -257,7 +257,10 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
if ("cannot create audiorecord" in msg || "audiorecord failed to initialize" in msg) {
|
||||
if ("cannot create audiorecord" in msg ||
|
||||
"audiorecord failed to initialize" in msg ||
|
||||
"microphone is in use by another voice feature" in msg
|
||||
) {
|
||||
return HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_mic_unavailable) ?: "Microphone unavailable",
|
||||
body = ctx?.getString(R.string.error_classify_mic_unavailable_body)
|
||||
|
||||
@@ -90,6 +90,7 @@ import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
|
||||
@@ -1037,6 +1038,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var bargeInListener: BargeInListener? = null
|
||||
private var bargeInListenerJob: Job? = null
|
||||
private var bargeInVadEngine: VadEngine? = null
|
||||
/**
|
||||
* The most recent asynchronous AudioRecord shutdown still releasing the
|
||||
* process-wide BargeIn microphone lease. Teardown is intentionally
|
||||
* idempotent, so completion paths may call [stopBargeInListener] after the
|
||||
* listener reference has already been cleared. Retaining this fence makes
|
||||
* every subsequent VoiceCapture start join the same ownership handoff.
|
||||
*/
|
||||
private val pendingBargeInReaderRelease = AtomicReference<Job?>(null)
|
||||
private val bargeInTurnEpoch = AtomicLong(0L)
|
||||
@Volatile private var activeBargeInTurnEpoch: Long = 0L
|
||||
|
||||
@@ -1373,6 +1382,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
// A mode change supersedes any capture that is still waiting for the
|
||||
// previous microphone owner to release. The selected mode below may
|
||||
// start a fresh Continuous capture with its own generation.
|
||||
cancelPendingListeningStart()
|
||||
|
||||
if (mode != InteractionMode.Continuous) {
|
||||
continuousLoopArmed = false
|
||||
continuousListeningPaused = false
|
||||
@@ -1844,6 +1858,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
fun exitVoiceMode() {
|
||||
cancelPendingListeningStart()
|
||||
// Idempotence guard — added 2026-04-21 after logcat showed the voice-
|
||||
// exit chime playing on every Add-connection tap.
|
||||
//
|
||||
@@ -1995,6 +2010,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fun startListening() {
|
||||
startListening(requireContinuousLoop = false)
|
||||
}
|
||||
|
||||
private fun startListening(requireContinuousLoop: Boolean) {
|
||||
// A direct mic tap starts a normal capture. Only the recorder opened by
|
||||
// onBargeInDetected may carry response-interruption command context.
|
||||
responseInterruptedForVoiceCommand = false
|
||||
@@ -2003,7 +2022,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Recorder not initialized")
|
||||
return
|
||||
}
|
||||
if (pendingListeningStartJob?.isActive == true) return
|
||||
if (requireContinuousLoop && !canStartContinuousCapture()) return
|
||||
// A direct/new capture request supersedes a stale handoff waiter. It
|
||||
// will join the same retained microphone-release fence under a fresh
|
||||
// epoch below instead of being silently dropped.
|
||||
cancelPendingListeningStart()
|
||||
if (rec.isRecording()) return
|
||||
if (_uiState.value.state == VoiceState.Listening) {
|
||||
// Listening is reserved for a live AudioRecord. Reconcile a stale
|
||||
@@ -2037,7 +2060,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try { realtimePcmPlayer?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
|
||||
if (microphoneRelease == null || microphoneRelease.isCompleted) {
|
||||
startVoiceCapture(rec)
|
||||
if (!requireContinuousLoop || canStartContinuousCapture()) {
|
||||
startVoiceCapture(rec)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2045,7 +2070,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val pendingStart = viewModelScope.launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
microphoneRelease.join()
|
||||
if (listeningStartEpoch == startEpoch) {
|
||||
if (listeningStartEpoch == startEpoch &&
|
||||
(!requireContinuousLoop || canStartContinuousCapture())
|
||||
) {
|
||||
startVoiceCapture(rec)
|
||||
}
|
||||
} finally {
|
||||
@@ -2058,6 +2085,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
pendingStart.start()
|
||||
}
|
||||
|
||||
private fun canStartContinuousCapture(): Boolean {
|
||||
val state = _uiState.value
|
||||
return state.voiceMode &&
|
||||
state.interactionMode == InteractionMode.Continuous &&
|
||||
state.state == VoiceState.Idle &&
|
||||
continuousLoopArmed &&
|
||||
!continuousListeningPaused
|
||||
}
|
||||
|
||||
private fun startVoiceCapture(rec: VoiceRecorder) {
|
||||
try {
|
||||
rec.startRecording()
|
||||
@@ -2218,6 +2254,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* listening turn; until then, idle queue-drain callbacks are ignored.
|
||||
*/
|
||||
fun pauseContinuousMode() {
|
||||
cancelPendingListeningStart()
|
||||
continuousLoopArmed = false
|
||||
continuousListeningPaused = _uiState.value.interactionMode == InteractionMode.Continuous
|
||||
continuousResumeJob?.cancel()
|
||||
@@ -5760,7 +5797,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
continuousLoopArmed &&
|
||||
_uiState.value.state == VoiceState.Idle
|
||||
) {
|
||||
startListening()
|
||||
startListening(requireContinuousLoop = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5825,6 +5862,29 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!activeResponseOwnsBargeIn()) {
|
||||
Log.i(TAG, "Barge-in listener skipped; no active voice response owns the microphone")
|
||||
return
|
||||
}
|
||||
|
||||
val pendingReaderRelease = pendingBargeInReaderRelease.get()?.takeUnless { it.isCompleted }
|
||||
if (pendingReaderRelease != null) {
|
||||
// A late playback/realtime callback may request the next turn's
|
||||
// listener while the previous AudioRecord is still unwinding.
|
||||
// Join the same ownership fence as VoiceCapture, then re-check the
|
||||
// turn epoch so stale generations cannot reopen the microphone.
|
||||
activeBargeInTurnEpoch = epoch
|
||||
viewModelScope.launch {
|
||||
pendingReaderRelease.join()
|
||||
if (activeBargeInTurnEpoch == epoch &&
|
||||
bargeInListener == null &&
|
||||
activeResponseOwnsBargeIn()
|
||||
) {
|
||||
startBargeInListenerIfEnabled(epoch = epoch)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val vad = try {
|
||||
vadFactory().also { it.setSensitivity(prefs.sensitivity) }
|
||||
@@ -5873,6 +5933,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeResponseOwnsBargeIn(): Boolean {
|
||||
val state = _uiState.value
|
||||
return state.voiceMode &&
|
||||
(state.state == VoiceState.Thinking || state.state == VoiceState.Speaking)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the active [BargeInListener], cancel its event subscribers,
|
||||
* unduck the player (in case a ducking watchdog hadn't yet restored
|
||||
@@ -5906,7 +5972,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try { realtimePcmPlayer?.unduck() } catch (_: Throwable) { /* ignore */ }
|
||||
isDucked = false
|
||||
}
|
||||
return stoppedReaderJob
|
||||
if (stoppedReaderJob != null) {
|
||||
pendingBargeInReaderRelease.set(stoppedReaderJob)
|
||||
stoppedReaderJob.invokeOnCompletion {
|
||||
pendingBargeInReaderRelease.compareAndSet(stoppedReaderJob, null)
|
||||
}
|
||||
}
|
||||
return pendingBargeInReaderRelease.get()?.takeUnless { it.isCompleted }
|
||||
}
|
||||
|
||||
private fun markBargeInPlaybackStarted(graceMs: Long) {
|
||||
@@ -5958,6 +6030,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
internal fun onBargeInDetected() {
|
||||
if (isBargeInStartupGuardActive()) return
|
||||
val interruptedMode = _uiState.value.interactionMode
|
||||
val interruptedEngine = voiceEngineMode
|
||||
val interruptedSpokenReply = _uiState.value.outputAudioActive
|
||||
if (interruptedSpokenReply) spokenInterruptionLatch.mark()
|
||||
duckingWatchdog?.cancel(); duckingWatchdog = null
|
||||
@@ -5991,18 +6065,63 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = "",
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val captureEpoch = ++listeningStartEpoch
|
||||
val pendingStart = viewModelScope.launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
microphoneRelease?.join()
|
||||
val rec = recorder
|
||||
if (rec != null && !rec.isRecording()) {
|
||||
if (!canStartBargeInCapture(captureEpoch, interruptedMode, interruptedEngine)) {
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
return@launch
|
||||
}
|
||||
val rec = recorder ?: error("Recorder not initialized")
|
||||
if (!rec.isRecording()) {
|
||||
rec.startRecording()
|
||||
}
|
||||
scheduleResumeWatchdog()
|
||||
if (canStartBargeInCapture(captureEpoch, interruptedMode, interruptedEngine)) {
|
||||
scheduleResumeWatchdog()
|
||||
} else {
|
||||
try { rec.cancel() } catch (_: Throwable) { /* ignore */ }
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
}
|
||||
} catch (t: CancellationException) {
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
responseInterruptedForVoiceCommand = false
|
||||
Log.w(TAG, "barge-in microphone handoff failed: ${t.message}")
|
||||
surfaceError(t, context = "record")
|
||||
if (listeningStartEpoch == captureEpoch) {
|
||||
responseInterruptedForVoiceCommand = false
|
||||
Log.w(TAG, "barge-in microphone handoff failed: ${t.message}")
|
||||
surfaceError(t, context = "record")
|
||||
}
|
||||
} finally {
|
||||
if (listeningStartEpoch == captureEpoch) {
|
||||
pendingListeningStartJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingListeningStartJob = pendingStart
|
||||
pendingStart.start()
|
||||
}
|
||||
|
||||
private fun canStartBargeInCapture(
|
||||
captureEpoch: Long,
|
||||
interruptedMode: InteractionMode,
|
||||
interruptedEngine: VoiceEngineMode,
|
||||
): Boolean {
|
||||
val state = _uiState.value
|
||||
return listeningStartEpoch == captureEpoch &&
|
||||
state.voiceMode &&
|
||||
state.state == VoiceState.Listening &&
|
||||
state.interactionMode == interruptedMode &&
|
||||
voiceEngineMode == interruptedEngine &&
|
||||
responseInterruptedForVoiceCommand
|
||||
}
|
||||
|
||||
private fun abandonBargeInCaptureIfCurrent(captureEpoch: Long) {
|
||||
if (listeningStartEpoch != captureEpoch) return
|
||||
responseInterruptedForVoiceCommand = false
|
||||
if (_uiState.value.state == VoiceState.Listening && recorder?.isRecording() != true) {
|
||||
_uiState.update {
|
||||
it.copy(state = VoiceState.Idle, amplitude = 0f, outputAudioActive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6314,9 +6433,22 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
startBargeInListenerIfEnabled()
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun stopBargeInListenerForTest(): Job? = stopBargeInListener()
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun finishAgentAudioOutputForTest() {
|
||||
finishAgentAudioOutput()
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun setVoiceEngineModeForTest(mode: VoiceEngineMode) {
|
||||
voiceEngineMode = mode
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun beginBargeInTurnForTest() {
|
||||
_uiState.update { it.copy(state = VoiceState.Thinking) }
|
||||
_uiState.update { it.copy(voiceMode = true, state = VoiceState.Thinking) }
|
||||
beginBargeInTurnIfEnabled()
|
||||
}
|
||||
|
||||
|
||||
@@ -152,4 +152,15 @@ class RelayErrorClassifierTest {
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun microphoneOwnershipConflictMapsToRetryableMicUnavailableHint() {
|
||||
val err = classifyError(
|
||||
IllegalStateException("Microphone is in use by another voice feature"),
|
||||
context = "record",
|
||||
)
|
||||
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||
import com.hermesandroid.relay.data.BargeInPreferences
|
||||
import com.hermesandroid.relay.data.BargeInPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import io.mockk.every
|
||||
@@ -273,6 +275,360 @@ class VoiceViewModelBargeInTest {
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous completion waits for queue-drain barge-in release before capture`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
// The play worker tears down barge-in before the shared completion
|
||||
// finalizer runs. Repeated teardown calls must retain that first
|
||||
// asynchronous release instead of attempting VoiceCapture immediately.
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous completion repeats serialized handoff across turns`() = runTest {
|
||||
val firstRelease = Job()
|
||||
val secondRelease = Job()
|
||||
var stopCalls = 0
|
||||
every { bargeInListener.stop() } answers {
|
||||
when (stopCalls++) {
|
||||
0 -> firstRelease
|
||||
1 -> secondRelease
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("First."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
firstRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Second."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
|
||||
secondRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 2) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barge-in disabled keeps continuous completion immediate`() = runTest {
|
||||
val vm = buildViewModel(
|
||||
BargeInPreferences(
|
||||
enabled = false,
|
||||
sensitivity = BargeInSensitivity.Off,
|
||||
),
|
||||
)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit cancels continuous capture waiting for microphone release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.exitVoiceMode()
|
||||
vm.startBargeInListenerForTest()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
assertTrue(!vm.uiState.value.voiceMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause cancels continuous capture waiting for microphone release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.pauseContinuousMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid mode change starts only the newly armed continuous capture`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.setInteractionMode(InteractionMode.TapToTalk)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
runCurrent()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next barge-in generation waits for prior reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("First."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 2) { bargeInListener.start(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barge-in capture waits for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.exitVoiceMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertTrue(!vm.uiState.value.voiceMode)
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous pause invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.pauseContinuousMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interaction mode switch invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.setInteractionMode(InteractionMode.HoldToTalk)
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(InteractionMode.HoldToTalk, vm.uiState.value.interactionMode)
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engine switch invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.setVoiceEngineModeForTest(VoiceEngineMode.RealtimeAgent)
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer manual capture supersedes barge-in release waiter`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader release completion starts valid barge-in capture only once`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
assertTrue(readerRelease.complete())
|
||||
runCurrent()
|
||||
assertTrue(!readerRelease.complete())
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed barge-in recorder acquisition does not arm resume watchdog`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
every { recorder.startRecording() } throws
|
||||
IllegalStateException("Microphone is in use by another voice feature")
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking.", "Tail."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
advanceTimeBy(700)
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient recorder ownership failures remain explicitly retryable`() = runTest {
|
||||
var attempts = 0
|
||||
every { recorder.startRecording() } answers {
|
||||
attempts++
|
||||
if (attempts <= 2) {
|
||||
throw IllegalStateException("Microphone is in use by another voice feature")
|
||||
}
|
||||
java.io.File("voice-retry-test.wav")
|
||||
}
|
||||
val vm = buildViewModel(
|
||||
BargeInPreferences(
|
||||
enabled = false,
|
||||
sensitivity = BargeInSensitivity.Off,
|
||||
),
|
||||
)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
verify(exactly = 3) { recorder.startRecording() }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Test 2 — resume with resumeAfterInterruption=true + silence
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user