


















@@ -0,0 +1,258 @@
1+package ai.openclaw.app.voice
2+3+import android.Manifest
4+import kotlinx.coroutines.CancellationException
5+import kotlinx.coroutines.CompletableDeferred
6+import kotlinx.coroutines.CoroutineScope
7+import kotlinx.coroutines.Dispatchers
8+import kotlinx.coroutines.ExperimentalCoroutinesApi
9+import kotlinx.coroutines.Job
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.test.advanceUntilIdle
12+import kotlinx.coroutines.test.runCurrent
13+import kotlinx.coroutines.test.runTest
14+import org.junit.Assert.assertEquals
15+import org.junit.Assert.assertNull
16+import org.junit.Test
17+import org.junit.runner.RunWith
18+import org.robolectric.RobolectricTestRunner
19+import org.robolectric.RuntimeEnvironment
20+import org.robolectric.Shadows.shadowOf
21+import org.robolectric.annotation.Config
22+23+@RunWith(RobolectricTestRunner::class)
24+@Config(sdk = [34])
25+class MicCaptureManagerTest {
26+ @Test
27+ @OptIn(ExperimentalCoroutinesApi::class)
28+fun transcriptionFinalQueuesGatewayMessage() =
29+ runTest {
30+val sentMessages = mutableListOf<String>()
31+val manager =
32+ createManager(
33+ scope = this,
34+ sendToGateway = { message, onRunIdKnown ->
35+ sentMessages += message
36+ onRunIdKnown("run-1")
37+null
38+ },
39+ )
40+41+ setPrivateField(manager, "transcriptionSessionId", "transcription-1")
42+ manager.onGatewayConnectionChanged(true)
43+ manager.handleGatewayEvent(
44+"talk.event",
45+"""{"transcriptionSessionId":"transcription-1","type":"partial","text":"hello"}""",
46+ )
47+ manager.handleGatewayEvent(
48+"talk.event",
49+"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"hello world","final":true}""",
50+ )
51+ runCurrent()
52+ manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-1", text = "reply"))
53+ advanceUntilIdle()
54+55+ assertNull(manager.liveTranscript.value)
56+ assertEquals(listOf("hello world"), sentMessages)
57+val conversation = manager.conversation.value.first()
58+ assertEquals(VoiceConversationRole.User, conversation.role)
59+ assertEquals("hello world", conversation.text)
60+ }
61+62+ @Test
63+fun transcriptionErrorDisablesMic() {
64+val manager = createManager()
65+66+ setPrivateField(manager, "transcriptionSessionId", "transcription-1")
67+ manager.handleGatewayEvent(
68+"talk.event",
69+"""{"transcriptionSessionId":"transcription-1","type":"error","message":"provider unavailable"}""",
70+ )
71+72+ assertEquals(false, manager.micEnabled.value)
73+ assertEquals("Transcription failed: provider unavailable", manager.statusText.value)
74+ }
75+76+ @Test
77+ @OptIn(ExperimentalCoroutinesApi::class)
78+fun punctuationOnlyTranscriptDoesNotSendTurn() =
79+ runTest {
80+val sentMessages = mutableListOf<String>()
81+val manager =
82+ createManager(
83+ scope = this,
84+ sendToGateway = { message, onRunIdKnown ->
85+ sentMessages += message
86+ onRunIdKnown("run-1")
87+"run-1"
88+ },
89+ )
90+91+ setPrivateField(manager, "transcriptionSessionId", "transcription-1")
92+ manager.onGatewayConnectionChanged(true)
93+ manager.handleGatewayEvent(
94+"talk.event",
95+"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":".","final":true}""",
96+ )
97+ advanceUntilIdle()
98+99+ assertEquals(emptyList<String>(), sentMessages)
100+ assertEquals(emptyList<VoiceConversationEntry>(), manager.conversation.value)
101+ }
102+103+ @Test
104+fun pcm16FramesAreEncodedAsPcmuFrames() {
105+val manager = createManager()
106+val method = manager.javaClass.getDeclaredMethod("pcm16ToPcmu", ByteArray::class.java)
107+ method.isAccessible = true
108+109+val encoded = method.invoke(manager, byteArrayOf(0, 0, 0, 0)) as ByteArray
110+111+ assertEquals(2, encoded.size)
112+ assertEquals(0xff.toByte(), encoded[0])
113+ assertEquals(0xff.toByte(), encoded[1])
114+ }
115+116+ @Test
117+ @OptIn(ExperimentalCoroutinesApi::class)
118+fun disablingMicDuringSessionCreateClosesReturnedSession() =
119+ runTest {
120+val createdSession = CompletableDeferred<String>()
121+val closedSessions = mutableListOf<String>()
122+val manager =
123+ createManager(
124+ scope = this,
125+ createTranscriptionSession = { createdSession.await() },
126+ closeTranscriptionSession = { sessionId -> closedSessions += sessionId },
127+ )
128+129+ manager.onGatewayConnectionChanged(true)
130+ manager.setMicEnabled(true)
131+ manager.setMicEnabled(false)
132+ createdSession.complete("transcription-1")
133+ advanceUntilIdle()
134+135+ assertEquals(listOf("transcription-1"), closedSessions)
136+ assertEquals(false, manager.isListening.value)
137+ }
138+139+ @Test
140+ @OptIn(ExperimentalCoroutinesApi::class)
141+fun disablingMicKeepsSessionOpenForFinalTranscript() =
142+ runTest {
143+val manager = createManager(scope = this)
144+145+ setPrivateMutableStateFlowValue(manager, "_micEnabled", true)
146+ setPrivateField(manager, "transcriptionSessionId", "transcription-1")
147+ manager.setMicEnabled(false)
148+ manager.handleGatewayEvent(
149+"talk.event",
150+"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"testing testing 1 2 3","final":true}""",
151+ )
152+ runCurrent()
153+154+ assertEquals("testing testing 1 2 3", manager.conversation.value.single().text)
155+ assertEquals("transcription-1", privateField<String?>(manager, "transcriptionSessionId"))
156+ privateField<Job?>(manager, "transcriptionDrainJob")?.cancel()
157+ }
158+159+ @Test
160+ @OptIn(ExperimentalCoroutinesApi::class)
161+fun reconnectRestartsAfterPendingCreateCancellation() =
162+ runTest {
163+val firstCreate = CompletableDeferred<String>()
164+val secondCreate = CompletableDeferred<String>()
165+var createCalls = 0
166+val manager =
167+ createManager(
168+ scope = this,
169+ createTranscriptionSession = {
170+ createCalls += 1
171+if (createCalls == 1) firstCreate.await() else secondCreate.await()
172+ },
173+ )
174+175+ manager.onGatewayConnectionChanged(true)
176+ manager.setMicEnabled(true)
177+ runCurrent()
178+ manager.onGatewayConnectionChanged(false)
179+ manager.onGatewayConnectionChanged(true)
180+ firstCreate.completeExceptionally(CancellationException("connection closed"))
181+ runCurrent()
182+183+ assertEquals(2, createCalls)
184+ assertEquals(true, manager.micEnabled.value)
185+ manager.setMicEnabled(false)
186+ secondCreate.completeExceptionally(CancellationException("test complete"))
187+ runCurrent()
188+ }
189+190+private fun createManager(
191+scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
192+createTranscriptionSession: suspend () -> String = { "transcription-1" },
193+closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
194+sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
195+ onRunIdKnown("run-1")
196+"run-1"
197+ },
198+ ): MicCaptureManager =
199+MicCaptureManager(
200+ context =
201+RuntimeEnvironment.getApplication().also { app ->
202+ shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO)
203+ },
204+ scope = scope,
205+ createTranscriptionSession = createTranscriptionSession,
206+ appendTranscriptionAudio = { _, _, _ -> },
207+ closeTranscriptionSession = closeTranscriptionSession,
208+ sendToGateway = sendToGateway,
209+ )
210+211+private fun setPrivateField(
212+target: Any,
213+name: String,
214+value: Any?,
215+ ) {
216+val field = target.javaClass.getDeclaredField(name)
217+ field.isAccessible = true
218+ field.set(target, value)
219+ }
220+221+ @Suppress("UNCHECKED_CAST")
222+private fun setPrivateMutableStateFlowValue(
223+target: Any,
224+name: String,
225+value: Boolean,
226+ ) {
227+val field = target.javaClass.getDeclaredField(name)
228+ field.isAccessible = true
229+ (field.get(target) as MutableStateFlow<Boolean>).value = value
230+ }
231+232+ @Suppress("UNCHECKED_CAST")
233+private fun <T> privateField(
234+target: Any,
235+name: String,
236+ ): T {
237+val field = target.javaClass.getDeclaredField(name)
238+ field.isAccessible = true
239+return field.get(target) as T
240+ }
241+242+private fun chatFinalPayload(
243+runId: String,
244+text: String,
245+ ): String =
246+"""
247+ {
248+ "runId": "$runId",
249+ "state": "final",
250+ "message": {
251+ "role": "assistant",
252+ "content": [
253+ { "type": "text", "text": "$text" }
254+ ]
255+ }
256+ }
257+ """.trimIndent()
258+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。