git.iko.soy/opencode-android
on d518ef7 help sign in
257 lines 12 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 // 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
81 }.apply {
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
88 }
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)
93 })
94 }
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?) {
103 mainHandler.post {
104 val speaking = _speakingId.value
105 if (speaking != null && (utteranceId == speaking || utteranceId?.startsWith("$speaking#") == true)) {
106 _speakingId.value = null
107 _state.value = TtsState.IDLE
108 chunks = emptyList()
109 currentChunk = 0
110 pausedFromChunk = 0
111 }
112 }
113 }
115 /**
116 * Speak [text] for message [id], or stop if [id] is already the one being spoken.
117 *
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.
121 */
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).
126 stop(); return true
127 }
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)
135 return true
136 }
138 /** Enqueue [split] for [id] starting at [fromChunk], marking the message as playing. */
139 private fun startPlayback(id: String, split: List<String>, fromChunk: Int) {
140 chunks = split
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
158 chunks = emptyList()
159 currentChunk = 0
160 } else {
161 stop()
162 }
163 return
164 }
165 }
166 _speakingId.value = id
167 _state.value = TtsState.PLAYING
168 }
170 /**
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).
174 */
175 fun pause() {
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
181 tts.stop()
182 _state.value = TtsState.PAUSED
183 }
185 /**
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).
188 */
189 fun resume() {
190 if (_state.value != TtsState.PAUSED) return
191 val id = _speakingId.value ?: return
192 val split = chunks
193 val from = pausedFromChunk.coerceIn(0, split.lastIndex.coerceAtLeast(0))
194 if (split.isEmpty()) { stop(); return }
195 startPlayback(id, split, fromChunk = from)
196 }
198 /**
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.
205 */
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()
212 fun flush() {
213 if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
214 }
215 for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
216 var p = piece
217 // A single piece with no usable boundary can still exceed the hard limit: hard-slice it.
218 while (p.length > hardMax) {
219 flush()
220 chunks.add(p.substring(0, hardMax))
221 p = p.substring(hardMax)
222 }
223 when {
224 p.isEmpty() -> Unit
225 current.isEmpty() -> current.append(p)
226 current.length + 1 + p.length <= packTarget -> current.append(' ').append(p)
227 else -> { flush(); current.append(p) }
228 }
229 }
230 flush()
231 return chunks
232 }
234 fun stop() {
235 tts.stop()
236 _speakingId.value = null
237 _state.value = TtsState.IDLE
238 chunks = emptyList()
239 currentChunk = 0
240 pausedFromChunk = 0
241 }
243 fun shutdown() {
244 if (shutDown) return
245 shutDown = true
246 tts.stop()
247 tts.shutdown()
248 _speakingId.value = null
249 _state.value = TtsState.IDLE
250 }
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()