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
14import soy.iko.opencode.data.network.NetworkConfig
17 * Remember a [TtsController] scoped to the current composition; it shuts the engine down
18 * when the composition leaves (so the process isn't left holding a TextToSpeech instance).
19 * The controller is a [RememberObserver], so an *abandoned* composition (one never committed)
20 * also releases the engine — a plain DisposableEffect would leak it in that case.
23fun rememberTtsController(): TtsController {
24 val context = LocalContext.current
25 return remember { TtsController(context.applicationContext) }
29 * Playback state of the current utterance, surfaced so the UI can show a play/pause/stop
30 * toggle. [PAUSED] is only reached from [PLAYING] via [TtsController.pause]; a fresh toggle
33enum class TtsState { IDLE, PLAYING, PAUSED }
36 * Thin wrapper over Android [TextToSpeech] for reading an assistant message aloud. Exposes
37 * [speakingId] — the id of the message currently being spoken, or null — and [state] so the
38 * UI can show a play/pause/stop toggle. [toggle] plays a message or stops it if it's already
39 * the one speaking. [pause] / [resume] suspend and continue playback without losing position.
41 * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
42 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
43 * so state is cleared on the main thread via [mainHandler].
45 * Pause/resume: Android's TextToSpeech has no native pause — `stop()` discards the queue.
46 * To approximate pause, we track the chunk list and the index of the chunk currently playing
47 * (advanced by `onStart`); pause calls `stop()` and stashes the resume index; resume re-enqueues
48 * the remaining chunks back-to-back (starting with the paused-from one) so playback continues
49 * from roughly where it stopped.
51class TtsController(context: Context) : RememberObserver {
53 private val mainHandler = Handler(Looper.getMainLooper())
54 // Written from the TextToSpeech init callback (a binder thread) and read from the main
55 // thread in toggle(); @Volatile guarantees the main thread observes a successful init
56 // promptly instead of a stale false, which would silently drop the read-aloud request.
57 @Volatile private var ready = false
58 private var shutDown = false
60 private val _speakingId = mutableStateOf<String?>(null)
61 val speakingId: State<String?> = _speakingId
63 private val _state = mutableStateOf(TtsState.IDLE)
64 val state: State<TtsState> = _state
66 // The chunked text for the currently-speaking utterance, plus the index of the chunk
67 // currently (or about to be) playing. Tracked so pause()/resume() can re-enqueue the
68 // tail without re-splitting the text. Cleared when playback ends or is stopped.
69 private var chunks: List<String> = emptyList()
70 private var currentChunk = 0
71 private var pausedFromChunk = 0
73 private val tts: TextToSpeech = TextToSpeech(context) { status ->
74 ready = status == TextToSpeech.SUCCESS
76 setOnUtteranceProgressListener(object : UtteranceProgressListener() {
77 override fun onStart(utteranceId: String?) {
78 // Advance the current-chunk pointer so pause() can capture the right resume
79 // index. The tracked id is the final chunk; intermediate ids are "$id#$index".
80 val idx = utteranceId?.substringAfter('#')?.toIntOrNull()
81 if (idx != null) currentChunk = idx
83 override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
84 @Deprecated("Deprecated in Java", ReplaceWith(""))
85 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
86 override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
90 /** Clear the speaking state when the utterance that finished is the one we're tracking. */
91 private fun clearIfCurrent(utteranceId: String?) {
93 if (_speakingId.value == utteranceId) {
94 _speakingId.value = null
95 _state.value = TtsState.IDLE
104 * Speak [text] for message [id], or stop if [id] is already the one being spoken.
106 * @return true if playback was (de)queued or stopped; false if the request was a no-op
107 * because the engine isn't ready (init failed or still pending) or the text is blank.
108 * Callers use this to surface feedback instead of the button appearing dead.
110 fun toggle(id: String, text: String): Boolean {
111 if (_speakingId.value == id) {
112 // Tapping play on the already-speaking message stops it (matching the prior
113 // behavior and the Stop icon the UI shows while playing).
116 if (!ready || text.isBlank()) return false
117 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
118 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
119 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
120 val split = chunkForTts(text)
121 if (split.isEmpty()) return false
122 startPlayback(id, split, fromChunk = 0)
126 /** Enqueue [split] for [id] starting at [fromChunk], marking the message as playing. */
127 private fun startPlayback(id: String, split: List<String>, fromChunk: Int) {
129 currentChunk = fromChunk
130 for (index in fromChunk until split.size) {
131 val chunk = split[index]
132 val queueMode = if (index == fromChunk) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
133 // Only the final chunk carries the tracked [id]; intermediate chunks get a
134 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
135 val utteranceId = if (index == split.lastIndex) id else "$id#$index"
136 val result = tts.speak(chunk, queueMode, null, utteranceId)
137 // If any chunk can't be enqueued, the final chunk's onDone (which would clear
138 // the Stop state) never fires — leaving the Stop button stuck with partial or
139 // no audio. Bail without marking this message as speaking so the button doesn't
140 // stick. A mid-queue failure also breaks the contiguous playback, so stopping
141 // is the safe default rather than playing a truncated prefix silently.
142 if (result != TextToSpeech.SUCCESS) {
143 if (index == fromChunk) {
144 _speakingId.value = null
145 _state.value = TtsState.IDLE
154 _speakingId.value = id
155 _state.value = TtsState.PLAYING
159 * Pause playback if a message is currently playing. Android's TextToSpeech has no native
160 * pause, so this stops the engine and stashes the index of the chunk that was playing;
161 * [resume] re-enqueues from there. No-op when not playing (e.g. already paused or idle).
164 if (_state.value != TtsState.PLAYING) return
165 // Capture the chunk currently playing so resume restarts from it rather than the next
166 // one — otherwise pause skips a chunk. currentChunk is advanced by onStart, so for a
167 // mid-utterance pause this points at the chunk that was interrupted.
168 pausedFromChunk = currentChunk
170 _state.value = TtsState.PAUSED
174 * Resume playback after [pause], re-enqueuing the remaining chunks from the paused-from
175 * index. No-op when not paused (e.g. idle or already playing).
178 if (_state.value != TtsState.PAUSED) return
179 val id = _speakingId.value ?: return
181 val from = pausedFromChunk.coerceIn(0, split.lastIndex.coerceAtLeast(0))
182 if (split.isEmpty()) { stop(); return }
183 startPlayback(id, split, fromChunk = from)
187 * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
188 * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
189 * boundaries, packing greedily up to [NetworkConfig.ttsChunkTargetMaxChars] (a tighter
190 * cap than the hard limit) so pause/resume restarts at a finer granularity — a pause
191 * mid-chunk restarts the whole chunk, so smaller chunks mean less replay on resume.
192 * Hard-slices any single piece that still overflows the absolute max.
194 private fun chunkForTts(text: String): List<String> {
195 val hardMax = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
196 val packTarget = NetworkConfig.ttsChunkTargetMaxChars.coerceAtMost(hardMax)
197 if (text.length <= packTarget) return listOf(text)
198 val chunks = ArrayList<String>()
199 val current = StringBuilder()
201 if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
203 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
205 // A single piece with no usable boundary can still exceed the hard limit: hard-slice it.
206 while (p.length > hardMax) {
208 chunks.add(p.substring(0, hardMax))
209 p = p.substring(hardMax)
213 current.isEmpty() -> current.append(p)
214 current.length + 1 + p.length <= packTarget -> current.append(' ').append(p)
215 else -> { flush(); current.append(p) }
224 _speakingId.value = null
225 _state.value = TtsState.IDLE
236 _speakingId.value = null
237 _state.value = TtsState.IDLE
240 // RememberObserver: release the engine whenever this instance leaves the composition,
241 // including an abandoned (never-committed) composition, which onForgotten doesn't cover.
242 override fun onRemembered() { /* engine is created eagerly in the constructor */ }
243 override fun onForgotten() = shutdown()
244 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