@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
@@ -4,14 +4,17 @@
4
4
  * Replaces the spawn-`afplay`-per-sentence approach (a fresh process per chunk
5
5
  * meant audible gaps, per-spawn latency, and no way to interrupt a clip mid-play)
6
6
  * with a single persistent player process fed raw 32-bit-float mono PCM over
7
- * stdin. Chunks are queued and drained by one writer so sentences play back to
7
+ * stdin. Chunks are queued and drained by one writer so segments play back to
8
8
  * back; writes are paced to stay only {@link LEAD_SECONDS} ahead of realtime so
9
9
  * ducking and stop take effect promptly instead of after seconds of buffered
10
10
  * audio. {@link StreamingAudioPlayer.stop} kills the process for instant silence.
11
11
  *
12
- * Where no streaming backend exists (Windows, or macOS without the bundled
13
- * ffmpeg), it degrades to the per-file {@link playAudioFile} path so speech still
14
- * works — just without gapless playback or mid-clip interruption.
12
+ * Where no streaming backend exists (Windows, or a host without ffmpeg/sox), it
13
+ * degrades to the per-file {@link playAudioFile} path so speech still works —
14
+ * just without gapless playback or mid-clip interruption. A backend that spawns
15
+ * but dies early (e.g. an ffmpeg built without its platform audio device) is
16
+ * detected via its exit and the session downgrades to per-file playback without
17
+ * dropping the chunk being played.
15
18
  */
16
19
  import * as fs from "node:fs/promises";
17
20
  import * as os from "node:os";
