Make voice recording ownership and playback failures explicit

This commit is contained in:
evgeny committed 2026-10-28 11:36:13 +01:00
1 parent 0219624c7d
commit c0c282ca3a
3 files changed
+213 -156

No files matched your search

@@ -10,6 +10,16 @@ import android.media.MediaRecorder
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
/**
* Запись голосового сообщения/тест микрофона и отдельный плеер native Opus-файла.
* Запись и тест делят один native recorder: успешный voiceStart даёт владение до stop/cancel.
* Lifecycle сериализован монитором менеджера; stopRecording/cancelRecording вызываются также из IO worker.
* Захват — PCM16 48 кГц mono, чтение без блокировки; stop сначала останавливает reader, ждёт join и освобождает Android.
* Ошибка захвата/очистки запрещает публикацию частичной записи: stopRecording вернёт -1 и отменит native recorder.
* После отказа worker владелец вызывает stop/cancel; неосвобождённый Android device сохраняется для повтора cleanup.
* Плеер владеет своим decoder/AudioTrack в worker; прогресс/завершение — быстрые callbacks этого потока.
* Pause/stop меняют поколение и прерывают worker, старое поколение больше не уведомляет UI.
*/
class AudioRecorderManager {
companion object {
const val SAMPLE_RATE = 48000
@@ -19,14 +29,13 @@ class AudioRecorderManager {
const val MIN_DURATION_MS = 500
}
private enum class CaptureMode { RECORD, TEST }
@Volatile private var captureMode: CaptureMode? = null
private var audioRecord: AudioRecord? = null
private var recordingThread: Thread? = null
@Volatile private var isRecording = false
private var testRecord: AudioRecord? = null
private var testThread: Thread? = null
@Volatile var isTesting = false
private set
@Volatile private var captureRunning = false
@Volatile private var captureFailed = false
val isTesting: Boolean get() = captureMode == CaptureMode.TEST && captureRunning
fun hasPermission(): Boolean {
val ctx = ChatApplication.instance
@@ -34,167 +43,160 @@ class AudioRecorderManager {
PackageManager.PERMISSION_GRANTED
}
fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean {
if (isRecording) return false
if (!hasPermission()) return false
/** target — канал либо dm:conv_id; reply/request относятся к каналу, generation — к PM.
* true — Android/native захват запущен; false — отказ с диагностикой и откатом принадлежащих ресурсов. */
@Synchronized fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation)
val ok = NativeLib.voiceStart(channelId, replyTo, requestId, generation)
if (!ok) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed")
private fun startCapture(mode: CaptureMode, target: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean {
if (captureMode != null) {
LogManager.addLog("WARN", "AudioRecorder", "capture start rejected mode=$mode owned=$captureMode running=$captureRunning")
return false
}
val bufferSize = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(FRAME_SAMPLES * 2)
audioRecord = try {
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize * 2
)
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord create failed: ${e.message}")
NativeLib.voiceCancel()
return false
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord not initialized")
audioRecord?.release()
audioRecord = null
NativeLib.voiceCancel()
return false
}
isRecording = true
LogManager.addLog("INFO", "AudioRecorder", "started ch=$channelId bufferSize=$bufferSize")
audioRecord?.startRecording()
val device = audioRecord ?: return false
recordingThread = Thread {
val buffer = ShortArray(FRAME_SAMPLES)
var totalRead = 0L
while (isRecording && device.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
val read = try { device.read(buffer, 0, FRAME_SAMPLES) } catch (e: Exception) {
if (isRecording) LogManager.addLog("ERROR", "AudioRecorder", "capture read failed: ${e.message}")
break
}
if (read <= 0) {
if (isRecording) LogManager.addLog("ERROR", "AudioRecorder", "capture read failed code=$read")
break
}
NativeLib.voiceFeed(buffer.copyOf(read))
totalRead += read
if (!releaseCaptureDevice()) return false
try {
if (!hasPermission()) {
LogManager.addLog("WARN", "AudioRecorder", "microphone permission missing mode=$mode")
return false
}
LogManager.addLog("DEBUG", "AudioRecorder", "thread done totalSamples=$totalRead")
}.apply {
priority = Thread.MAX_PRIORITY
start()
if (!NativeLib.voiceStart(target, reply, request, generation)) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart rejected mode=$mode target=$target")
return false
}
captureMode = mode; captureFailed = false
val minimum = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
check(minimum > 0) { "AudioRecord minimum=$minimum" }
val bytes = minimum.coerceAtLeast(FRAME_SAMPLES * 2) * 2
val device = AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT, bytes)
audioRecord = device
check(device.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord not initialized" }
device.startRecording()
check(device.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "AudioRecord did not start" }
captureRunning = true
recordingThread = Thread({ captureLoop(device, mode) }, "utun-voice-capture").apply { priority = Thread.MAX_PRIORITY }
recordingThread!!.start()
LogManager.addLog("INFO", "AudioRecorder", "capture started mode=$mode target=$target bufferBytes=$bytes")
return true
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "capture start failed mode=$mode target=$target: $e")
if (captureMode != null) { captureFailed = true; finishCapture(false) }
return false
}
return true
}
fun stopRecording(): Int {
if (!isRecording) return 0
isRecording = false
audioRecord?.apply {
try { stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioRecorder", "capture stop failed: ${e.message}") }
private fun captureLoop(device: AudioRecord, mode: CaptureMode) {
val buffer = ShortArray(FRAME_SAMPLES)
var total = 0L
try {
while (captureRunning) {
check(device.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "AudioRecord stopped unexpectedly" }
val read = device.read(buffer, 0, buffer.size, AudioRecord.READ_NON_BLOCKING)
if (!captureRunning) break
check(read in 0..buffer.size) { "AudioRecord.read=$read" }
if (read == 0) { Thread.sleep(2); continue }
if (!NativeLib.voiceFeed(buffer.copyOf(read))) {
captureFailed = true
error("native voiceFeed rejected samples=$read")
}
total += read
}
} catch (e: Exception) {
if (captureRunning || captureFailed) {
captureFailed = true
LogManager.addLog("ERROR", "AudioRecorder", "capture failed mode=$mode samples=$total: $e")
}
} finally {
captureRunning = false
LogManager.addLog("DEBUG", "AudioRecorder", "capture worker exited mode=$mode samples=$total failed=$captureFailed")
}
recordingThread?.join()
recordingThread = null
audioRecord?.release()
audioRecord = null
val duration = NativeLib.voiceStop()
LogManager.addLog("INFO", "AudioRecorder", "stopped duration=${duration}ms")
return duration
}
fun cancelRecording() {
if (!isRecording) return
isRecording = false
audioRecord?.apply {
try { stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioRecorder", "capture cancel failed: ${e.message}") }
/** Под монитором lifecycle: native освобождается только после join и попытки Android release. */
private fun finishCapture(publish: Boolean): Int {
val mode = captureMode ?: return 0
captureRunning = false
recordingThread?.interrupt()
var failed = captureFailed
var interrupted = Thread.interrupted()
try {
try { audioRecord?.stop() }
catch (e: Exception) { failed = true; LogManager.addLog("WARN", "AudioRecorder", "capture stop failed mode=$mode: $e") }
while (recordingThread?.isAlive == true) {
try { recordingThread?.join() }
catch (e: InterruptedException) {
interrupted = true
LogManager.addLog("WARN", "AudioRecorder", "capture join interrupted mode=$mode; waiting for worker")
}
}
recordingThread = null
failed = failed || captureFailed
if (!releaseCaptureDevice()) failed = true
val duration = if (publish && !failed) NativeLib.voiceStop() else { NativeLib.voiceCancel(); if (publish) -1 else 0 }
LogManager.addLog(if (duration < 0) "ERROR" else "INFO", "AudioRecorder",
"capture finished mode=$mode publish=$publish failed=$failed durationMs=$duration")
return duration
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "native capture finish failed mode=$mode: $e")
try { NativeLib.voiceCancel() }
catch (cleanup: Exception) { LogManager.addLog("ERROR", "AudioRecorder", "native capture cancel failed: $cleanup") }
return -1
} finally {
captureMode = null
if (interrupted) Thread.currentThread().interrupt()
}
recordingThread?.join()
recordingThread = null
audioRecord?.release()
audioRecord = null
NativeLib.voiceCancel()
LogManager.addLog("INFO", "AudioRecorder", "cancelled")
}
fun isActive(): Boolean = isRecording
/** Под монитором lifecycle; отказ release сохраняет владение для следующего cleanup/start. */
private fun releaseCaptureDevice(): Boolean {
val device = audioRecord ?: return true
return try {
device.release(); audioRecord = null
LogManager.addLog("DEBUG", "AudioRecorder", "capture device released mode=$captureMode")
true
} catch (e: Exception) {
LogManager.addLog("WARN", "AudioRecorder", "capture release failed mode=$captureMode; retained for retry: $e")
false
}
}
fun startMicTest(): Boolean {
if (StreamController.blocksCapture()) return false
/** Длительность native в мс, -1 — отказ/запись отброшена после ошибки, 0 — нет записи.
* Успех означает передачу подготовки в native, публикация сообщения подтверждается отдельно событиями. */
@Synchronized fun stopRecording(): Int {
if (captureMode == CaptureMode.RECORD) return finishCapture(true)
if (captureMode == null) releaseCaptureDevice()
return 0
}
@Synchronized fun cancelRecording() {
if (captureMode == CaptureMode.RECORD) finishCapture(false) else if (captureMode == null) releaseCaptureDevice()
}
fun isActive(): Boolean = captureMode == CaptureMode.RECORD && captureRunning
/** Индикатор уровня через native recorder; успешный тест нужно завершить stopMicTest без публикации. */
@Synchronized fun startMicTest(): Boolean {
if (StreamController.blocksCapture()) {
LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: stream owns capture")
return false
}
if (isTesting) return true
if (!hasPermission()) return false
NativeLib.voiceStart("__test__")
val bufferSize = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(FRAME_SAMPLES * 2)
testRecord = try {
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize * 2
)
} catch (e: Exception) {
NativeLib.voiceCancel(); return false
}
if (testRecord?.state != AudioRecord.STATE_INITIALIZED) {
testRecord?.release(); testRecord = null; NativeLib.voiceCancel(); return false
}
isTesting = true
testRecord?.startRecording()
testThread = Thread {
val buffer = ShortArray(FRAME_SAMPLES)
while (isTesting && testRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
val read = testRecord?.read(buffer, 0, FRAME_SAMPLES) ?: break
if (read <= 0) break
NativeLib.voiceFeed(buffer.copyOf(read))
}
}.apply { priority = Thread.MAX_PRIORITY; start() }
return true
return startCapture(CaptureMode.TEST, "__test__")
}
fun stopMicTest() {
isTesting = false
testThread?.join(1000); testThread = null
testRecord?.apply { try { stop() } catch (_: Exception) {}; release() }
testRecord = null
NativeLib.voiceCancel()
@Synchronized fun stopMicTest() {
if (captureMode == CaptureMode.TEST) finishCapture(false) else if (captureMode == null) releaseCaptureDevice()
}
@Volatile private var playing = false
private var playThread: Thread? = null
@Volatile private var playGeneration = 0
/** Запустить с начала; callbacks приходят из worker только для текущего поколения, completion также при ошибке. */
fun playVoiceFile(filePath: String, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) {
playFromPosition(filePath, 0, onProgress, onComplete)
}
fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) {
/** Запустить со startMs, округлённого вниз до 20 мс; seek декодирует и отбрасывает предшествующий PCM.
* Отрицательный startMs ограничивается нулём; прогресс учитывает только принятые AudioTrack сэмплы. */
@Synchronized fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) {
stopPlayback()
playing = true
val myGen = playGeneration
@@ -210,41 +212,56 @@ class AudioRecorderManager {
return@Thread
}
val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2]
check(sampleRate in listOf(8000, 12000, 16000, 24000, 48000) && channels in 1..2 && totalSamples > 0) {
"invalid decode info rate=$sampleRate channels=$channels samples=$totalSamples"
}
/* skip to startMs by decoding and discarding frames */
// Seek выполняется по полным 20мс кадрам; позиция берётся из фактически пропущенных сэмплов.
val frameSamples = sampleRate / 50 * channels
val skipFrames = startMs / FRAME_MS
val skipFrames = startMs.coerceAtLeast(0) / FRAME_MS
val skipBuf = ShortArray(frameSamples)
var skippedSamples = 0L
for (f in 0 until skipFrames) {
if (myGen != playGeneration) return@Thread
val dec = NativeLib.voiceDecodeRead(handle, skipBuf)
if (dec <= 0) break
check(dec in 0..skipBuf.size) { "decode seek failed result=$dec" }
if (dec == 0) break
skippedSamples += dec
}
val bufSize = AudioTrack.getMinBufferSize(sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT)
check(bufSize > 0) { "AudioTrack minimum=$bufSize" }
val output = AudioTrack(AudioManager.STREAM_MUSIC, sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT, bufSize, AudioTrack.MODE_STREAM)
track = output
check(output.state == AudioTrack.STATE_INITIALIZED) { "AudioTrack not initialized" }
output.play()
LogManager.addLog("INFO", "AudioPlayer", "playback started generation=$myGen rate=$sampleRate channels=$channels " +
"skippedSamples=$skippedSamples totalSamples=$totalSamples")
val pcm = ShortArray(frameSamples)
var readTotal = 0L
var read: Int
while (playing && myGen == playGeneration) {
read = NativeLib.voiceDecodeRead(handle, pcm)
if (read <= 0) break
check(read in 0..pcm.size) { "decode read failed result=$read" }
if (read == 0) break
if (myGen != playGeneration) break
output.write(pcm, 0, read)
readTotal += read
var offset = 0
while (playing && myGen == playGeneration && offset < read) {
val written = output.write(pcm, offset, read - offset)
check(written in 1..(read - offset)) { "AudioTrack.write=$written remaining=${read - offset}" }
offset += written; readTotal += written
}
val total = (skippedSamples + readTotal).toFloat() / totalSamples.coerceAtLeast(1).toFloat()
val eMs = startMs + (readTotal * FRAME_MS / frameSamples).toInt()
onProgress?.invoke(total, eMs)
val eMs = ((skippedSamples + readTotal) * 1000 / (sampleRate * channels)).toInt()
synchronized(this@AudioRecorderManager) {
if (myGen == playGeneration) onProgress?.invoke(total, eMs)
}
}
val drainMs = (bufSize * 1000L) / (sampleRate.toLong() * channels * 2) + 100
if (myGen == playGeneration) Thread.sleep(drainMs)
@@ -255,20 +272,30 @@ class AudioRecorderManager {
} finally {
track?.apply {
try { stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: ${e.message}") }
release()
try { release() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "release failed: $e") }
}
if (handle != 0L) NativeLib.voiceDecodeClose(handle)
if (playGeneration == myGen) { playing = false; onComplete() }
try { if (handle != 0L) NativeLib.voiceDecodeClose(handle) }
catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "decode close failed generation=$myGen: $e") }
synchronized(this@AudioRecorderManager) {
if (playGeneration == myGen) {
playing = false; playThread = null
try { onComplete() }
catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "completion callback failed generation=$myGen: $e") }
}
}
LogManager.addLog("DEBUG", "AudioPlayer", "playback worker exited generation=$myGen current=$playGeneration")
}
}.apply { start() }
}
fun pausePlayback() {
/** Асинхронно остановить текущий worker; позицию для следующего playFromPosition хранит вызывающий код. */
@Synchronized fun pausePlayback() {
playGeneration++; playing = false
playThread?.interrupt(); playThread = null
}
fun stopPlayback() {
/** Инвалидировать callbacks и прервать worker; собственные AudioTrack/decoder он освобождает в finally. */
@Synchronized fun stopPlayback() {
playGeneration++; playing = false
playThread?.interrupt(); playThread = null
}
@@ -9,6 +9,7 @@ object NativeLib {
@Volatile var active = false
var acceptStart = true
var acceptFeed = true
var onFeed: () -> Unit = {}
var stopResult = 800
val starts = CopyOnWriteArrayList<List<Any>>()
val feeds = CopyOnWriteArrayList<ShortArray>()
@@ -18,7 +19,7 @@ object NativeLib {
val decoderCloses = AtomicInteger()
var decoderInfo = intArrayOf(48000, 1, 960)
fun reset() {
active = false; acceptStart = true; acceptFeed = true; stopResult = 800
active = false; acceptStart = true; acceptFeed = true; stopResult = 800; onFeed = {}
starts.clear(); feeds.clear(); stops.set(0); cancels.set(0)
decoderReads.clear(); decoderCloses.set(0); decoderInfo = intArrayOf(48000, 1, 960)
}
@@ -27,7 +28,7 @@ object NativeLib {
if (!acceptStart || active) return false
active = true; return true
}
fun voiceFeed(pcm: ShortArray): Boolean { feeds.add(pcm.copyOf()); return active && acceptFeed }
fun voiceFeed(pcm: ShortArray): Boolean { feeds.add(pcm.copyOf()); onFeed(); 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 }
@@ -98,6 +98,35 @@ class AudioRecorderManagerTest {
val result = runCatching { manager.stopRecording() }
assertEquals(-1, result.getOrNull()); assertFalse(NativeLib.active)
assertEquals(1, NativeLib.cancels.get()); diagnostic("release")
device.failures = emptySet()
manager.cancelRecording()
assertTrue("cleanup lost the unreleased device", device.released)
assertEquals(1, NativeLib.cancels.get())
}
@Test fun feedFailureWhileStopIsJoiningCannotPublishPartialPcm() {
val entered = CountDownLatch(1); val leave = CountDownLatch(1)
NativeLib.acceptFeed = false
NativeLib.onFeed = {
entered.countDown()
var interrupted = false
while (true) {
try { check(leave.await(1, TimeUnit.SECONDS)); break }
catch (_: InterruptedException) { interrupted = true }
}
if (interrupted) Thread.currentThread().interrupt()
}
val device = plan(); assertTrue(manager.startRecording("7"))
device.chunks.add(AudioRecord.Chunk())
assertTrue(entered.await(1, TimeUnit.SECONDS))
val stopped = java.util.concurrent.FutureTask { manager.stopRecording() }
val worker = Thread(stopped, "voice-stop-race").apply { start() }
try {
assertTrue(device.stopEntered.await(1, TimeUnit.SECONDS))
leave.countDown()
assertEquals(-1, stopped.get(1, TimeUnit.SECONDS))
assertEquals(0, NativeLib.stops.get()); assertEquals(1, NativeLib.cancels.get())
} finally { leave.countDown(); worker.join(2_000) }
}
@Test fun testReaderIsStoppedBeforeJoinAndStopFailureCannotHangIt() {