Reproduce voice capture rollback and playback cleanup failures
This commit is contained in:
1 parent
537814e7fd
commit
0219624c7d
4 files changed
+198
-2
No files matched your search
@@ -5,8 +5,10 @@ kotlin {
|
||||
sourceSets.named("main") {
|
||||
// Настоящие reader/очереди, независимый от router-tests Android API без аппаратных ресурсов.
|
||||
kotlin.srcDir("../app/src/main/java")
|
||||
kotlin.include("android/**", "com/utun/chat/data/AndroidMicrophoneCapture.kt",
|
||||
"com/utun/chat/data/MicrophoneCapture.kt", "com/utun/chat/data/CaptureHostLog.kt")
|
||||
kotlin.include("android/**", "androidx/**", "com/utun/chat/data/AndroidMicrophoneCapture.kt",
|
||||
"com/utun/chat/data/MicrophoneCapture.kt", "com/utun/chat/data/CaptureHostLog.kt",
|
||||
"com/utun/chat/RecorderHostApp.kt", "com/utun/chat/data/RecorderHostEnvironment.kt",
|
||||
"com/utun/chat/data/AudioRecorderManager.kt")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class AudioRecord(val source: Int, val rate: Int, val mask: Int, encoding: Int,
|
||||
val stopCalls = AtomicInteger()
|
||||
val releaseCalls = AtomicInteger()
|
||||
val modes = CopyOnWriteArrayList<Int>()
|
||||
@Volatile var reader: Thread? = null
|
||||
var released = false
|
||||
}
|
||||
companion object {
|
||||
@@ -86,6 +87,7 @@ class AudioRecord(val source: Int, val rate: Int, val mask: Int, encoding: Int,
|
||||
fun startRecording() { operation("start"); if (plan.starts) recording = RECORDSTATE_RECORDING; operation("start after") }
|
||||
fun read(pcm: ShortArray, offset: Int, size: Int) = read(pcm, offset, size, READ_BLOCKING)
|
||||
fun read(pcm: ShortArray, offset: Int, size: Int, mode: Int): Int {
|
||||
plan.reader = Thread.currentThread()
|
||||
plan.readCalls.incrementAndGet(); plan.modes.add(mode); plan.readEntered.countDown()
|
||||
operation("read")
|
||||
plan.holdRead?.await()
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/** Native singleton и decoder handles заменены только управляемой границей; recorder работает настоящий. */
|
||||
object NativeLib {
|
||||
@Volatile var active = false
|
||||
var acceptStart = true
|
||||
var acceptFeed = true
|
||||
var stopResult = 800
|
||||
val starts = CopyOnWriteArrayList<List<Any>>()
|
||||
val feeds = CopyOnWriteArrayList<ShortArray>()
|
||||
val stops = AtomicInteger()
|
||||
val cancels = AtomicInteger()
|
||||
val decoderReads = LinkedBlockingQueue<Int>()
|
||||
val decoderCloses = AtomicInteger()
|
||||
var decoderInfo = intArrayOf(48000, 1, 960)
|
||||
fun reset() {
|
||||
active = false; acceptStart = true; acceptFeed = true; stopResult = 800
|
||||
starts.clear(); feeds.clear(); stops.set(0); cancels.set(0)
|
||||
decoderReads.clear(); decoderCloses.set(0); decoderInfo = intArrayOf(48000, 1, 960)
|
||||
}
|
||||
@Synchronized fun voiceStart(channel: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean {
|
||||
starts.add(listOf(channel, reply, request, generation))
|
||||
if (!acceptStart || active) return false
|
||||
active = true; return true
|
||||
}
|
||||
fun voiceFeed(pcm: ShortArray): Boolean { feeds.add(pcm.copyOf()); return active && acceptFeed }
|
||||
@Synchronized fun voiceStop(): Int { stops.incrementAndGet(); active = false; return stopResult }
|
||||
@Synchronized fun voiceCancel() { cancels.incrementAndGet(); active = false }
|
||||
fun voiceDecodeOpen(path: String, info: IntArray): Long { decoderInfo.copyInto(info); return 1 }
|
||||
fun voiceDecodeRead(handle: Long, pcm: ShortArray): Int {
|
||||
val count = decoderReads.poll() ?: 0
|
||||
if (count > 0) pcm.fill(12, 0, count)
|
||||
return count
|
||||
}
|
||||
fun voiceDecodeClose(handle: Long) { decoderCloses.incrementAndGet() }
|
||||
}
|
||||
|
||||
object StreamController { var blocked = false; fun blocksCapture() = blocked }
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.RecorderHostContext
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** Настоящий lifecycle записи/плеера: отказы Android/JNI и частичные записи PCM без аппаратных устройств. */
|
||||
class AudioRecorderManagerTest {
|
||||
private val manager = AudioRecorderManager()
|
||||
@Before fun prepare() {
|
||||
AudioRecord.reset(); AudioTrack.reset(); NativeLib.reset(); StreamController.blocked = false
|
||||
ChatApplication.instance = RecorderHostContext(); LogManager.entries.clear()
|
||||
}
|
||||
@After fun release() {
|
||||
AudioRecord.created.forEach { it.plan.failures = emptySet(); it.plan.chunks.offer(AudioRecord.Chunk(count = -3)) }
|
||||
AudioTrack.created.forEach { it.plan.failures = emptySet() }
|
||||
manager.cancelRecording(); manager.stopMicTest(); manager.stopPlayback()
|
||||
AudioRecord.created.forEach {
|
||||
it.plan.reader?.join(2_000)
|
||||
assertFalse("reader survived cleanup", it.plan.reader?.isAlive == true)
|
||||
if (!it.plan.released) it.release()
|
||||
}
|
||||
}
|
||||
private fun plan() = AudioRecord.Plan().also { AudioRecord.plans.add(it) }
|
||||
private fun waitUntil(done: () -> Boolean) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2)
|
||||
while (!done() && System.nanoTime() < deadline) Thread.sleep(2)
|
||||
assertTrue("worker did not reach expected state", done())
|
||||
}
|
||||
private fun diagnostic(fragment: String) = assertTrue("missing diagnostic: $fragment",
|
||||
LogManager.entries.any { it.level in listOf("WARN", "ERROR") && fragment in it.message })
|
||||
|
||||
@Test fun startupFailureMatrixRollsBackAndroidAndNativeOwnership() {
|
||||
for (operation in listOf("state", "start", "start after", "recording")) {
|
||||
val device = plan().apply { failures = setOf(operation) }
|
||||
val result = runCatching { manager.startRecording("7") }
|
||||
assertEquals("startup escaped: $operation ${result.exceptionOrNull()}", false, result.getOrNull())
|
||||
assertFalse(manager.isActive()); assertFalse(NativeLib.active)
|
||||
assertTrue("unreleased device: $operation", device.released)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun uninitializedOrNotRecordingDeviceIsRejected() {
|
||||
for (ready in listOf(false, true)) {
|
||||
val device = plan().apply { initialized = ready; starts = false }
|
||||
assertFalse(manager.startRecording("7"))
|
||||
assertFalse(NativeLib.active); assertTrue(device.released)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun invalidMinimumDoesNotCreateOrPublishARecorder() {
|
||||
AudioRecord.minimums[AudioFormat.CHANNEL_IN_MONO] = -2
|
||||
plan()
|
||||
assertFalse(manager.startRecording("7")); assertFalse(NativeLib.active)
|
||||
assertTrue(AudioRecord.created.isEmpty()); diagnostic("minimum")
|
||||
}
|
||||
|
||||
@Test fun rejectedNativeTestDoesNotCaptureOrCancelAnotherOwner() {
|
||||
NativeLib.active = true
|
||||
plan()
|
||||
assertFalse(manager.startMicTest())
|
||||
assertTrue(AudioRecord.created.isEmpty()); assertTrue(NativeLib.active)
|
||||
assertEquals(0, NativeLib.cancels.get())
|
||||
}
|
||||
|
||||
@Test fun stoppingAnInactiveTestKeepsTheMessageRecording() {
|
||||
plan(); assertTrue(manager.startRecording("7"))
|
||||
manager.stopMicTest()
|
||||
assertTrue(manager.isActive()); assertTrue(NativeLib.active)
|
||||
manager.cancelRecording(); assertFalse(NativeLib.active)
|
||||
}
|
||||
|
||||
@Test fun failedFeedAndReadDiscardInsteadOfPublishingPartialVoice() {
|
||||
for (feedFailure in listOf(false, true)) {
|
||||
NativeLib.acceptFeed = !feedFailure
|
||||
val device = plan()
|
||||
assertTrue(manager.startRecording("7"))
|
||||
device.chunks.add(AudioRecord.Chunk(count = if (feedFailure) 960 else -6))
|
||||
waitUntil { if (feedFailure) NativeLib.feeds.isNotEmpty() else device.readCalls.get() > 0 }
|
||||
waitUntil { !manager.isActive() }
|
||||
assertEquals(-1, manager.stopRecording())
|
||||
assertFalse(NativeLib.active); assertEquals(0, NativeLib.stops.get()); assertTrue(device.released)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun releaseFailureStillCancelsTheOwnedNativeRecording() {
|
||||
val device = plan(); assertTrue(manager.startRecording("7"))
|
||||
device.failures = setOf("release")
|
||||
val result = runCatching { manager.stopRecording() }
|
||||
assertEquals(-1, result.getOrNull()); assertFalse(NativeLib.active)
|
||||
assertEquals(1, NativeLib.cancels.get()); diagnostic("release")
|
||||
}
|
||||
|
||||
@Test fun testReaderIsStoppedBeforeJoinAndStopFailureCannotHangIt() {
|
||||
val device = plan(); assertTrue(manager.startMicTest())
|
||||
assertTrue(device.readEntered.await(1, TimeUnit.SECONDS))
|
||||
device.failures = setOf("stop")
|
||||
val done = CountDownLatch(1)
|
||||
val worker = Thread({ try { manager.stopMicTest() } finally { done.countDown() } }, "voice-test-stop")
|
||||
worker.start()
|
||||
try { assertTrue("test cleanup waited for a blocked read", done.await(300, TimeUnit.MILLISECONDS)) }
|
||||
finally {
|
||||
device.failures = emptySet(); device.chunks.offer(AudioRecord.Chunk(count = -3)); worker.join(2_000)
|
||||
}
|
||||
assertTrue(device.released); assertFalse(NativeLib.active)
|
||||
}
|
||||
|
||||
@Test fun partialOutputWritesPreserveEveryDecodedSample() {
|
||||
val output = AudioTrack.Plan().also { AudioTrack.plans.add(it); it.results.add(240) }
|
||||
NativeLib.decoderReads.add(960)
|
||||
val done = CountDownLatch(1)
|
||||
manager.playVoiceFile("voice.opus", onComplete = { done.countDown() })
|
||||
assertTrue(done.await(2, TimeUnit.SECONDS))
|
||||
assertEquals(960, output.accepted.size); assertTrue(output.accepted.all { it == 12.toShort() })
|
||||
assertEquals(1, NativeLib.decoderCloses.get())
|
||||
}
|
||||
|
||||
@Test fun failedOutputWriteIsNotReportedAsPlayedPcm() {
|
||||
AudioTrack.plans.add(AudioTrack.Plan().apply { results.add(-6) })
|
||||
NativeLib.decoderReads.add(960)
|
||||
val progress = AtomicInteger(); val done = CountDownLatch(1)
|
||||
manager.playVoiceFile("voice.opus", onProgress = { _, _ -> progress.incrementAndGet() }, onComplete = { done.countDown() })
|
||||
assertTrue(done.await(2, TimeUnit.SECONDS))
|
||||
assertEquals(0, progress.get()); assertEquals(1, NativeLib.decoderCloses.get()); diagnostic("write")
|
||||
}
|
||||
|
||||
@Test fun trackCleanupFailureDoesNotLeakDecoderOrLoseCompletion() {
|
||||
val output = AudioTrack.Plan().also { AudioTrack.plans.add(it); it.failures = setOf("stop", "release") }
|
||||
val done = CountDownLatch(1)
|
||||
manager.playVoiceFile("voice.opus", onComplete = { done.countDown() })
|
||||
assertTrue(done.await(2, TimeUnit.SECONDS))
|
||||
assertEquals(1, NativeLib.decoderCloses.get()); assertEquals(1, output.releaseCalls); diagnostic("release")
|
||||
}
|
||||
|
||||
@Test fun decoderErrorHasADiagnosticAndClosesItsHandle() {
|
||||
AudioTrack.plans.add(AudioTrack.Plan()); NativeLib.decoderReads.add(-1)
|
||||
val done = CountDownLatch(1)
|
||||
manager.playVoiceFile("voice.opus", onComplete = { done.countDown() })
|
||||
assertTrue(done.await(2, TimeUnit.SECONDS)); assertEquals(1, NativeLib.decoderCloses.get()); diagnostic("decode")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user