@@ -41,8 +44,7 @@ export interface StreamingPlayerLookup {
41
44
  * and plays it to the default output device. An empty list means no streaming
42
45
  * backend is available and the caller should fall back to per-file playback.
43
46
  *
44
- * - darwin: none; `afplay` is file-only, so macOS uses the interruptible
45
- * per-file fallback.
47
+ * - darwin: `ffmpeg` (AudioToolbox output device) sox's `play` (coreaudio).
46
48
  * - linux/other POSIX: `ffmpeg` (`-f pulse` then `-f alsa`) → `paplay`/`aplay`
47
49
  * raw fallbacks.
48
50
  * - win32: none (PowerShell `SoundPlayer` is file-only).
@@ -57,7 +59,19 @@ export function streamingPlayerCommandsFor(
57
59
  const rate = String(sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE);
58
60
  const input = ["-loglevel", "error", "-nostdin", "-f", "f32le", "-ar", rate, "-ac", "1", "-i", "pipe:0"];
59
61
 
60
- if (platform === "darwin") return [];
62
+ if (platform === "darwin") {
63
+ const commands: PlayerCommand[] = [];
64
+ const ffmpegBin = ffmpeg();
65
+ if (ffmpegBin) commands.push({ cmd: ffmpegBin, args: [...input, "-f", "audiotoolbox", "default"] });
66
+ const play = which("play");
67
+ if (play) {
68
+ commands.push({
69
+ cmd: play,
70
+ args: ["-q", "-t", "raw", "-e", "floating-point", "-b", "32", "-r", rate, "-c", "1", "-"],
71
+ });
72
+ }
73
+ return commands;
74
+ }
61
75
  if (platform === "win32") {
62
76
  return [];
63
77
  }
@@ -87,6 +101,8 @@ export class StreamingAudioPlayer {
87
101
  #mode: "stream" | "file" = "file";
88
102
  #proc: Subprocess<"pipe", "ignore", "ignore"> | null = null;
89
103
  #sink: FileSink | null = null;
104
+ /** Streaming backends not yet tried; consumed head-first by {@link #spawnStream}. */
105
+ #candidates: PlayerCommand[] | null = null;
90
106
  #writtenSec = 0;
91
107
  #startedAt = 0;
92
108
  #started = false;
@@ -133,27 +149,47 @@ export class StreamingAudioPlayer {
133
149
  this.#abortController.abort();
134
150
  this.#signal();
135
151
  try {
136
- this.#sink?.end();
152
+ // end() flushes asynchronously; the SIGKILL below races it, so a broken
153
+ // pipe here is expected — swallow the rejection (it otherwise surfaces
154
+ // as an unhandled EPIPE right as speech ends).
155
+ void Promise.resolve(this.#sink?.end()).catch(() => {});
137
156
  } catch {}
138
157
  try {
139
158
  this.#proc?.kill("SIGKILL");
140
159
  } catch {}
141
160
  }
142
161
 
162
+ /**
163
+ * Spawn the next untried streaming backend; false once the list is
164
+ * exhausted. A backend that spawns but dies early (e.g. an ffmpeg built
165
+ * without this platform's audio output device) would otherwise swallow PCM
166
+ * into a dead pipe, so its exit advances to the next candidate — or to
167
+ * per-file playback — and #writeStream's failure path replays the
168
+ * in-flight chunk.
169
+ */
143
170
  #spawnStream(): boolean {
144
- for (const command of streamingPlayerCommandsFor(process.platform, this.#sampleRate)) {
171
+ this.#candidates ??= streamingPlayerCommandsFor(process.platform, this.#sampleRate);
172
+ for (let command = this.#candidates.shift(); command; command = this.#candidates.shift()) {
173
+ const { cmd, args } = command;
145
174
  try {
146
- const proc = Bun.spawn([command.cmd, ...command.args], {
175
+ const proc = Bun.spawn([cmd, ...args], {
147
176
  stdin: "pipe",
148
177
  stdout: "ignore",
149
178
  stderr: "ignore",
150
179
  });
151
180
  this.#proc = proc;
152
181
  this.#sink = proc.stdin;
182
+ void proc.exited.then(code => {
183
+ if (this.#proc !== proc || this.#stopped || this.#inputClosed) return;
184
+ logger.debug("tts: streaming backend exited early; trying next backend", { cmd, code });
185
+ this.#proc = null;
186
+ this.#sink = null;
187
+ this.#mode = this.#spawnStream() ? "stream" : "file";
188
+ });
153
189
  return true;
154
190
  } catch (error) {
155
191
  logger.debug("tts: streaming player spawn failed", {
156
- cmd: command.cmd,
192
+ cmd,
157
193
  error: error instanceof Error ? error.message : String(error),
158
194
  });
159
195
  }
@@ -184,8 +220,20 @@ export class StreamingAudioPlayer {
184
220
  await Bun.sleep((ahead - LEAD_SECONDS) * 1000);
185
221
  if (this.#stopped) return;
186
222
  }
187
- this.#writeStream(chunk);
188
- this.#writtenSec += chunk.length / this.#sampleRate;
223
+ if (await this.#writeStream(chunk)) {
224
+ this.#writtenSec += chunk.length / this.#sampleRate;
225
+ continue;
226
+ }
227
+ // Backend died mid-write: move to the next streaming candidate
228
+ // (or the file path) and replay this exact chunk so nothing is
229
+ // dropped.
230
+ this.#mode = this.#spawnStream() ? "stream" : "file";
231
+ if (this.#mode === "stream" && (await this.#writeStream(chunk))) {
232
+ this.#writtenSec += chunk.length / this.#sampleRate;
233
+ } else {
234
+ this.#mode = "file";
235
+ await this.#playFile(chunk);
236
+ }
189
237
  } else {
190
238
  await this.#playFile(chunk);
191
239
  }
@@ -219,16 +267,23 @@ export class StreamingAudioPlayer {
219
267
  return promise;
220
268
  }
221
269
 
222
- #writeStream(pcm: Float32Array): void {
270
+ /**
271
+ * Write one chunk into the backend's stdin and await the flush — a broken
272
+ * pipe rejects here (not as an unhandled rejection later), so the caller
273
+ * can replay the exact chunk on the next backend.
274
+ */
275
+ async #writeStream(pcm: Float32Array): Promise<boolean> {
223
276
  const sink = this.#sink;
224
- if (!sink) return;
277
+ if (!sink) return false;
225
278
  try {
226
279
  sink.write(this.#bytes(pcm));
227
- sink.flush();
280
+ await sink.flush();
281
+ return true;
228
282
  } catch (error) {
229
283
  logger.debug("tts: streaming write failed", {
230
284
  error: error instanceof Error ? error.message : String(error),
231
285
  });
286
+ return false;
232
287
  }
233
288
  }
234
289
 
@@ -42,7 +42,7 @@ export interface TtsStreamOptions {
42
42
  signal?: AbortSignal;
43
43
  }
44
44
 
45
- /** One synthesized sentence of a streaming session, in emission order. */
45
+ /** One synthesized segment of a streaming session, in emission order. */
46
46
  export interface TtsAudioChunk {
47
47
  index: number;
48
48
  text: string;
@@ -51,10 +51,10 @@ export interface TtsAudioChunk {
51
51
  }
52
52
 
53
53
  /**
54
- * A live streaming-synthesis session. Feed text incrementally with {@link push}
55
- * and close the input with {@link end}; `chunks` yields each synthesized
56
- * sentence's audio as soon as it is ready, then completes once the worker
57
- * finishes draining the closed input.
54
+ * A live streaming-synthesis session. Feed complete speakable segments with
55
+ * {@link push} (the worker synthesizes each push as-is) and close the input
56
+ * with {@link end}; `chunks` yields each segment's audio as soon as it is
57
+ * ready, then completes once the worker finishes draining the closed input.
58
58
  */
59
59
  export interface TtsStreamHandle {
60
60
  push(text: string): void;
@@ -239,11 +239,12 @@ export class TtsClient {
239
239
  }
240
240
 
241
241
  /**
242
- * Open a streaming-synthesis session. Text is fed incrementally through the
243
- * returned handle's `push`/`end`; audio is emitted one synthesized sentence at
244
- * a time via `chunks`, so playback can begin before the full text is known.
245
- * Returns an inert handle (immediately-ended `chunks`) for unknown models or
246
- * an already-aborted signal, and fails the iterator if the worker cannot spawn.
242
+ * Open a streaming-synthesis session. Complete speakable segments are fed
243
+ * through the returned handle's `push`/`end`; audio is emitted one segment
244
+ * at a time via `chunks`, so playback can begin before the full text is
245
+ * known. Returns an inert handle (immediately-ended `chunks`) for unknown
246
+ * models or an already-aborted signal, and fails the iterator if the worker
247
+ * cannot spawn.
247
248
  */
248
249
  synthesizeStream(modelKey: string, options: TtsStreamOptions = {}): TtsStreamHandle {
249
250
  if (!isTtsLocalModelKey(modelKey) || options.signal?.aborted) {
@@ -24,10 +24,12 @@ export type TtsWorkerInbound =
24
24
  | { type: "ping"; id: string }
25
25
  | { type: "synthesize"; id: string; modelKey: TtsLocalModelKey; text: string; voice?: string }
26
26
  | { type: "download"; id: string; modelKey: TtsLocalModelKey }
27
- // Streaming synthesis: a session is opened with `stream-start`, fed incrementally
28
- // with `stream-push`, and closed with `stream-end`. `stream-cancel` interrupts
29
- // without a final drain. The worker emits an `audio-chunk` per synthesized
30
- // sentence and a final `stream-done` only for non-cancelled sessions.
27
+ // Streaming synthesis: a session is opened with `stream-start`, fed complete
28
+ // speakable segments with `stream-push` (the parent's SpeakableStream does all
29
+ // splitting/normalization; the worker synthesizes each push as-is), and closed
30
+ // with `stream-end`. `stream-cancel` interrupts without a final drain. The
31
+ // worker emits an `audio-chunk` per segment and a final `stream-done` only for
32
+ // non-cancelled sessions.
31
33
  | { type: "stream-start"; id: string; modelKey: TtsLocalModelKey; voice?: string }
32
34
  | { type: "stream-push"; id: string; text: string }
33
35
  | { type: "stream-end"; id: string }
@@ -40,7 +42,7 @@ export type TtsWorkerOutbound =
40
42
  | { type: "error"; id: string; error: string }
41
43
  | { type: "progress"; id: string; event: TtsProgressEvent }
42
44
  | { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
43
- // One synthesized sentence of a streaming session, in emission order, followed
45
+ // One synthesized segment of a streaming session, in emission order, followed
44
46
  // by a single `stream-done` once the input stream is closed and drained.
45
47
  | { type: "audio-chunk"; id: string; index: number; text: string; pcm: Float32Array; sampleRate: number }
46
48
  | { type: "stream-done"; id: string };
@@ -56,5 +58,12 @@ export type TtsWorkerOutbound =
56
58
  */
57
59
  export interface TtsTransport {
58
60
  send(message: TtsWorkerOutbound): void;
61
+ /**
62
+ * Send and resolve once the message has drained into the IPC channel.
63
+ * Streaming synthesis awaits this per audio chunk: ONNX inference blocks
64
+ * the worker's event loop for seconds at a time, so fire-and-forget sends
65
+ * queue unflushed until the session ends and arrive as one burst.
66
+ */
67
+ sendAndFlush(message: TtsWorkerOutbound): Promise<void>;
59
68
  onMessage(handler: (message: TtsWorkerInbound) => void): () => void;
60
69
  }
@@ -39,20 +39,6 @@ type KokoroDevice = "cpu" | "wasm" | "webgpu";
39
39
  /** A loaded Kokoro voice synthesizer (subset of `kokoro-js`'s `KokoroTTS`). */
40
40
  interface KokoroTtsInstance {
41
41
  generate(text: string, options: { voice: string }): Promise<RawAudio>;
42
- stream(
43
- text: string | TextSplitterStreamInstance,
44
- options: { voice: string },
45
- ): AsyncGenerator<{ text: string; phonemes: string; audio: RawAudio }, void, void>;
46
- }
47
-
48
- /**
49
- * Incremental text source for {@link KokoroTtsInstance.stream} (subset of
50
- * `kokoro-js`'s `TextSplitterStream`). Text pushed at any time is split into
51
- * complete sentences; `close` flushes the trailing buffer and ends the stream.
52
- */
53
- interface TextSplitterStreamInstance {
54
- push(...texts: string[]): void;
55
- close(): void;
56
42
  }
57
43
 
58
44
  /** `KokoroTTS` static surface used to load a model from the Hugging Face Hub. */
@@ -67,7 +53,6 @@ interface KokoroRuntime {
67
53
  },
68
54
  ): Promise<KokoroTtsInstance>;
69
55
  };
70
- TextSplitterStream: new () => TextSplitterStreamInstance;
71
56
  }
72
57
 
73
58
  /**
@@ -96,15 +81,18 @@ const kokoroRuntime = new MemoizedRuntime<KokoroRuntime>();
96
81
 
97
82
  /**
98
83
  * In-flight streaming sessions keyed by request id. A session is created on
99
- * `stream-start` and torn down when its generator finishes. Text pushed before
100
- * the model finishes loading is held in `buffered` and flushed into the splitter
101
- * once it exists; pushes after that go straight to the live splitter.
84
+ * `stream-start` and torn down when its run loop finishes. Each `stream-push`
85
+ * carries one complete speakable segment (the parent's `SpeakableStream` does
86
+ * all splitting and normalization); segments queue here and the run loop
87
+ * synthesizes them in arrival order, waking via `wake` when idle.
102
88
  */
103
89
  interface StreamSession {
104
90
  modelKey: TtsLocalModelKey;
105
91
  voice: string | undefined;
106
- buffered: string[];
107
- splitter: TextSplitterStreamInstance | null;
92
+ /** Speakable segments awaiting synthesis, in arrival order. */
93
+ queue: string[];
94
+ /** Resolves the run loop's idle wait when a push/end/cancel arrives. */
95
+ wake: (() => void) | null;
108
96
  ended: boolean;
109
97
  cancelled: boolean;
110
98
  }
@@ -316,43 +304,51 @@ async function handleQueuedRequest(
316
304
  }
317
305
 
318
306
  /**
319
- * Drive one streaming session to completion: load the model, create the
320
- * splitter, flush any text pushed before the model was ready, then emit one
321
- * `audio-chunk` per synthesized sentence followed by a single `stream-done`.
322
- * Serialized through {@link synthesizeQueue} so it never interleaves model
323
- * access with a batch synthesize/download.
307
+ * Drive one streaming session to completion: load the model, then synthesize
308
+ * each queued segment in arrival order one `audio-chunk` per segment,
309
+ * followed by a single `stream-done`. Chunk sends are drained before the next
310
+ * segment's inference (see the comment at the send site). Serialized through
311
+ * {@link synthesizeQueue} so it never interleaves model access with a batch
312
+ * synthesize/download.
324
313
  */
325
314
  async function runStreamSession(transport: TtsTransport, id: string, session: StreamSession): Promise<void> {
326
315
  try {
327
- if (session.cancelled) return;
328
- const runtime = await loadKokoroRuntime(transport, id, session.modelKey);
329
316
  if (session.cancelled) return;
330
317
  const synthesizer = await loadModel(session.modelKey, transport, id);
331
318
  if (session.cancelled) return;
332
319
  const spec = getTtsLocalModelSpec(session.modelKey);
333
- const splitter = new runtime.TextSplitterStream();
334
- // Flush buffered text before exposing the splitter so a push racing this
335
- // block can't slip ahead of the already-queued fragments.
336
- for (const text of session.buffered) {
337
- if (session.cancelled) return;
338
- splitter.push(text);
339
- }
340
- session.buffered = [];
341
- session.splitter = splitter;
342
- if (session.ended || session.cancelled) splitter.close();
343
320
  const voice = resolveTtsVoice(session.modelKey, session.voice);
344
321
  let index = 0;
345
- for await (const chunk of synthesizer.stream(splitter, { voice })) {
322
+ while (!session.cancelled) {
323
+ const segment = session.queue.shift();
324
+ if (segment === undefined) {
325
+ if (session.ended) break;
326
+ const { promise, resolve } = Promise.withResolvers<void>();
327
+ session.wake = resolve;
328
+ // Re-check after arming: a push/end/cancel racing the empty shift.
329
+ if (session.queue.length > 0 || session.ended || session.cancelled) {
330
+ session.wake = null;
331
+ resolve();
332
+ }
333
+ await promise;
334
+ continue;
335
+ }
336
+ const output = await synthesizer.generate(segment, { voice });
346
337
  if (session.cancelled) break;
347
- const audio = Array.isArray(chunk.audio.audio) ? chunk.audio.audio[0] : chunk.audio.audio;
338
+ const audio = Array.isArray(output.audio) ? output.audio[0] : output.audio;
348
339
  if (!audio) continue;
349
- transport.send({
340
+ // Drain the IPC write before the next segment's inference: ONNX
341
+ // blocks this event loop for seconds at a time, so a fire-and-forget
342
+ // send would sit in the pipe queue until the session ends and every
343
+ // chunk would arrive in one burst (long silence, then all segments
344
+ // at once) instead of streaming per-segment.
345
+ await transport.sendAndFlush({
350
346
  type: "audio-chunk",
351
347
  id,
352
348
  index: index++,
353
- text: chunk.text,
349
+ text: segment,
354
350
  pcm: audio,
355
- sampleRate: chunk.audio.sampling_rate || spec?.sampleRate || 24_000,
351
+ sampleRate: output.sampling_rate || spec?.sampleRate || 24_000,
356
352
  });
357
353
  }
358
354
  if (!session.cancelled) transport.send({ type: "stream-done", id });
@@ -370,8 +366,8 @@ function startStreamSession(
370
366
  const session: StreamSession = {
371
367
  modelKey: message.modelKey,
372
368
  voice: message.voice,
373
- buffered: [],
374
- splitter: null,
369
+ queue: [],
370
+ wake: null,
375
371
  ended: false,
376
372
  cancelled: false,
377
373
  };
@@ -382,26 +378,33 @@ function startStreamSession(
382
378
  );
383
379
  }
384
380
 
381
+ /** Wake the session's run loop if it is parked on an empty queue. */
382
+ function wakeStreamSession(session: StreamSession): void {
383
+ const wake = session.wake;
384
+ session.wake = null;
385
+ wake?.();
386
+ }
387
+
385
388
  function pushToStreamSession(id: string, text: string): void {
386
389
  const session = streamSessions.get(id);
387
390
  if (!session || session.cancelled) return;
388
- if (session.splitter) session.splitter.push(text);
389
- else session.buffered.push(text);
391
+ session.queue.push(text);
392
+ wakeStreamSession(session);
390
393
  }
391
394
 
392
395
  function endStreamSession(id: string): void {
393
396
  const session = streamSessions.get(id);
394
397
  if (!session || session.cancelled) return;
395
398
  session.ended = true;
396
- session.splitter?.close();
399
+ wakeStreamSession(session);
397
400
  }
398
401
 
399
402
  function cancelStreamSession(id: string): void {
400
403
  const session = streamSessions.get(id);
401
404
  if (!session) return;
402
405
  session.cancelled = true;
403
- session.buffered = [];
404
- session.splitter?.close();
406
+ session.queue.length = 0;
407
+ wakeStreamSession(session);
405
408
  streamSessions.delete(id);
406
409
  }
407
410