Skip to content

Commit 95febe0

Browse files
authored
fix(tts): add streamFlush() API and drain Kokoro buffer on streamStop (#1197)
## Description `Kokoro::stream` hung when the buffer held content without an end-of-sentence character: the timer-based force-flush (after `kStreamMaxSkippedIterations` idle iterations) tried to extract 0 characters, so the loop reset its skip counter without draining anything and `streamStop(false)` never returned. The same threshold also fired mid-token for LLM-style streaming, partitioning sentences before they finished. Replace the threshold with caller-driven flushing: - New `streamFlush()` API. Caller signals "drain what's currently buffered, EOS or not" — typically right before `streamStop(false)` for normal apps that ended on un-terminated content. - `streamStop(false)` now drains automatically (equivalent to `streamFlush()` plus auto-stop on empty buffer), so it's guaranteed to return even if the trailing tail has no EOS. - LLM-style callers feeding partial tokens never call `streamFlush()` — model punctuation drives natural EOS partitioning, and the residual tail is drained by `streamStop(false)` when generation completes. The skipped-iteration force-flush and its tuning constant `kStreamMaxSkippedIterations` are removed. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [ ] iOS - [x] Android ### Testing instructions 1. Build & run `apps/speech` on Android. 2. Open `Text to Speech - LLM Streaming`. 3. Tap Generate and let the story play through. 4. Tap Stop mid-stream — verify it returns promptly without hanging. ### Related issues Fixes #1153
1 parent 39577d0 commit 95febe0

10 files changed

Lines changed: 92 additions & 22 deletions

File tree

‎apps/speech/screens/TextToSpeechLLMScreen.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ export const TextToSpeechLLMScreen = ({ onBack }: TextToSpeechLLMProps) => {
140140
} catch (e) {
141141
console.error('Generation failed:', e);
142142
} finally {
143+
// LLM finished — partition any trailing un-terminated tail so it gets
144+
// synthesized before the stream closes.
145+
tts.streamFlush();
143146
tts.streamStop(false);
144147
await ttsPromise;
145148

‎docs/docs/03-hooks/01-natural-language-processing/useTextToSpeech.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ The module provides two ways to generate speech using either raw text or pre-gen
8585

8686
1. [**`forward({ text, speed, phonemize })`**](../../06-api-reference/interfaces/TextToSpeechType.md#forward): Generates the complete audio waveform at once. Returns a promise resolving to a `Float32Array`.
8787
2. [**`stream({ speed, phonemize, stopAutomatically, onNext, ... })`**](../../06-api-reference/interfaces/TextToSpeechType.md#stream): An async generator-like functionality (managed via callbacks like `onNext`) that yields chunks of audio as they are computed.
88-
This is ideal for reducing the "time to first audio" for long sentences. You can also dynamically insert text during the generation process using `streamInsert(text)` and stop it with `streamStop(instant)`.
88+
This is ideal for reducing the "time to first audio" for long sentences. You can also dynamically insert text during the generation process using `streamInsert(text)`, force-partition trailing content without an end-of-sentence character via `streamFlush()`, and stop the stream with `streamStop(instant)`.
8989

9090
:::tip Recommendation
9191
In most cases, the **`stream()`** method is recommended over `forward()`. It significantly reduces latency by allowing audio playback to begin as soon as the first chunk is synthesized, rather than waiting for the entire text to be processed.

‎docs/docs/04-typescript-api/01-natural-language-processing/TextToSpeechModule.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ The module provides a way to generate speech using either raw text or pre-genera
4949

5050
1. [**`forward(text, speed, phonemize)`**](../../06-api-reference/classes/TextToSpeechModule.md#forward): Generates the complete audio waveform at once. Returns a promise resolving to a `Float32Array`.
5151
- `phonemize` defaults to `true`. When set to `false`, the input is expected to be a string of IPA phonemes.
52-
2. [**`stream({ speed, phonemize, stopAutomatically, ... })`**](../../06-api-reference/classes/TextToSpeechModule.md#stream): An async generator that yields chunks of audio as they are computed. This is ideal for reducing the "time to first audio" for long sentences. In contrast to `forward`, it enables inserting text chunks dynamically into processing buffer with [**`streamInsert(text)`**](../../06-api-reference/classes/TextToSpeechModule.md#streaminsert) and allows stopping generation early with [**`streamStop(instant)`**](../../06-api-reference/classes/TextToSpeechModule.md#streamstop).
52+
2. [**`stream({ speed, phonemize, stopAutomatically, ... })`**](../../06-api-reference/classes/TextToSpeechModule.md#stream): An async generator that yields chunks of audio as they are computed. This is ideal for reducing the "time to first audio" for long sentences. In contrast to `forward`, it enables inserting text chunks dynamically into the processing buffer with [**`streamInsert(text)`**](../../06-api-reference/classes/TextToSpeechModule.md#streaminsert), force-partitioning trailing un-terminated content via [**`streamFlush()`**](../../06-api-reference/classes/TextToSpeechModule.md#streamflush), and stopping generation early with [**`streamStop(instant)`**](../../06-api-reference/classes/TextToSpeechModule.md#streamstop).
5353

5454
### Using Phonemes
5555

‎packages/react-native-executorch/common/rnexecutorch/host_objects/ModelHostObject.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@ template <typename Model> class ModelHostObject : public JsiHostObject {
231231
addFunctions(JSI_EXPORT_FUNCTION(
232232
ModelHostObject<Model>, synchronousHostFunction<&Model::streamInsert>,
233233
"streamInsert"));
234+
addFunctions(JSI_EXPORT_FUNCTION(
235+
ModelHostObject<Model>, synchronousHostFunction<&Model::streamFlush>,
236+
"streamFlush"));
234237
}
235238

236239
if constexpr (meta::HasGenerateFromString<Model>) {

‎packages/react-native-executorch/common/rnexecutorch/models/text_to_speech/kokoro/Kokoro.cpp‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ void Kokoro::stream(std::shared_ptr<jsi::Function> callback, float speed,
174174

175175
isStreaming_ = true;
176176
stopOnEmptyBuffer_ = stopOnEmptyBuffer;
177+
flushPending_ = false;
177178

178179
// The outer streaming loop is responsible for handling the input buffer.
179180
// The extracted text is then passed to the inner loop, which performs a
@@ -205,15 +206,21 @@ void Kokoro::stream(std::shared_ptr<jsi::Function> callback, float speed,
205206
? std::distance(eosIt, inputTextBuffer_.rend())
206207
: 0;
207208

208-
// To maximize the quality of the speech, we try to avoid processing
209-
// chunks which end in the middle of a sentence.
210-
if (chunkSize > 0 ||
211-
streamSkippedIterations >= params::kStreamMaxSkippedIterations) {
209+
// Default behavior: hold back partial content until an EOS arrives, so
210+
// we don't synthesize mid-sentence (relevant for LLM token streaming).
211+
// When the caller signals via `streamFlush()` / `streamStop(false)`
212+
// that they want the tail drained, take the entire searchable window
213+
// instead. The flush flag stays set until the buffer empties, so a
214+
// multi-chunk drain progresses across iterations.
215+
if (chunkSize == 0 && flushPending_.load()) {
216+
chunkSize = searchLimit;
217+
}
218+
if (chunkSize > 0) {
212219
input = inputTextBuffer_.substr(0, chunkSize);
213220
inputTextBuffer_.erase(0, chunkSize);
214-
streamSkippedIterations = 0;
215-
} else {
216-
streamSkippedIterations++;
221+
if (inputTextBuffer_.empty()) {
222+
flushPending_ = false;
223+
}
217224
}
218225
}
219226

@@ -317,7 +324,7 @@ void Kokoro::stream(std::shared_ptr<jsi::Function> callback, float speed,
317324
std::scoped_lock<std::mutex> lock(inputTextBufferMutex_);
318325
inputTextBuffer_.clear();
319326
isStreaming_ = false;
320-
streamSkippedIterations = 0;
327+
flushPending_ = false;
321328
}
322329
}
323330

@@ -410,10 +417,14 @@ void Kokoro::streamInsert(std::u32string chunk) noexcept {
410417
inputTextBuffer_.append(chunk);
411418
}
412419

420+
void Kokoro::streamFlush() noexcept { flushPending_ = true; }
421+
413422
void Kokoro::streamStop(bool instant) noexcept {
414423
if (instant) {
415424
isStreaming_ = false;
416425
} else {
426+
// Ensure trailing un-terminated content is drained before the loop exits.
427+
flushPending_ = true;
417428
stopOnEmptyBuffer_ = true;
418429
}
419430
}

‎packages/react-native-executorch/common/rnexecutorch/models/text_to_speech/kokoro/Kokoro.h‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,24 @@ class Kokoro {
6060
*/
6161
void streamInsert(std::u32string chunk) noexcept;
6262

63+
/**
64+
* Requests the streaming loop to partition and synthesize whatever is
65+
* currently buffered, even if no end-of-sentence character is present.
66+
*
67+
* Use after the last `streamInsert` of an utterance when you want trailing
68+
* un-terminated content to play out without stopping the stream. LLM-style
69+
* callers feeding partial tokens typically should not call this — the
70+
* model's punctuation drives natural EOS partitioning.
71+
*/
72+
void streamFlush() noexcept;
73+
6374
/**
6475
* Signals the streaming process to stop.
6576
*
6677
* @param instant If true, stops immediately, discarding remaining buffered
67-
* text. If false, finishes processing the current buffer before stopping.
78+
* text. If false, drains the current buffer (force-flushing any trailing
79+
* un-terminated content) before stopping — equivalent to `streamFlush()`
80+
* followed by an automatic stop once the buffer empties.
6881
*/
6982
void streamStop(bool instant) noexcept;
7083

@@ -100,7 +113,11 @@ class Kokoro {
100113
// --- Streaming control State ---
101114
std::atomic<bool> isStreaming_{false};
102115
std::atomic<bool> stopOnEmptyBuffer_{true};
103-
int32_t streamSkippedIterations = 0;
116+
// Set by `streamFlush()` or `streamStop(false)`. While true, the stream
117+
// loop force-extracts the entire searchable window even when no EOS is
118+
// present. Cleared once the buffer drains so subsequent inserts go back to
119+
// EOS-aligned chunking.
120+
std::atomic<bool> flushPending_{false};
104121
};
105122
} // namespace models::text_to_speech::kokoro
106123

‎packages/react-native-executorch/common/rnexecutorch/models/text_to_speech/kokoro/Params.h‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,6 @@ namespace rnexecutorch::models::text_to_speech::kokoro::params {
2020
*/
2121
inline constexpr size_t kMaxTextSize = 2048;
2222

23-
/**
24-
* A number of skipped streaming iterations after which we process the remaining
25-
* input no matter how it looks like.
26-
*/
27-
inline constexpr int32_t kStreamMaxSkippedIterations = 3;
28-
2923
/**
3024
* A size of pause (in miliseconds) applied after each streaming iteration.
3125
*/

‎packages/react-native-executorch/src/hooks/natural_language_processing/useTextToSpeech.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ export const useTextToSpeech = (
141141
[moduleInstance]
142142
);
143143

144+
const streamFlush = useCallback(() => {
145+
if (moduleInstance) {
146+
moduleInstance.streamFlush();
147+
}
148+
}, [moduleInstance]);
149+
144150
const streamStop = useCallback(
145151
(instant: boolean = true) => {
146152
if (moduleInstance) {
@@ -157,6 +163,7 @@ export const useTextToSpeech = (
157163
forward,
158164
stream,
159165
streamInsert,
166+
streamFlush,
160167
streamStop,
161168
downloadProgress,
162169
};

‎packages/react-native-executorch/src/modules/natural_language_processing/TextToSpeechModule.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,17 +184,30 @@ export class TextToSpeechModule {
184184
}
185185

186186
/**
187-
* Inserts new content (text or IPA phonemes) into the buffer to be processed in streaming mode.
187+
* Inserts new content (text or IPA phonemes) into the buffer to be processed
188+
* in streaming mode. Trailing un-terminated content sits in the buffer
189+
* until {@link TextToSpeechModule.streamFlush} or `streamStop(false)`
190+
* releases it.
188191
* @param input - The text or phoneme fragment to append to the streaming buffer.
189192
*/
190193
public streamInsert(input: string): void {
191194
this.nativeModule.streamInsert(input);
192195
}
193196

197+
/**
198+
* Force-partitions whatever is currently buffered, even without an
199+
* end-of-sentence character. Call after the final `streamInsert` of an
200+
* utterance to play out the trailing tail without ending the stream.
201+
*/
202+
public streamFlush(): void {
203+
this.nativeModule.streamFlush();
204+
}
205+
194206
/**
195207
* Stops the streaming process if there is any ongoing.
196208
* @param instant - If true, stops the streaming as soon as possible. Otherwise
197-
* allows the module to complete processing for the remains of the buffer.
209+
* drains the current buffer (force-flushing any trailing
210+
* un-terminated content) before stopping.
198211
*/
199212
public streamStop(instant: boolean = true): void {
200213
this.nativeModule.streamStop(instant);

‎packages/react-native-executorch/src/types/tts.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,36 @@ export interface TextToSpeechType {
135135
stream: (input: TextToSpeechStreamingInput) => Promise<void>;
136136

137137
/**
138-
* Inserts new text chunk into the buffer to be processed in streaming mode.
138+
* Inserts a new text chunk into the buffer to be processed in streaming mode.
139+
*
140+
* Chunks accumulate until an end-of-sentence character (`.?!;…`) appears in
141+
* the buffer, at which point they're partitioned and synthesized. If the
142+
* caller stops feeding before an EOS arrives, the trailing tail will sit in
143+
* the buffer until `streamFlush()` or `streamStop(false)` is called.
144+
* @param textChunk - Text (or IPA phonemes) to append to the streaming buffer.
139145
*/
140146
streamInsert: (textChunk: string) => void;
141147

148+
/**
149+
* Requests the streaming session to partition and synthesize whatever is
150+
* currently buffered, even if no end-of-sentence character is present.
151+
*
152+
* Call after the final `streamInsert` of an utterance when you want
153+
* trailing un-terminated content to play out without ending the stream.
154+
* LLM-style callers feeding partial tokens typically should not call this —
155+
* model punctuation drives natural EOS partitioning, and the residual tail
156+
* is drained by `streamStop(false)` when generation completes.
157+
*/
158+
streamFlush: () => void;
159+
142160
/**
143161
* Interrupts and stops the currently active audio generation stream.
144162
* @param instant If true, stops the streaming as soon as possible. Otherwise
145-
* allows the module to complete processing for the remains of the buffer.
163+
* drains the current buffer (force-flushing any trailing
164+
* un-terminated content) before stopping — equivalent to
165+
* calling {@link TextToSpeechType.streamFlush} followed by an
166+
* automatic stop once the buffer empties, so this call
167+
* always returns.
146168
*/
147169
streamStop: (instant?: boolean) => void;
148170
}

0 commit comments

Comments
 (0)