@pellux/goodvibes-tui 1.9.1 → 1.10.0
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 +36 -5
- package/README.md +1 -1
- package/docs/foundation-artifacts/knowledge-graphql.graphql +5 -0
- package/docs/foundation-artifacts/knowledge-store.sql +33 -0
- package/docs/foundation-artifacts/operator-contract.json +4026 -1376
- package/package.json +2 -2
- package/src/audio/player.ts +91 -5
- package/src/audio/spoken-turn-wiring.ts +10 -2
- package/src/cli/management-utils.ts +4 -0
- package/src/config/credential-availability.ts +1 -1
- package/src/config/index.ts +1 -1
- package/src/core/turn-cancellation.ts +1 -1
- package/src/daemon/cli.ts +4 -4
- package/src/daemon/service-commands.ts +8 -8
- package/src/export/cost-utils.ts +9 -1
- package/src/input/command-registry.ts +16 -5
- package/src/input/commands/config.ts +1 -1
- package/src/input/commands/control-room-runtime.ts +12 -5
- package/src/input/commands/incident-runtime.ts +2 -2
- package/src/input/commands/memory.ts +12 -12
- package/src/input/commands/planning-runtime.ts +1 -1
- package/src/input/commands/recall-bundle.ts +8 -8
- package/src/input/commands/recall-capture.ts +3 -3
- package/src/input/commands/recall-query.ts +41 -15
- package/src/input/commands/recall-review.ts +12 -12
- package/src/input/commands/session-content.ts +1 -1
- package/src/input/feed-context-factory.ts +1 -1
- package/src/input/handler-content-actions.ts +2 -2
- package/src/input/handler-feed-routes.ts +2 -2
- package/src/input/handler-feed.ts +1 -1
- package/src/input/handler-onboarding-daemon-adopt.ts +3 -3
- package/src/input/handler-picker-routes.ts +2 -2
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/model-picker.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-apply.ts +3 -3
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +3 -3
- package/src/input/onboarding/onboarding-wizard-steps.ts +1 -1
- package/src/input/session-picker-modal.ts +1 -1
- package/src/input/settings-modal-data.ts +56 -2
- package/src/main.ts +8 -6
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/builtin/operations.ts +4 -4
- package/src/panels/builtin/shared.ts +4 -4
- package/src/panels/cost-tracker-panel.ts +2 -2
- package/src/panels/fleet-panel-format.ts +1 -1
- package/src/panels/fleet-panel.ts +15 -15
- package/src/panels/fleet-read-model.ts +11 -11
- package/src/panels/fleet-steer.ts +2 -2
- package/src/panels/fleet-stop.ts +2 -2
- package/src/panels/fleet-tabs.ts +4 -4
- package/src/panels/fleet-transcript.ts +5 -5
- package/src/panels/modals/memory-modal.ts +77 -16
- package/src/panels/types.ts +1 -1
- package/src/renderer/compaction-quality.ts +1 -1
- package/src/renderer/fleet-tab-strip.ts +2 -2
- package/src/renderer/footer-tips.ts +1 -1
- package/src/renderer/model-workspace.ts +1 -1
- package/src/renderer/session-picker-modal.ts +4 -4
- package/src/renderer/shell-surface.ts +1 -1
- package/src/renderer/status-glyphs.ts +3 -3
- package/src/renderer/terminal-bg-probe.ts +1 -1
- package/src/renderer/theme.ts +2 -2
- package/src/renderer/turn-injection.ts +3 -3
- package/src/renderer/ui-factory.ts +7 -7
- package/src/renderer/ui-primitives.ts +3 -3
- package/src/runtime/bootstrap-command-context.ts +4 -0
- package/src/runtime/bootstrap-command-parts.ts +4 -1
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +2 -2
- package/src/runtime/bootstrap-shell.ts +18 -7
- package/src/runtime/bootstrap.ts +22 -22
- package/src/runtime/code-index-services.ts +2 -2
- package/src/runtime/index.ts +11 -3
- package/src/runtime/legacy-daemon-migration.ts +9 -9
- package/src/runtime/memory-fold.ts +1 -1
- package/src/runtime/memory-spine-transport.ts +289 -0
- package/src/runtime/onboarding/snapshot.ts +3 -3
- package/src/runtime/onboarding/types.ts +2 -2
- package/src/runtime/operator-token-cleanup.ts +2 -2
- package/src/runtime/orchestrator-core-services.ts +29 -4
- package/src/runtime/process-lifecycle.ts +19 -5
- package/src/runtime/services.ts +27 -28
- package/src/runtime/session-inbound-inputs.ts +1 -1
- package/src/runtime/session-spine-transport.ts +2 -2
- package/src/runtime/ui-services.ts +1 -1
- package/src/runtime/workstream-services.ts +1 -1
- package/src/runtime/wrfc-persistence.ts +2 -2
- package/src/shell/ui-openers.ts +2 -2
- package/src/version.ts +1 -1
- package/src/audio/spoken-turn-controller.ts +0 -243
- package/src/audio/text-chunker.ts +0 -110
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// workstream-services.ts —
|
|
2
|
+
// workstream-services.ts — phase/work-item orchestration engine
|
|
3
3
|
//
|
|
4
4
|
// Constructs the TUI's OrchestrationEngine instance (@pellux/goodvibes-sdk/
|
|
5
5
|
// platform/orchestration, landed on SDK main as wo701/W4.1) and a thin
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* additionally exposes the recovered set for inspection.
|
|
20
20
|
*
|
|
21
21
|
* A chain that looked interrupted (non-terminal) in the old snapshot can be
|
|
22
|
-
* reaped to terminal IN PLACE by `WrfcController.importChain` (
|
|
22
|
+
* reaped to terminal IN PLACE by `WrfcController.importChain` (the zombie-reap pass:
|
|
23
23
|
* no member agent survived the restart — see wrfc-controller.ts). This
|
|
24
24
|
* module always re-checks `chain.state` AFTER the import call, never the
|
|
25
25
|
* pre-import classification, so a reaped chain is treated as history, not
|
|
@@ -209,7 +209,7 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
209
209
|
// chain. The accessor is optional for read-only test doubles.
|
|
210
210
|
//
|
|
211
211
|
// NOTE: importChain may reap this chain to a terminal state IN PLACE
|
|
212
|
-
// (
|
|
212
|
+
// (zombie reap: no member agent survived the restart) —
|
|
213
213
|
// always read chain.state AFTER this call below, never the pre-import
|
|
214
214
|
// classification captured by candidateInterrupted.
|
|
215
215
|
this.controller.importChain?.(chain);
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -231,7 +231,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
231
231
|
|
|
232
232
|
commandContext.openModelPicker = () => {
|
|
233
233
|
void (async () => {
|
|
234
|
-
//
|
|
234
|
+
// getSelectableModels() is catalog-driven and can list models whose
|
|
235
235
|
// `provider` id (e.g. 'google', sourced from the pricing catalog) was never
|
|
236
236
|
// handed to providerRegistry.register()/registerRuntimeProvider(). Selecting
|
|
237
237
|
// such a model fails hard at turn time with ProviderNotFoundError
|
|
@@ -264,7 +264,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
264
264
|
|
|
265
265
|
commandContext.openProviderPicker = () => {
|
|
266
266
|
void (async () => {
|
|
267
|
-
//
|
|
267
|
+
// listModels() surfaces every catalog provider id, not just the ones
|
|
268
268
|
// actually registered on this runtime (see openModelPicker above for the
|
|
269
269
|
// same class of bug). Intersect against providerRegistry.has() so the
|
|
270
270
|
// provider picker never offers a provider that will fail with
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '1.
|
|
9
|
+
let _version = '1.10.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
|
-
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
|
-
import type { TurnEvent } from '@/runtime/index.ts';
|
|
4
|
-
import type { VoiceService, VoiceSynthesisStreamResult } from '@pellux/goodvibes-sdk/platform/voice';
|
|
5
|
-
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
6
|
-
import { TtsTextChunker } from './text-chunker.ts';
|
|
7
|
-
import type { StreamingAudioPlayer } from './player.ts';
|
|
8
|
-
|
|
9
|
-
export interface SpokenTurnControllerOptions {
|
|
10
|
-
readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
11
|
-
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
12
|
-
readonly player: StreamingAudioPlayer;
|
|
13
|
-
readonly notify?: (message: string) => void;
|
|
14
|
-
readonly now?: () => number;
|
|
15
|
-
readonly setInterval?: typeof setInterval;
|
|
16
|
-
readonly clearInterval?: typeof clearInterval;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export class SpokenTurnController {
|
|
20
|
-
private pendingPrompt: string | null = null;
|
|
21
|
-
private activeTurnId: string | null = null;
|
|
22
|
-
private chunker: TtsTextChunker | null = null;
|
|
23
|
-
private chunkSequence = 0;
|
|
24
|
-
private playbackChain: Promise<void> = Promise.resolve();
|
|
25
|
-
private readonly abortControllers = new Set<AbortController>();
|
|
26
|
-
private timer: ReturnType<typeof setInterval> | null = null;
|
|
27
|
-
private errorReportedForTurn = false;
|
|
28
|
-
private noPlayerNoticed = false;
|
|
29
|
-
private readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
30
|
-
private readonly configManager: Pick<ConfigManager, 'get'>;
|
|
31
|
-
private readonly player: StreamingAudioPlayer;
|
|
32
|
-
private readonly notify?: (message: string) => void;
|
|
33
|
-
private readonly now: () => number;
|
|
34
|
-
private readonly setIntervalImpl: typeof setInterval;
|
|
35
|
-
private readonly clearIntervalImpl: typeof clearInterval;
|
|
36
|
-
|
|
37
|
-
constructor(options: SpokenTurnControllerOptions) {
|
|
38
|
-
this.voiceService = options.voiceService;
|
|
39
|
-
this.configManager = options.configManager;
|
|
40
|
-
this.player = options.player;
|
|
41
|
-
this.notify = options.notify;
|
|
42
|
-
this.now = options.now ?? (() => Date.now());
|
|
43
|
-
this.setIntervalImpl = options.setInterval ?? setInterval;
|
|
44
|
-
this.clearIntervalImpl = options.clearInterval ?? clearInterval;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
submitNextTurn(prompt: string): boolean {
|
|
48
|
-
const normalized = prompt.trim();
|
|
49
|
-
if (!normalized) return false;
|
|
50
|
-
this.stop();
|
|
51
|
-
if (!this.player.available) {
|
|
52
|
-
if (!this.noPlayerNoticed) {
|
|
53
|
-
this.noPlayerNoticed = true;
|
|
54
|
-
this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
// Reset the no-player notice if player becomes available again.
|
|
59
|
-
this.noPlayerNoticed = false;
|
|
60
|
-
this.pendingPrompt = normalized;
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Returns whether speech was actually ACTIVE when stopped. The notice only
|
|
66
|
-
* prints in that case — stop() on an idle controller used to notify anyway,
|
|
67
|
-
* spamming "[TTS] Spoken output stopped." on every Ctrl+C (batch replay
|
|
68
|
-
* D5/N-noise); callers use the return to decide whether the press "did a
|
|
69
|
-
* job" (see handleCtrlC's consume-on-speech-stop).
|
|
70
|
-
*/
|
|
71
|
-
stop(message?: string): boolean {
|
|
72
|
-
const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
|
|
73
|
-
|| this.chunker !== null || this.abortControllers.size > 0;
|
|
74
|
-
this.pendingPrompt = null;
|
|
75
|
-
this.activeTurnId = null;
|
|
76
|
-
this.chunker?.reset();
|
|
77
|
-
this.chunker = null;
|
|
78
|
-
this.stopTimer();
|
|
79
|
-
for (const controller of this.abortControllers) controller.abort();
|
|
80
|
-
this.abortControllers.clear();
|
|
81
|
-
this.player.stop();
|
|
82
|
-
this.playbackChain = Promise.resolve();
|
|
83
|
-
this.errorReportedForTurn = false;
|
|
84
|
-
if (message && wasActive) this.notify?.(`[TTS] ${message}`);
|
|
85
|
-
return wasActive;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
handleTurnEvent(event: TurnEvent): void {
|
|
89
|
-
if (event.type === 'TURN_SUBMITTED') {
|
|
90
|
-
this.maybeStartTurn(event.turnId, event.prompt);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
|
|
94
|
-
|
|
95
|
-
if (event.type === 'STREAM_DELTA') {
|
|
96
|
-
this.enqueueChunks(this.chunker?.push(event.content) ?? []);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (event.type === 'STREAM_END') {
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (event.type === 'TURN_COMPLETED') {
|
|
103
|
-
this.finishTurn(event.turnId);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (event.type === 'TURN_CANCEL' || event.type === 'TURN_ERROR' || event.type === 'PREFLIGHT_FAIL') {
|
|
107
|
-
this.stop(event.type === 'TURN_CANCEL' ? 'Spoken output stopped.' : 'Spoken output stopped because the turn did not complete.');
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
private maybeStartTurn(turnId: string, prompt: string): void {
|
|
112
|
-
if (!this.pendingPrompt) return;
|
|
113
|
-
if (prompt.trim() !== this.pendingPrompt) return;
|
|
114
|
-
this.pendingPrompt = null;
|
|
115
|
-
this.activeTurnId = turnId;
|
|
116
|
-
this.chunkSequence = 0;
|
|
117
|
-
this.errorReportedForTurn = false;
|
|
118
|
-
this.chunker = new TtsTextChunker({ now: this.now });
|
|
119
|
-
this.playbackChain = Promise.resolve();
|
|
120
|
-
this.startTimer();
|
|
121
|
-
this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private finishTurn(turnId: string): void {
|
|
125
|
-
if (turnId !== this.activeTurnId) return;
|
|
126
|
-
this.enqueueChunks(this.chunker?.flushAll() ?? []);
|
|
127
|
-
this.stopTimer();
|
|
128
|
-
const chain = this.playbackChain;
|
|
129
|
-
chain.finally(() => {
|
|
130
|
-
if (this.activeTurnId !== turnId) return;
|
|
131
|
-
this.activeTurnId = null;
|
|
132
|
-
this.chunker = null;
|
|
133
|
-
this.abortControllers.clear();
|
|
134
|
-
}).catch(() => {
|
|
135
|
-
// Errors are already reported in the queued task.
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
private startTimer(): void {
|
|
140
|
-
this.stopTimer();
|
|
141
|
-
this.timer = this.setIntervalImpl(() => {
|
|
142
|
-
if (!this.activeTurnId || !this.chunker) return;
|
|
143
|
-
this.enqueueChunks(this.chunker.flushDue());
|
|
144
|
-
}, 250);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private stopTimer(): void {
|
|
148
|
-
if (!this.timer) return;
|
|
149
|
-
this.clearIntervalImpl(this.timer);
|
|
150
|
-
this.timer = null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
private enqueueChunks(chunks: readonly string[]): void {
|
|
154
|
-
for (const chunk of chunks) {
|
|
155
|
-
this.enqueueChunk(chunk);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private enqueueChunk(text: string): void {
|
|
160
|
-
const turnId = this.activeTurnId;
|
|
161
|
-
if (!turnId || !text.trim()) return;
|
|
162
|
-
const sequence = ++this.chunkSequence;
|
|
163
|
-
const abortController = new AbortController();
|
|
164
|
-
this.abortControllers.add(abortController);
|
|
165
|
-
const resultPromise = this.synthesize(text, turnId, sequence, abortController.signal)
|
|
166
|
-
.then((result) => ({ ok: true as const, result }))
|
|
167
|
-
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
168
|
-
|
|
169
|
-
this.playbackChain = this.playbackChain.then(async () => {
|
|
170
|
-
if (abortController.signal.aborted) return;
|
|
171
|
-
const result = await resultPromise;
|
|
172
|
-
this.abortControllers.delete(abortController);
|
|
173
|
-
if (!result.ok) {
|
|
174
|
-
this.reportError(result.error);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
await this.player.play(result.result.chunks, {
|
|
178
|
-
format: String(result.result.format ?? 'mp3'),
|
|
179
|
-
signal: abortController.signal,
|
|
180
|
-
});
|
|
181
|
-
}).catch((error: unknown) => {
|
|
182
|
-
this.abortControllers.delete(abortController);
|
|
183
|
-
this.reportError(error);
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
|
|
188
|
-
// tts.speed: VoiceSynthesisRequest accepts speed (number | undefined).
|
|
189
|
-
// No ConfigKey for tts.speed exists in the current SDK schema — pending
|
|
190
|
-
// SDK schema addition. Speed is not threaded from config until that key
|
|
191
|
-
// is added. See docs/voice-and-live-tts.md § Speed.
|
|
192
|
-
return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
|
|
193
|
-
text,
|
|
194
|
-
voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
|
|
195
|
-
format: 'mp3',
|
|
196
|
-
speed: readOptionalConfigNumber(this.configManager, 'tts.speed'),
|
|
197
|
-
signal,
|
|
198
|
-
metadata: {
|
|
199
|
-
source: 'goodvibes-tui',
|
|
200
|
-
feature: 'live-tts',
|
|
201
|
-
turnId,
|
|
202
|
-
sequence,
|
|
203
|
-
},
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
private reportError(error: unknown): void {
|
|
208
|
-
if (this.errorReportedForTurn) return;
|
|
209
|
-
this.errorReportedForTurn = true;
|
|
210
|
-
this.activeTurnId = null;
|
|
211
|
-
this.chunker = null;
|
|
212
|
-
this.stopTimer();
|
|
213
|
-
for (const controller of this.abortControllers) controller.abort();
|
|
214
|
-
this.abortControllers.clear();
|
|
215
|
-
this.player.stop();
|
|
216
|
-
this.playbackChain = Promise.resolve();
|
|
217
|
-
this.notify?.(`[TTS] Live playback stopped: ${summarizeError(error)}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
|
|
222
|
-
const value = String(configManager.get(key) ?? '').trim();
|
|
223
|
-
return value || undefined;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* readOptionalConfigNumber — reads a numeric config value by key.
|
|
228
|
-
*
|
|
229
|
-
* `tts.speed` is not yet a ConfigKey in the SDK schema. This helper accepts
|
|
230
|
-
* a string key and casts it, returning undefined when the value is absent,
|
|
231
|
-
* zero, or not a finite positive number. Once `tts.speed` is added to the
|
|
232
|
-
* SDK schema the cast can be removed and the key typed statically.
|
|
233
|
-
*
|
|
234
|
-
* SDK handoff note: add { key: 'tts.speed', type: 'number', default: 1,
|
|
235
|
-
* description: '...' } to schema-domain-core.js and `tts: { ..., speed: 1 }`
|
|
236
|
-
* to DEFAULT_CONFIG.tts to complete this feature.
|
|
237
|
-
*/
|
|
238
|
-
function readOptionalConfigNumber(configManager: Pick<ConfigManager, 'get'>, key: string): number | undefined {
|
|
239
|
-
// Cast required: key is not yet a valid ConfigKey in the SDK schema.
|
|
240
|
-
const raw = configManager.get(key as ConfigKey);
|
|
241
|
-
const value = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
|
|
242
|
-
return isFinite(value) && value > 0 ? value : undefined;
|
|
243
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
export interface TtsTextChunkerOptions {
|
|
2
|
-
readonly minBoundaryChars?: number;
|
|
3
|
-
readonly maxChunkChars?: number;
|
|
4
|
-
readonly maxLatencyMs?: number;
|
|
5
|
-
readonly now?: () => number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export class TtsTextChunker {
|
|
9
|
-
private buffer = '';
|
|
10
|
-
private firstBufferedAt: number | null = null;
|
|
11
|
-
private readonly minBoundaryChars: number;
|
|
12
|
-
private readonly maxChunkChars: number;
|
|
13
|
-
private readonly maxLatencyMs: number;
|
|
14
|
-
private readonly now: () => number;
|
|
15
|
-
|
|
16
|
-
constructor(options: TtsTextChunkerOptions = {}) {
|
|
17
|
-
this.minBoundaryChars = options.minBoundaryChars ?? 24;
|
|
18
|
-
this.maxChunkChars = options.maxChunkChars ?? 320;
|
|
19
|
-
this.maxLatencyMs = options.maxLatencyMs ?? 1_000;
|
|
20
|
-
this.now = options.now ?? (() => Date.now());
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
push(delta: string): string[] {
|
|
24
|
-
if (!delta) return [];
|
|
25
|
-
if (this.firstBufferedAt === null) this.firstBufferedAt = this.now();
|
|
26
|
-
this.buffer += delta;
|
|
27
|
-
return this.drainReady(false);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
flushDue(): string[] {
|
|
31
|
-
if (!this.buffer.trim() || this.firstBufferedAt === null) return [];
|
|
32
|
-
if (this.now() - this.firstBufferedAt < this.maxLatencyMs) return [];
|
|
33
|
-
return this.drainReady(true);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
flushAll(): string[] {
|
|
37
|
-
if (!this.buffer.trim()) {
|
|
38
|
-
this.buffer = '';
|
|
39
|
-
this.firstBufferedAt = null;
|
|
40
|
-
return [];
|
|
41
|
-
}
|
|
42
|
-
return [this.takeChunk(this.buffer.length)].filter(Boolean);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
reset(): void {
|
|
46
|
-
this.buffer = '';
|
|
47
|
-
this.firstBufferedAt = null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
private drainReady(forceLatencyFlush: boolean): string[] {
|
|
51
|
-
const chunks: string[] = [];
|
|
52
|
-
while (this.buffer.trim()) {
|
|
53
|
-
const boundary = this.findBoundary(forceLatencyFlush);
|
|
54
|
-
if (boundary <= 0) break;
|
|
55
|
-
const chunk = this.takeChunk(boundary);
|
|
56
|
-
if (chunk) chunks.push(chunk);
|
|
57
|
-
forceLatencyFlush = false;
|
|
58
|
-
}
|
|
59
|
-
return chunks;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
private findBoundary(forceLatencyFlush: boolean): number {
|
|
63
|
-
const latestSentence = this.findLatestSentenceBoundary();
|
|
64
|
-
if (latestSentence >= this.minBoundaryChars) return latestSentence;
|
|
65
|
-
|
|
66
|
-
if (this.buffer.length >= this.maxChunkChars) {
|
|
67
|
-
return this.findWordBoundaryBefore(this.maxChunkChars) || this.maxChunkChars;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (forceLatencyFlush) {
|
|
71
|
-
return this.buffer.length;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return -1;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
private findLatestSentenceBoundary(): number {
|
|
78
|
-
let best = -1;
|
|
79
|
-
for (let i = 0; i < this.buffer.length; i++) {
|
|
80
|
-
const char = this.buffer[i];
|
|
81
|
-
if (char !== '.' && char !== '!' && char !== '?' && char !== ';' && char !== ':' && char !== '\n') {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
const next = this.buffer[i + 1];
|
|
85
|
-
if (i === this.buffer.length - 1 || next === undefined || /\s/.test(next)) {
|
|
86
|
-
best = i + 1;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return best;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private findWordBoundaryBefore(index: number): number {
|
|
93
|
-
const max = Math.min(index, this.buffer.length);
|
|
94
|
-
for (let i = max; i > 0; i--) {
|
|
95
|
-
if (/\s/.test(this.buffer[i - 1] ?? '')) return i;
|
|
96
|
-
}
|
|
97
|
-
return -1;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private takeChunk(end: number): string {
|
|
101
|
-
const raw = this.buffer.slice(0, end);
|
|
102
|
-
this.buffer = this.buffer.slice(end);
|
|
103
|
-
this.firstBufferedAt = this.buffer.trim() ? this.now() : null;
|
|
104
|
-
return normalizeSpeechText(raw);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function normalizeSpeechText(text: string): string {
|
|
109
|
-
return text.replace(/\s+/g, ' ').trim();
|
|
110
|
-
}
|