@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.
- package/CHANGELOG.md +91 -0
- package/dist/cli.js +3855 -3798
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/irc/bus.d.ts +5 -4
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +13 -13
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/task/discovery.d.ts +5 -2
- package/dist/types/task/renderer.d.ts +1 -0
- package/dist/types/tools/ast-grep.d.ts +4 -2
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +1 -0
- package/dist/types/tools/eval-render.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -2
- package/dist/types/tools/grep.d.ts +4 -3
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/renderers.d.ts +24 -0
- package/dist/types/tools/ssh.d.ts +4 -1
- package/dist/types/tts/index.d.ts +2 -0
- package/dist/types/tts/speakable.d.ts +47 -0
- package/dist/types/tts/speech-enhancer.d.ts +46 -0
- package/dist/types/tts/streaming-player.d.ts +1 -2
- package/dist/types/tts/tts-client.d.ts +11 -10
- package/dist/types/tts/tts-protocol.d.ts +7 -0
- package/dist/types/tts/vocalizer.d.ts +15 -8
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/utils/git.d.ts +2 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/cli/gallery-fixtures/fs.ts +2 -2
- package/src/cli/gallery-fixtures/search.ts +2 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/cli.ts +27 -5
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/model-resolver.ts +31 -10
- package/src/config/settings-schema.ts +21 -0
- package/src/cursor.ts +1 -1
- package/src/dap/client.ts +134 -36
- package/src/discovery/helpers.ts +8 -0
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +34 -34
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/extensions/loader.ts +10 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
- package/src/extensibility/plugins/loader.ts +30 -1
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/irc/bus.ts +5 -4
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/assistant-message.ts +1 -1
- package/src/modes/components/move-overlay.ts +35 -23
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +119 -20
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/components/tree-selector.ts +10 -3
- package/src/modes/controllers/event-controller.ts +23 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/prompts/goals/goal-mode-context.md +4 -0
- package/src/prompts/goals/goal-todo-context.md +10 -2
- package/src/prompts/system/speech-rewrite.md +15 -0
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-system-marker.md +2 -1
- package/src/prompts/system/title-system.md +2 -1
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/glob.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +329 -79
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-history-format.ts +6 -2
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/task/discovery.ts +25 -2
- package/src/task/executor.ts +24 -4
- package/src/task/render.ts +26 -12
- package/src/task/renderer.ts +1 -0
- package/src/task/worktree.ts +144 -16
- package/src/tiny/text.ts +109 -17
- package/src/tools/ast-grep.ts +20 -17
- package/src/tools/bash.ts +46 -9
- package/src/tools/browser/render.ts +2 -0
- package/src/tools/debug.ts +1 -0
- package/src/tools/eval-render.ts +5 -2
- package/src/tools/gh-renderer.ts +3 -0
- package/src/tools/glob.ts +24 -20
- package/src/tools/grep.ts +39 -37
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +23 -6
- package/src/tools/renderers.ts +24 -0
- package/src/tools/ssh.ts +25 -7
- package/src/tts/index.ts +2 -0
- package/src/tts/speakable.ts +382 -0
- package/src/tts/speech-enhancer.ts +204 -0
- package/src/tts/streaming-player.ts +71 -16
- package/src/tts/tts-client.ts +11 -10
- package/src/tts/tts-protocol.ts +14 -5
- package/src/tts/tts-worker.ts +52 -49
- package/src/tts/vocalizer.ts +277 -46
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/utils/git.ts +8 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- 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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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:
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/tts/tts-client.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface TtsStreamOptions {
|
|
|
42
42
|
signal?: AbortSignal;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** One synthesized
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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.
|
|
243
|
-
* returned handle's `push`/`end`; audio is emitted one
|
|
244
|
-
* a time via `chunks`, so playback can begin before the full text is
|
|
245
|
-
* Returns an inert handle (immediately-ended `chunks`) for unknown
|
|
246
|
-
* an already-aborted signal, and fails the iterator if the worker
|
|
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) {
|
package/src/tts/tts-protocol.ts
CHANGED
|
@@ -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
|
|
28
|
-
// with `stream-push
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
|
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
|
}
|
package/src/tts/tts-worker.ts
CHANGED
|
@@ -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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
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,
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* access with a batch
|
|
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
|
-
|
|
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(
|
|
338
|
+
const audio = Array.isArray(output.audio) ? output.audio[0] : output.audio;
|
|
348
339
|
if (!audio) continue;
|
|
349
|
-
|
|
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:
|
|
349
|
+
text: segment,
|
|
354
350
|
pcm: audio,
|
|
355
|
-
sampleRate:
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
|
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.
|
|
404
|
-
session
|
|
406
|
+
session.queue.length = 0;
|
|
407
|
+
wakeStreamSession(session);
|
|
405
408
|
streamSessions.delete(id);
|
|
406
409
|
}
|
|
407
410
|
|