From 5ee87c535b9aee5ec8d144646e47baf95f5bf954 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 10:20:48 +0100 Subject: [PATCH] Avoid capture-lock acquisition while rolling back a playback worker launch --- .../utun/chat/data/AudioRecorderManager.kt | 204 +++++++++--------- 1 file changed, 103 insertions(+), 101 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index 096f6530..53c4265d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -276,116 +276,118 @@ class AudioRecorderManager { fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioPlayer", "play rejected: cleanup pending"); return } synchronized(playbackLock) { - stopPlayback() - playing = true - val myGen = playGeneration + stopPlayback() + playing = true + val myGen = playGeneration - val worker = Thread { - var handle = 0L - var track: AudioTrack? = null - try { - val info = IntArray(3) - handle = NativeLib.voiceDecodeOpen(filePath, info) - if (handle == 0L) { - LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath") - 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" - } - - // Seek выполняется по полным 20мс кадрам; позиция берётся из фактически пропущенных сэмплов. - val frameSamples = sampleRate / 50 * channels - 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) - 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) - check(read in 0..pcm.size) { "decode read failed result=$read" } - if (read == 0) break - if (myGen != playGeneration) break - 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 worker = Thread { + var handle = 0L + var track: AudioTrack? = null + try { + val info = IntArray(3) + handle = NativeLib.voiceDecodeOpen(filePath, info) + if (handle == 0L) { + LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath") + return@Thread } - val total = (skippedSamples + readTotal).toFloat() / totalSamples.coerceAtLeast(1).toFloat() - val eMs = ((skippedSamples + readTotal) * 1000 / (sampleRate * channels)).toInt() - synchronized(playbackLock) { - if (myGen == playGeneration) onProgress?.invoke(total, eMs) + 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" } - } - val drainMs = (bufSize * 1000L) / (sampleRate.toLong() * channels * 2) + 100 - if (myGen == playGeneration) Thread.sleep(drainMs) - } catch (_: InterruptedException) { - LogManager.addLog("DEBUG", "AudioPlayer", "playback cancelled") - } catch (e: Exception) { - LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") - } finally { - var retryMs = 250L - var notified = false - var interrupted = Thread.interrupted() - while (true) { - track?.let { output -> - try { output.stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: $e") } - try { output.release(); track = null } - catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "release failed; worker retains output: $e") } + + // Seek выполняется по полным 20мс кадрам; позиция берётся из фактически пропущенных сэмплов. + val frameSamples = sampleRate / 50 * channels + 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) + check(dec in 0..skipBuf.size) { "decode seek failed result=$dec" } + if (dec == 0) break + skippedSamples += dec } - try { if (handle != 0L) { NativeLib.voiceDecodeClose(handle); handle = 0 } } - catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "decode close failed generation=$myGen; retained: $e") } - if (!notified) { + + 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) + check(read in 0..pcm.size) { "decode read failed result=$read" } + if (read == 0) break + if (myGen != playGeneration) break + 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 = ((skippedSamples + readTotal) * 1000 / (sampleRate * channels)).toInt() synchronized(playbackLock) { - if (playGeneration == myGen) { - playing = false; playThread = null - try { onComplete() } - catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "completion callback failed generation=$myGen: $e") } - } + if (myGen == playGeneration) onProgress?.invoke(total, eMs) } - notified = true } - if (track == null && handle == 0L) break - try { Thread.sleep(retryMs) } catch (_: InterruptedException) { interrupted = true } - retryMs = (retryMs * 2).coerceAtMost(30_000) + val drainMs = (bufSize * 1000L) / (sampleRate.toLong() * channels * 2) + 100 + if (myGen == playGeneration) Thread.sleep(drainMs) + } catch (_: InterruptedException) { + LogManager.addLog("DEBUG", "AudioPlayer", "playback cancelled") + } catch (e: Exception) { + LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") + } finally { + var retryMs = 250L + var notified = false + var interrupted = Thread.interrupted() + while (true) { + track?.let { output -> + try { output.stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: $e") } + try { output.release(); track = null } + catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "release failed; worker retains output: $e") } + } + try { if (handle != 0L) { NativeLib.voiceDecodeClose(handle); handle = 0 } } + catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "decode close failed generation=$myGen; retained: $e") } + if (!notified) { + synchronized(playbackLock) { + if (playGeneration == myGen) { + playing = false; playThread = null + try { onComplete() } + catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "completion callback failed generation=$myGen: $e") } + } + } + notified = true + } + if (track == null && handle == 0L) break + try { Thread.sleep(retryMs) } catch (_: InterruptedException) { interrupted = true } + retryMs = (retryMs * 2).coerceAtMost(30_000) + } + playbackWorkers.remove(Thread.currentThread()); forgetIfIdle() + if (interrupted) Thread.currentThread().interrupt() + LogManager.addLog("DEBUG", "AudioPlayer", "playback worker exited generation=$myGen current=$playGeneration") } - playbackWorkers.remove(Thread.currentThread()); forgetIfIdle() - if (interrupted) Thread.currentThread().interrupt() - LogManager.addLog("DEBUG", "AudioPlayer", "playback worker exited generation=$myGen current=$playGeneration") } - } - playThread = worker; playbackWorkers.add(worker); owners.add(this) - try { worker.start() } - catch (e: Exception) { - playbackWorkers.remove(worker); playThread = null; playing = false; forgetIfIdle() - LogManager.addLog("ERROR", "AudioPlayer", "Playback worker start failed: $e") - onComplete() - } + playThread = worker; playbackWorkers.add(worker); owners.add(this) + try { worker.start() } + catch (e: Exception) { + playbackWorkers.remove(worker); playThread = null; playing = false + LogManager.addLog("ERROR", "AudioPlayer", "Playback worker start failed: $e") + try { onComplete() } + catch (callback: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "Startup failure callback failed: $callback") } + requestClose() + } } }