diff --git a/CHANGELOG.md b/CHANGELOG.md index 957528ddf7d..01ff46d6f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -243,6 +243,7 @@ Docs: https://docs.openclaw.ai - Android/theme: switch status bar icon contrast with the active system theme so Android light mode no longer leaves unreadable light icons over the app header. (#51098) Thanks @goweii. - Discord/ACP: forward worker abort signals into ACP turns so timed-out Discord jobs cancel the running turn instead of silently leaving the bound ACP session working in the background. - Gateway/openresponses: preserve assistant commentary and session continuity across hosted-tool `/v1/responses` turns, and emit streamed tool-call payloads before finalization so client tool loops stay resumable. (#52171) Thanks @CharZhou. +- Android/Talk: serialize `TalkModeManager` player teardown so rapid interrupt/restart cycles stop double-releasing or overlapping TTS playback. (#52310) Thanks @Kaneki-x. ### Breaking diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 2a82588b46b..64886c94a97 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -104,6 +104,7 @@ class TalkModeManager( private val playbackGeneration = AtomicLong(0L) private var ttsJob: Job? = null + private val playerLock = Any() private var player: MediaPlayer? = null @Volatile private var finalizeInFlight = false private var listenWatchdogJob: Job? = null @@ -763,7 +764,9 @@ class TalkModeManager( try { withContext(Dispatchers.IO) { tempFile.writeBytes(audioBytes) } val player = MediaPlayer() - this.player = player + synchronized(playerLock) { + this.player = player + } val finished = CompletableDeferred() player.setAudioAttributes( AudioAttributes.Builder() @@ -784,7 +787,7 @@ class TalkModeManager( ensurePlaybackActive(playbackToken) } finally { try { - cleanupPlayer() + cleanupPlayer(player) } catch (_: Throwable) {} tempFile.delete() } @@ -821,7 +824,11 @@ class TalkModeManager( return } if (resetInterrupt) { - val currentMs = player?.currentPosition?.toDouble() ?: 0.0 + val currentMs = synchronized(playerLock) { + try { + player?.currentPosition?.toDouble() ?: 0.0 + } catch (_: IllegalStateException) { 0.0 } + } lastInterruptedAtSeconds = currentMs / 1000.0 } cleanupPlayer() @@ -864,10 +871,16 @@ class TalkModeManager( audioFocusRequest = null } - private fun cleanupPlayer() { - player?.stop() - player?.release() - player = null + private fun cleanupPlayer(expectedPlayer: MediaPlayer? = null) { + synchronized(playerLock) { + val p = player ?: return + if (expectedPlayer != null && p !== expectedPlayer) return + player = null + try { + p.stop() + } catch (_: IllegalStateException) {} + p.release() + } } private fun shouldInterrupt(transcript: String): Boolean {