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
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.
22fun rememberTtsController(): TtsController {
23 val context = LocalContext.current
24 return remember { TtsController(context.applicationContext) }
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
32enum class TtsState { IDLE, PLAYING, PAUSED }
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.
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].
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.
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
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
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)
89 /** Clear the speaking state when the utterance that finished is the one we're tracking. */
90 private fun clearIfCurrent(utteranceId: String?) {
92 if (_speakingId.value == utteranceId) {
93 _speakingId.value = null
94 _state.value = TtsState.IDLE
103 * Speak [text] for message [id], or stop if [id] is already the one being spoken.
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.
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).
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)
125 /** Enqueue [split] for [id] starting at [fromChunk], marking the message as playing. */
126 private fun startPlayback(id: String, split: List<String>, fromChunk: Int) {
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
153 _speakingId.value = id
154 _state.value = TtsState.PLAYING
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).
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
169 _state.value = TtsState.PAUSED
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).
177 if (_state.value != TtsState.PAUSED) return
178 val id = _speakingId.value ?: return
180 val from = pausedFromChunk.coerceIn(0, split.lastIndex.coerceAtLeast(0))
181 if (split.isEmpty()) { stop(); return }
182 startPlayback(id, split, fromChunk = from)
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.
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()
196 if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
198 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
200 // A single piece with no usable boundary can still exceed the limit: hard-slice it.
201 while (p.length > max) {
203 chunks.add(p.substring(0, max))
208 current.isEmpty() -> current.append(p)
209 current.length + 1 + p.length <= max -> current.append(' ').append(p)
210 else -> { flush(); current.append(p) }
219 _speakingId.value = null
220 _state.value = TtsState.IDLE
231 _speakingId.value = null
232 _state.value = TtsState.IDLE
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()
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list