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 // currentChunk is written from onStart (a binder thread) and read from pause()/resume()
70 // on the main thread; without @Volatile the main thread could read a stale value and
71 // resume from the wrong chunk, replaying audio the user already heard. pausedFromChunk
72 // is derived from currentChunk in pause() and read in resume(), so it carries the same
73 // visibility requirement. chunks is only mutated on the main thread (start/stop/clear),
74 // so it doesn't need @Volatile.
75 private var chunks: List<String> = emptyList()
76 @Volatile private var currentChunk = 0
77 @Volatile private var pausedFromChunk = 0
79 private val tts: TextToSpeech = TextToSpeech(context) { status ->
80 ready = status == TextToSpeech.SUCCESS
82 setOnUtteranceProgressListener(object : UtteranceProgressListener() {
83 override fun onStart(utteranceId: String?) {
84 // Advance the current-chunk pointer so pause() can capture the right resume
85 // index. The tracked id is the final chunk; intermediate ids are "$id#$index".
86 val idx = utteranceId?.substringAfter('#')?.toIntOrNull()
87 if (idx != null) currentChunk = idx
89 override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
90 @Deprecated("Deprecated in Java", ReplaceWith(""))
91 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
92 override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
96 /** Clear the speaking state when the utterance that finished is the one we're tracking.
97 * Accepts both the final chunk's id (the bare [id]) and an intermediate chunk's id
98 * (`"$id#$index"`) — a [onError]/[onDone] on an intermediate chunk means the engine
99 * stopped or errored mid-playback, so the speaking state must clear or the Stop button
100 * stays stuck (the prior behavior only matched the bare id, leaving a runtime error on
101 * an intermediate chunk with no way to clear the UI). */
102 private fun clearIfCurrent(utteranceId: String?) {
104 val speaking = _speakingId.value
105 if (speaking != null && (utteranceId == speaking || utteranceId?.startsWith("$speaking#") == true)) {
106 _speakingId.value = null
107 _state.value = TtsState.IDLE
116 * Speak [text] for message [id], or stop if [id] is already the one being spoken.
118 * @return true if playback was (de)queued or stopped; false if the request was a no-op
119 * because the engine isn't ready (init failed or still pending) or the text is blank.
120 * Callers use this to surface feedback instead of the button appearing dead.
122 fun toggle(id: String, text: String): Boolean {
123 if (_speakingId.value == id) {
124 // Tapping play on the already-speaking message stops it (matching the prior
125 // behavior and the Stop icon the UI shows while playing).
128 if (!ready || text.isBlank()) return false
129 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
130 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
131 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
132 val split = chunkForTts(text)
133 if (split.isEmpty()) return false
134 startPlayback(id, split, fromChunk = 0)
138 /** Enqueue [split] for [id] starting at [fromChunk], marking the message as playing. */
139 private fun startPlayback(id: String, split: List<String>, fromChunk: Int) {
141 currentChunk = fromChunk
142 for (index in fromChunk until split.size) {
143 val chunk = split[index]
144 val queueMode = if (index == fromChunk) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
145 // Only the final chunk carries the tracked [id]; intermediate chunks get a
146 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
147 val utteranceId = if (index == split.lastIndex) id else "$id#$index"
148 val result = tts.speak(chunk, queueMode, null, utteranceId)
149 // If any chunk can't be enqueued, the final chunk's onDone (which would clear
150 // the Stop state) never fires — leaving the Stop button stuck with partial or
151 // no audio. Bail without marking this message as speaking so the button doesn't
152 // stick. A mid-queue failure also breaks the contiguous playback, so stopping
153 // is the safe default rather than playing a truncated prefix silently.
154 if (result != TextToSpeech.SUCCESS) {
155 if (index == fromChunk) {
156 _speakingId.value = null
157 _state.value = TtsState.IDLE
166 _speakingId.value = id
167 _state.value = TtsState.PLAYING
171 * Pause playback if a message is currently playing. Android's TextToSpeech has no native
172 * pause, so this stops the engine and stashes the index of the chunk that was playing;
173 * [resume] re-enqueues from there. No-op when not playing (e.g. already paused or idle).
176 if (_state.value != TtsState.PLAYING) return
177 // Capture the chunk currently playing so resume restarts from it rather than the next
178 // one — otherwise pause skips a chunk. currentChunk is advanced by onStart, so for a
179 // mid-utterance pause this points at the chunk that was interrupted.
180 pausedFromChunk = currentChunk
182 _state.value = TtsState.PAUSED
186 * Resume playback after [pause], re-enqueuing the remaining chunks from the paused-from
187 * index. No-op when not paused (e.g. idle or already playing).
190 if (_state.value != TtsState.PAUSED) return
191 val id = _speakingId.value ?: return
193 val from = pausedFromChunk.coerceIn(0, split.lastIndex.coerceAtLeast(0))
194 if (split.isEmpty()) { stop(); return }
195 startPlayback(id, split, fromChunk = from)
199 * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
200 * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
201 * boundaries, packing greedily up to [NetworkConfig.ttsChunkTargetMaxChars] (a tighter
202 * cap than the hard limit) so pause/resume restarts at a finer granularity — a pause
203 * mid-chunk restarts the whole chunk, so smaller chunks mean less replay on resume.
204 * Hard-slices any single piece that still overflows the absolute max.
206 private fun chunkForTts(text: String): List<String> {
207 val hardMax = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
208 val packTarget = NetworkConfig.ttsChunkTargetMaxChars.coerceAtMost(hardMax)
209 if (text.length <= packTarget) return listOf(text)
210 val chunks = ArrayList<String>()
211 val current = StringBuilder()
213 if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
215 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
217 // A single piece with no usable boundary can still exceed the hard limit: hard-slice it.
218 while (p.length > hardMax) {
220 chunks.add(p.substring(0, hardMax))
221 p = p.substring(hardMax)
225 current.isEmpty() -> current.append(p)
226 current.length + 1 + p.length <= packTarget -> current.append(' ').append(p)
227 else -> { flush(); current.append(p) }
236 _speakingId.value = null
237 _state.value = TtsState.IDLE
248 _speakingId.value = null
249 _state.value = TtsState.IDLE
252 // RememberObserver: release the engine whenever this instance leaves the composition,
253 // including an abandoned (never-committed) composition, which onForgotten doesn't cover.
254 override fun onRemembered() { /* engine is created eagerly in the constructor */ }
255 override fun onForgotten() = shutdown()
256 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