Avoid capture-lock acquisition while rolling back a playback worker launch

This commit is contained in:
evgeny committed 2026-10-29 13:35:19 +01:00
1 parent 3f2cc2abb3
commit 5ee87c535b
1 file changed
+103 -101
@@ -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()
}
}
}