git.iko.soy/opencode-android
on 2794fda help sign in
245 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
14import soy.iko.opencode.data.network.NetworkConfig
16/**
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.
21 */
22@Composable
23fun rememberTtsController(): TtsController {
24 val context = LocalContext.current
25 return remember { TtsController(context.applicationContext) }
28/**
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
31 * resets to [IDLE].
32 */
33enum class TtsState { IDLE, PLAYING, PAUSED }
35/**
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.
40 *
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].
44 *
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.
50 */
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
75 }.apply {
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
82 }
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)
87 })
88 }
90 /** Clear the speaking state when the utterance that finished is the one we're tracking. */
91 private fun clearIfCurrent(utteranceId: String?) {
92 mainHandler.post {
93 if (_speakingId.value == utteranceId) {
94 _speakingId.value = null
95 _state.value = TtsState.IDLE
96 chunks = emptyList()
97 currentChunk = 0
98 pausedFromChunk = 0
99 }
100 }
101 }
103 /**
104 * Speak [text] for message [id], or stop if [id] is already the one being spoken.
105 *
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.
109 */
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).
114 stop(); return true
115 }
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)
123 return true
124 }
126 /** Enqueue [split] for [id] starting at [fromChunk], marking the message as playing. */
127 private fun startPlayback(id: String, split: List<String>, fromChunk: Int) {
128 chunks = split
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
146 chunks = emptyList()
147 currentChunk = 0
148 } else {
149 stop()
150 }
151 return
152 }
153 }
154 _speakingId.value = id
155 _state.value = TtsState.PLAYING
156 }
158 /**
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).
162 */
163 fun pause() {
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
169 tts.stop()
170 _state.value = TtsState.PAUSED
171 }
173 /**
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).
176 */
177 fun resume() {
178 if (_state.value != TtsState.PAUSED) return
179 val id = _speakingId.value ?: return
180 val split = chunks
181 val from = pausedFromChunk.coerceIn(0, split.lastIndex.coerceAtLeast(0))
182 if (split.isEmpty()) { stop(); return }
183 startPlayback(id, split, fromChunk = from)
184 }
186 /**
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.
193 */
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()
200 fun flush() {
201 if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
202 }
203 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
204 var p = piece
205 // A single piece with no usable boundary can still exceed the hard limit: hard-slice it.
206 while (p.length > hardMax) {
207 flush()
208 chunks.add(p.substring(0, hardMax))
209 p = p.substring(hardMax)
210 }
211 when {
212 p.isEmpty() -> Unit
213 current.isEmpty() -> current.append(p)
214 current.length + 1 + p.length <= packTarget -> current.append(' ').append(p)
215 else -> { flush(); current.append(p) }
216 }
217 }
218 flush()
219 return chunks
220 }
222 fun stop() {
223 tts.stop()
224 _speakingId.value = null
225 _state.value = TtsState.IDLE
226 chunks = emptyList()
227 currentChunk = 0
228 pausedFromChunk = 0
229 }
231 fun shutdown() {
232 if (shutDown) return
233 shutDown = true
234 tts.stop()
235 tts.shutdown()
236 _speakingId.value = null
237 _state.value = TtsState.IDLE
238 }
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()