git.iko.soy/opencode-android
on 61cdc75 help sign in
240 lines 11 kB history raw
1package soy.iko.opencode.ui.chat
3import android.content.Context
4import android.os.Handler
5import android.os.Looper
6import android.speech.tts.TextToSpeech
7import android.speech.tts.UtteranceProgressListener
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.RememberObserver
10import androidx.compose.runtime.State
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.ui.platform.LocalContext
15/**
16 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
17 * when the composition leaves (so the process isn't left holding a TextToSpeech instance).
18 * The controller is a [RememberObserver], so an *abandoned* composition (one never committed)
19 * also releases the engine — a plain DisposableEffect would leak it in that case.
20 */
21@Composable
22fun rememberTtsController(): TtsController {
23 val context = LocalContext.current
24 return remember { TtsController(context.applicationContext) }
27/**
28 * Playback state of the current utterance, surfaced so the UI can show a play/pause/stop
29 * toggle. [PAUSED] is only reached from [PLAYING] via [TtsController.pause]; a fresh toggle
30 * resets to [IDLE].
31 */
32enum class TtsState { IDLE, PLAYING, PAUSED }
34/**
35 * Thin wrapper over Android [TextToSpeech] for reading an assistant message aloud. Exposes
36 * [speakingId] — the id of the message currently being spoken, or null — and [state] so the
37 * UI can show a play/pause/stop toggle. [toggle] plays a message or stops it if it's already
38 * the one speaking. [pause] / [resume] suspend and continue playback without losing position.
39 *
40 * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
41 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
42 * so state is cleared on the main thread via [mainHandler].
43 *
44 * Pause/resume: Android's TextToSpeech has no native pause — `stop()` discards the queue.
45 * To approximate pause, we track the chunk list and the index of the chunk currently playing
46 * (advanced by `onStart`); pause calls `stop()` and stashes the resume index; resume re-enqueues
47 * the remaining chunks back-to-back (starting with the paused-from one) so playback continues
48 * from roughly where it stopped.
49 */
50class TtsController(context: Context) : RememberObserver {
52 private val mainHandler = Handler(Looper.getMainLooper())
53 // Written from the TextToSpeech init callback (a binder thread) and read from the main
54 // thread in toggle(); @Volatile guarantees the main thread observes a successful init
55 // promptly instead of a stale false, which would silently drop the read-aloud request.
56 @Volatile private var ready = false
57 private var shutDown = false
59 private val _speakingId = mutableStateOf<String?>(null)
60 val speakingId: State<String?> = _speakingId
62 private val _state = mutableStateOf(TtsState.IDLE)
63 val state: State<TtsState> = _state
65 // The chunked text for the currently-speaking utterance, plus the index of the chunk
66 // currently (or about to be) playing. Tracked so pause()/resume() can re-enqueue the
67 // tail without re-splitting the text. Cleared when playback ends or is stopped.
68 private var chunks: List<String> = emptyList()
69 private var currentChunk = 0
70 private var pausedFromChunk = 0
72 private val tts: TextToSpeech = TextToSpeech(context) { status ->
73 ready = status == TextToSpeech.SUCCESS
74 }.apply {
75 setOnUtteranceProgressListener(object : UtteranceProgressListener() {
76 override fun onStart(utteranceId: String?) {
77 // Advance the current-chunk pointer so pause() can capture the right resume
78 // index. The tracked id is the final chunk; intermediate ids are "$id#$index".
79 val idx = utteranceId?.substringAfter('#')?.toIntOrNull()
80 if (idx != null) currentChunk = idx
81 }
82 override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
83 @Deprecated("Deprecated in Java", ReplaceWith(""))
84 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
85 override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
86 })
87 }
89 /** Clear the speaking state when the utterance that finished is the one we're tracking. */
90 private fun clearIfCurrent(utteranceId: String?) {
91 mainHandler.post {
92 if (_speakingId.value == utteranceId) {
93 _speakingId.value = null
94 _state.value = TtsState.IDLE
95 chunks = emptyList()
96 currentChunk = 0
97 pausedFromChunk = 0
98 }
99 }
100 }
102 /**
103 * Speak [text] for message [id], or stop if [id] is already the one being spoken.
104 *
105 * @return true if playback was (de)queued or stopped; false if the request was a no-op
106 * because the engine isn't ready (init failed or still pending) or the text is blank.
107 * Callers use this to surface feedback instead of the button appearing dead.
108 */
109 fun toggle(id: String, text: String): Boolean {
110 if (_speakingId.value == id) {
111 // Tapping play on the already-speaking message stops it (matching the prior
112 // behavior and the Stop icon the UI shows while playing).
113 stop(); return true
114 }
115 if (!ready || text.isBlank()) return false
116 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
117 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
118 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
119 val split = chunkForTts(text)
120 if (split.isEmpty()) return false
121 startPlayback(id, split, fromChunk = 0)
122 return true
123 }
125 /** Enqueue [split] for [id] starting at [fromChunk], marking the message as playing. */
126 private fun startPlayback(id: String, split: List<String>, fromChunk: Int) {
127 chunks = split
128 currentChunk = fromChunk
129 for (index in fromChunk until split.size) {
130 val chunk = split[index]
131 val queueMode = if (index == fromChunk) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
132 // Only the final chunk carries the tracked [id]; intermediate chunks get a
133 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
134 val utteranceId = if (index == split.lastIndex) id else "$id#$index"
135 val result = tts.speak(chunk, queueMode, null, utteranceId)
136 // If any chunk can't be enqueued, the final chunk's onDone (which would clear
137 // the Stop state) never fires — leaving the Stop button stuck with partial or
138 // no audio. Bail without marking this message as speaking so the button doesn't
139 // stick. A mid-queue failure also breaks the contiguous playback, so stopping
140 // is the safe default rather than playing a truncated prefix silently.
141 if (result != TextToSpeech.SUCCESS) {
142 if (index == fromChunk) {
143 _speakingId.value = null
144 _state.value = TtsState.IDLE
145 chunks = emptyList()
146 currentChunk = 0
147 } else {
148 stop()
149 }
150 return
151 }
152 }
153 _speakingId.value = id
154 _state.value = TtsState.PLAYING
155 }
157 /**
158 * Pause playback if a message is currently playing. Android's TextToSpeech has no native
159 * pause, so this stops the engine and stashes the index of the chunk that was playing;
160 * [resume] re-enqueues from there. No-op when not playing (e.g. already paused or idle).
161 */
162 fun pause() {
163 if (_state.value != TtsState.PLAYING) return
164 // Capture the chunk currently playing so resume restarts from it rather than the next
165 // one — otherwise pause skips a chunk. currentChunk is advanced by onStart, so for a
166 // mid-utterance pause this points at the chunk that was interrupted.
167 pausedFromChunk = currentChunk
168 tts.stop()
169 _state.value = TtsState.PAUSED
170 }
172 /**
173 * Resume playback after [pause], re-enqueuing the remaining chunks from the paused-from
174 * index. No-op when not paused (e.g. idle or already playing).
175 */
176 fun resume() {
177 if (_state.value != TtsState.PAUSED) return
178 val id = _speakingId.value ?: return
179 val split = chunks
180 val from = pausedFromChunk.coerceIn(0, split.lastIndex.coerceAtLeast(0))
181 if (split.isEmpty()) { stop(); return }
182 startPlayback(id, split, fromChunk = from)
183 }
185 /**
186 * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
187 * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
188 * boundaries, packing greedily, and hard-slices any single piece that still overflows.
189 */
190 private fun chunkForTts(text: String): List<String> {
191 val max = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
192 if (text.length <= max) return listOf(text)
193 val chunks = ArrayList<String>()
194 val current = StringBuilder()
195 fun flush() {
196 if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
197 }
198 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
199 var p = piece
200 // A single piece with no usable boundary can still exceed the limit: hard-slice it.
201 while (p.length > max) {
202 flush()
203 chunks.add(p.substring(0, max))
204 p = p.substring(max)
205 }
206 when {
207 p.isEmpty() -> Unit
208 current.isEmpty() -> current.append(p)
209 current.length + 1 + p.length <= max -> current.append(' ').append(p)
210 else -> { flush(); current.append(p) }
211 }
212 }
213 flush()
214 return chunks
215 }
217 fun stop() {
218 tts.stop()
219 _speakingId.value = null
220 _state.value = TtsState.IDLE
221 chunks = emptyList()
222 currentChunk = 0
223 pausedFromChunk = 0
224 }
226 fun shutdown() {
227 if (shutDown) return
228 shutDown = true
229 tts.stop()
230 tts.shutdown()
231 _speakingId.value = null
232 _state.value = TtsState.IDLE
233 }
235 // RememberObserver: release the engine whenever this instance leaves the composition,
236 // including an abandoned (never-committed) composition, which onForgotten doesn't cover.
237 override fun onRemembered() { /* engine is created eagerly in the constructor */ }
238 override fun onForgotten() = shutdown()
239 override fun onAbandoned() = shutdown()