@pellux/goodvibes-tui 0.19.31 → 0.19.33
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 +30 -0
- package/README.md +4 -2
- package/docs/foundation-artifacts/operator-contract.json +778 -494
- package/package.json +2 -2
- package/src/audio/player.ts +156 -0
- package/src/audio/spoken-turn-controller.ts +200 -0
- package/src/audio/spoken-turn-model-routing.ts +117 -0
- package/src/audio/spoken-turn-wiring.ts +44 -0
- package/src/audio/text-chunker.ts +110 -0
- package/src/cli/management.ts +26 -3
- package/src/cli/provider-auth-routes.ts +22 -0
- package/src/input/command-registry.ts +6 -0
- package/src/input/commands/tts-runtime.ts +334 -0
- package/src/input/commands.ts +2 -0
- package/src/input/handler-onboarding.ts +12 -0
- package/src/input/model-picker.ts +2 -1
- package/src/input/onboarding/onboarding-wizard-steps.ts +25 -2
- package/src/input/onboarding/onboarding-wizard-types.ts +2 -0
- package/src/main.ts +31 -30
- package/src/renderer/onboarding/onboarding-wizard.ts +38 -14
- package/src/renderer/ui-factory.ts +1 -1
- package/src/runtime/bootstrap-command-context.ts +6 -1
- package/src/runtime/bootstrap-command-parts.ts +10 -1
- package/src/runtime/bootstrap-shell.ts +2 -0
- package/src/shell/ui-openers.ts +11 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.33",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
92
92
|
"@ast-grep/napi": "^0.42.0",
|
|
93
93
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
94
|
-
"@pellux/goodvibes-sdk": "
|
|
94
|
+
"@pellux/goodvibes-sdk": "0.25.8",
|
|
95
95
|
"bash-language-server": "^5.6.0",
|
|
96
96
|
"fuse.js": "^7.1.0",
|
|
97
97
|
"graphql": "^16.13.2",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import type { Writable } from 'node:stream';
|
|
5
|
+
import type { VoiceAudioChunk } from '@pellux/goodvibes-sdk/platform/voice/index';
|
|
6
|
+
|
|
7
|
+
export interface StreamingAudioPlayerCommand {
|
|
8
|
+
readonly command: string;
|
|
9
|
+
readonly args: readonly string[];
|
|
10
|
+
readonly label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StreamingAudioPlayer {
|
|
14
|
+
readonly label: string;
|
|
15
|
+
readonly available: boolean;
|
|
16
|
+
play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions): Promise<void>;
|
|
17
|
+
stop(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StreamingAudioPlaybackOptions {
|
|
21
|
+
readonly format?: string;
|
|
22
|
+
readonly signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SpawnProcess {
|
|
26
|
+
readonly stdin: Writable;
|
|
27
|
+
once(event: 'close', listener: () => void): this;
|
|
28
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
29
|
+
}
|
|
30
|
+
type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnProcess;
|
|
31
|
+
|
|
32
|
+
export interface LocalStreamingAudioPlayerOptions {
|
|
33
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
34
|
+
readonly spawnProcess?: SpawnProcessFactory;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
|
|
38
|
+
readonly command: StreamingAudioPlayerCommand | null;
|
|
39
|
+
private activeProcess: SpawnProcess | null = null;
|
|
40
|
+
private readonly spawnProcess: SpawnProcessFactory;
|
|
41
|
+
|
|
42
|
+
constructor(options: LocalStreamingAudioPlayerOptions = {}) {
|
|
43
|
+
this.command = resolveStreamingAudioPlayerCommand(options.env ?? process.env);
|
|
44
|
+
this.spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get label(): string {
|
|
48
|
+
return this.command?.label ?? 'no streaming audio player found';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get available(): boolean {
|
|
52
|
+
return this.command !== null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions = {}): Promise<void> {
|
|
56
|
+
if (!this.command) {
|
|
57
|
+
throw new Error('No streaming audio player found. Install mpv or ffplay to use /tts live playback.');
|
|
58
|
+
}
|
|
59
|
+
if (options.signal?.aborted) return;
|
|
60
|
+
|
|
61
|
+
const proc = this.spawnProcess(this.command.command, buildPlayerArgs(this.command, options.format));
|
|
62
|
+
this.activeProcess = proc;
|
|
63
|
+
const abort = () => {
|
|
64
|
+
try { proc.stdin.destroy(); } catch { /* ignore */ }
|
|
65
|
+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
66
|
+
};
|
|
67
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
for await (const chunk of chunks) {
|
|
71
|
+
if (options.signal?.aborted) break;
|
|
72
|
+
if (chunk.data.byteLength === 0) continue;
|
|
73
|
+
await writeStdin(proc, chunk.data);
|
|
74
|
+
}
|
|
75
|
+
proc.stdin.end();
|
|
76
|
+
await waitForExit(proc);
|
|
77
|
+
} finally {
|
|
78
|
+
options.signal?.removeEventListener('abort', abort);
|
|
79
|
+
if (this.activeProcess === proc) this.activeProcess = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
stop(): void {
|
|
84
|
+
const proc = this.activeProcess;
|
|
85
|
+
this.activeProcess = null;
|
|
86
|
+
if (!proc) return;
|
|
87
|
+
try { proc.stdin.destroy(); } catch { /* ignore */ }
|
|
88
|
+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = process.env): StreamingAudioPlayerCommand | null {
|
|
93
|
+
const mpv = findExecutable('mpv', env);
|
|
94
|
+
if (mpv) {
|
|
95
|
+
return {
|
|
96
|
+
command: mpv,
|
|
97
|
+
args: ['--no-terminal', '--really-quiet', '--force-window=no', '--cache=no', '-'],
|
|
98
|
+
label: 'mpv',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const ffplay = findExecutable('ffplay', env);
|
|
102
|
+
if (ffplay) {
|
|
103
|
+
return {
|
|
104
|
+
command: ffplay,
|
|
105
|
+
args: ['-nodisp', '-autoexit', '-loglevel', 'error', '-i', 'pipe:0'],
|
|
106
|
+
label: 'ffplay',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildPlayerArgs(command: StreamingAudioPlayerCommand, format?: string): readonly string[] {
|
|
113
|
+
if (command.label !== 'ffplay' || !format) return command.args;
|
|
114
|
+
const normalized = format.trim().toLowerCase();
|
|
115
|
+
if (!normalized || normalized.includes('/')) return command.args;
|
|
116
|
+
return ['-nodisp', '-autoexit', '-loglevel', 'error', '-f', normalized, '-i', 'pipe:0'];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findExecutable(name: string, env: NodeJS.ProcessEnv): string | null {
|
|
120
|
+
const pathValue = env.PATH ?? '';
|
|
121
|
+
const extensions = process.platform === 'win32'
|
|
122
|
+
? ['', '.exe', '.cmd', '.bat']
|
|
123
|
+
: [''];
|
|
124
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
125
|
+
if (!dir) continue;
|
|
126
|
+
for (const ext of extensions) {
|
|
127
|
+
const candidate = join(dir, `${name}${ext}`);
|
|
128
|
+
try {
|
|
129
|
+
accessSync(candidate, constants.X_OK);
|
|
130
|
+
return candidate;
|
|
131
|
+
} catch {
|
|
132
|
+
// Keep scanning PATH.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function defaultSpawnProcess(command: string, args: readonly string[]): SpawnProcess {
|
|
140
|
+
return spawn(command, [...args], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeStdin(proc: SpawnProcess, data: Uint8Array): Promise<void> {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
proc.stdin.write(Buffer.from(data), (error) => {
|
|
146
|
+
if (error) reject(error);
|
|
147
|
+
else resolve();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function waitForExit(proc: SpawnProcess): Promise<void> {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
proc.once('close', () => resolve());
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
|
|
2
|
+
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config/schema';
|
|
3
|
+
import type { TurnEvent } from '@pellux/goodvibes-sdk/platform/runtime/events/index';
|
|
4
|
+
import type { VoiceService, VoiceSynthesisStreamResult } from '@pellux/goodvibes-sdk/platform/voice/index';
|
|
5
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
|
|
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 readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
29
|
+
private readonly configManager: Pick<ConfigManager, 'get'>;
|
|
30
|
+
private readonly player: StreamingAudioPlayer;
|
|
31
|
+
private readonly notify?: (message: string) => void;
|
|
32
|
+
private readonly now: () => number;
|
|
33
|
+
private readonly setIntervalImpl: typeof setInterval;
|
|
34
|
+
private readonly clearIntervalImpl: typeof clearInterval;
|
|
35
|
+
|
|
36
|
+
constructor(options: SpokenTurnControllerOptions) {
|
|
37
|
+
this.voiceService = options.voiceService;
|
|
38
|
+
this.configManager = options.configManager;
|
|
39
|
+
this.player = options.player;
|
|
40
|
+
this.notify = options.notify;
|
|
41
|
+
this.now = options.now ?? (() => Date.now());
|
|
42
|
+
this.setIntervalImpl = options.setInterval ?? setInterval;
|
|
43
|
+
this.clearIntervalImpl = options.clearInterval ?? clearInterval;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
submitNextTurn(prompt: string): boolean {
|
|
47
|
+
const normalized = prompt.trim();
|
|
48
|
+
if (!normalized) return false;
|
|
49
|
+
this.stop();
|
|
50
|
+
if (!this.player.available) {
|
|
51
|
+
this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
this.pendingPrompt = normalized;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop(message?: string): void {
|
|
59
|
+
this.pendingPrompt = null;
|
|
60
|
+
this.activeTurnId = null;
|
|
61
|
+
this.chunker?.reset();
|
|
62
|
+
this.chunker = null;
|
|
63
|
+
this.stopTimer();
|
|
64
|
+
for (const controller of this.abortControllers) controller.abort();
|
|
65
|
+
this.abortControllers.clear();
|
|
66
|
+
this.player.stop();
|
|
67
|
+
this.playbackChain = Promise.resolve();
|
|
68
|
+
this.errorReportedForTurn = false;
|
|
69
|
+
if (message) this.notify?.(`[TTS] ${message}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handleTurnEvent(event: TurnEvent): void {
|
|
73
|
+
if (event.type === 'TURN_SUBMITTED') {
|
|
74
|
+
this.maybeStartTurn(event.turnId, event.prompt);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
|
|
78
|
+
|
|
79
|
+
if (event.type === 'STREAM_DELTA') {
|
|
80
|
+
this.enqueueChunks(this.chunker?.push(event.content) ?? []);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (event.type === 'STREAM_END' || event.type === 'TURN_COMPLETED') {
|
|
84
|
+
this.finishTurn(event.turnId);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (event.type === 'TURN_CANCEL' || event.type === 'TURN_ERROR' || event.type === 'PREFLIGHT_FAIL') {
|
|
88
|
+
this.stop(event.type === 'TURN_CANCEL' ? 'Spoken output stopped.' : 'Spoken output stopped because the turn did not complete.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private maybeStartTurn(turnId: string, prompt: string): void {
|
|
93
|
+
if (!this.pendingPrompt) return;
|
|
94
|
+
if (prompt.trim() !== this.pendingPrompt) return;
|
|
95
|
+
this.pendingPrompt = null;
|
|
96
|
+
this.activeTurnId = turnId;
|
|
97
|
+
this.chunkSequence = 0;
|
|
98
|
+
this.errorReportedForTurn = false;
|
|
99
|
+
this.chunker = new TtsTextChunker({ now: this.now });
|
|
100
|
+
this.playbackChain = Promise.resolve();
|
|
101
|
+
this.startTimer();
|
|
102
|
+
this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private finishTurn(turnId: string): void {
|
|
106
|
+
if (turnId !== this.activeTurnId) return;
|
|
107
|
+
this.enqueueChunks(this.chunker?.flushAll() ?? []);
|
|
108
|
+
this.stopTimer();
|
|
109
|
+
const chain = this.playbackChain;
|
|
110
|
+
chain.finally(() => {
|
|
111
|
+
if (this.activeTurnId !== turnId) return;
|
|
112
|
+
this.activeTurnId = null;
|
|
113
|
+
this.chunker = null;
|
|
114
|
+
this.abortControllers.clear();
|
|
115
|
+
}).catch(() => {
|
|
116
|
+
// Errors are already reported in the queued task.
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private startTimer(): void {
|
|
121
|
+
this.stopTimer();
|
|
122
|
+
this.timer = this.setIntervalImpl(() => {
|
|
123
|
+
if (!this.activeTurnId || !this.chunker) return;
|
|
124
|
+
this.enqueueChunks(this.chunker.flushDue());
|
|
125
|
+
}, 250);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private stopTimer(): void {
|
|
129
|
+
if (!this.timer) return;
|
|
130
|
+
this.clearIntervalImpl(this.timer);
|
|
131
|
+
this.timer = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private enqueueChunks(chunks: readonly string[]): void {
|
|
135
|
+
for (const chunk of chunks) {
|
|
136
|
+
this.enqueueChunk(chunk);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private enqueueChunk(text: string): void {
|
|
141
|
+
const turnId = this.activeTurnId;
|
|
142
|
+
if (!turnId || !text.trim()) return;
|
|
143
|
+
const sequence = ++this.chunkSequence;
|
|
144
|
+
const abortController = new AbortController();
|
|
145
|
+
this.abortControllers.add(abortController);
|
|
146
|
+
const resultPromise = this.synthesize(text, turnId, sequence, abortController.signal)
|
|
147
|
+
.then((result) => ({ ok: true as const, result }))
|
|
148
|
+
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
149
|
+
|
|
150
|
+
this.playbackChain = this.playbackChain.then(async () => {
|
|
151
|
+
if (abortController.signal.aborted) return;
|
|
152
|
+
const result = await resultPromise;
|
|
153
|
+
this.abortControllers.delete(abortController);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
this.reportError(result.error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await this.player.play(result.result.chunks, {
|
|
159
|
+
format: String(result.result.format ?? 'mp3'),
|
|
160
|
+
signal: abortController.signal,
|
|
161
|
+
});
|
|
162
|
+
}).catch((error: unknown) => {
|
|
163
|
+
this.abortControllers.delete(abortController);
|
|
164
|
+
this.reportError(error);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
|
|
169
|
+
return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
|
|
170
|
+
text,
|
|
171
|
+
voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
|
|
172
|
+
format: 'mp3',
|
|
173
|
+
signal,
|
|
174
|
+
metadata: {
|
|
175
|
+
source: 'goodvibes-tui',
|
|
176
|
+
feature: 'live-tts',
|
|
177
|
+
turnId,
|
|
178
|
+
sequence,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private reportError(error: unknown): void {
|
|
184
|
+
if (this.errorReportedForTurn) return;
|
|
185
|
+
this.errorReportedForTurn = true;
|
|
186
|
+
this.activeTurnId = null;
|
|
187
|
+
this.chunker = null;
|
|
188
|
+
this.stopTimer();
|
|
189
|
+
for (const controller of this.abortControllers) controller.abort();
|
|
190
|
+
this.abortControllers.clear();
|
|
191
|
+
this.player.stop();
|
|
192
|
+
this.playbackChain = Promise.resolve();
|
|
193
|
+
this.notify?.(`[TTS] Live playback stopped: ${summarizeError(error)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
|
|
198
|
+
const value = String(configManager.get(key) ?? '').trim();
|
|
199
|
+
return value || undefined;
|
|
200
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
|
|
2
|
+
import type { ModelDefinition, ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers/registry';
|
|
3
|
+
import type { ContentPart } from '@pellux/goodvibes-sdk/platform/providers/interface';
|
|
4
|
+
import type { Orchestrator, OrchestratorUserInputOptions } from '../core/orchestrator.ts';
|
|
5
|
+
|
|
6
|
+
const SPOKEN_TURN_SOURCE = 'tts';
|
|
7
|
+
|
|
8
|
+
type RunTurn = (
|
|
9
|
+
text: string,
|
|
10
|
+
content?: ContentPart[],
|
|
11
|
+
options?: OrchestratorUserInputOptions,
|
|
12
|
+
) => Promise<void>;
|
|
13
|
+
|
|
14
|
+
type PatchableOrchestrator = {
|
|
15
|
+
runTurn?: RunTurn;
|
|
16
|
+
setCoreServices: (services: { providerRegistry?: ProviderRegistry }) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface SpokenTurnModelRoutingOptions {
|
|
20
|
+
readonly orchestrator: Orchestrator;
|
|
21
|
+
readonly providerRegistry: ProviderRegistry;
|
|
22
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
23
|
+
readonly notify?: (message: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSpokenTurnInputOptions(): OrchestratorUserInputOptions {
|
|
27
|
+
return {
|
|
28
|
+
origin: {
|
|
29
|
+
source: SPOKEN_TURN_SOURCE,
|
|
30
|
+
surface: 'tui',
|
|
31
|
+
metadata: { spokenOutput: true },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function attachSpokenTurnModelRouting(options: SpokenTurnModelRoutingOptions): () => void {
|
|
37
|
+
const target = options.orchestrator as unknown as PatchableOrchestrator;
|
|
38
|
+
const originalRunTurn = target.runTurn?.bind(options.orchestrator);
|
|
39
|
+
if (!originalRunTurn) return () => {};
|
|
40
|
+
|
|
41
|
+
target.runTurn = async (text, content, inputOptions) => {
|
|
42
|
+
if (!isSpokenTurn(inputOptions)) {
|
|
43
|
+
await originalRunTurn(text, content, inputOptions);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const override = resolveSpokenTurnModelOverride({
|
|
48
|
+
providerRegistry: options.providerRegistry,
|
|
49
|
+
configManager: options.configManager,
|
|
50
|
+
notify: options.notify,
|
|
51
|
+
});
|
|
52
|
+
if (!override) {
|
|
53
|
+
await originalRunTurn(text, content, inputOptions);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const routedRegistry = createRoutedProviderRegistry(options.providerRegistry, override);
|
|
58
|
+
target.setCoreServices({ providerRegistry: routedRegistry });
|
|
59
|
+
try {
|
|
60
|
+
await originalRunTurn(text, content, inputOptions);
|
|
61
|
+
} finally {
|
|
62
|
+
target.setCoreServices({ providerRegistry: options.providerRegistry });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
target.runTurn = originalRunTurn;
|
|
68
|
+
target.setCoreServices({ providerRegistry: options.providerRegistry });
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveSpokenTurnModelOverride(options: {
|
|
73
|
+
readonly providerRegistry: Pick<ProviderRegistry, 'listModels' | 'getCurrentModel'>;
|
|
74
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
75
|
+
readonly notify?: (message: string) => void;
|
|
76
|
+
}): ModelDefinition | null {
|
|
77
|
+
const modelRef = readConfigString(options.configManager, 'tts.llmModel');
|
|
78
|
+
if (!modelRef) return null;
|
|
79
|
+
|
|
80
|
+
const providerId = readConfigString(options.configManager, 'tts.llmProvider');
|
|
81
|
+
const current = options.providerRegistry.getCurrentModel();
|
|
82
|
+
const model = options.providerRegistry.listModels().find((candidate) => {
|
|
83
|
+
const refMatches = candidate.registryKey === modelRef || candidate.id === modelRef;
|
|
84
|
+
const providerMatches = !providerId || candidate.provider === providerId;
|
|
85
|
+
return refMatches && providerMatches;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!model) {
|
|
89
|
+
options.notify?.(`[TTS] Configured TTS LLM '${modelRef}' was not found; using current chat model.`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (model.selectable === false) {
|
|
93
|
+
options.notify?.(`[TTS] Configured TTS LLM '${modelRef}' is not selectable; using current chat model.`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if ((model.registryKey ?? model.id) === (current.registryKey ?? current.id)) return null;
|
|
97
|
+
return model;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isSpokenTurn(options: OrchestratorUserInputOptions | undefined): boolean {
|
|
101
|
+
return options?.origin?.source === SPOKEN_TURN_SOURCE
|
|
102
|
+
|| options?.origin?.metadata?.['spokenOutput'] === true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createRoutedProviderRegistry(providerRegistry: ProviderRegistry, model: ModelDefinition): ProviderRegistry {
|
|
106
|
+
return new Proxy(providerRegistry, {
|
|
107
|
+
get(target, prop, receiver) {
|
|
108
|
+
if (prop === 'getCurrentModel') return () => model;
|
|
109
|
+
const value = Reflect.get(target, prop, receiver);
|
|
110
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readConfigString(configManager: Pick<ConfigManager, 'get'>, key: 'tts.llmProvider' | 'tts.llmModel'): string {
|
|
116
|
+
return String(configManager.get(key) ?? '').trim();
|
|
117
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
|
|
2
|
+
import type { UiRuntimeEvents } from '@pellux/goodvibes-sdk/platform/runtime/ui-events';
|
|
3
|
+
import type { VoiceService } from '@pellux/goodvibes-sdk/platform/voice/index';
|
|
4
|
+
import { LocalStreamingAudioPlayer } from './player.ts';
|
|
5
|
+
import { SpokenTurnController } from './spoken-turn-controller.ts';
|
|
6
|
+
|
|
7
|
+
export interface SpokenTurnRuntime {
|
|
8
|
+
readonly unsubs: readonly (() => void)[];
|
|
9
|
+
submitNextTurn(prompt: string): boolean;
|
|
10
|
+
stop(message?: string): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WireSpokenTurnRuntimeOptions {
|
|
14
|
+
readonly voiceService: VoiceService;
|
|
15
|
+
readonly configManager: ConfigManager;
|
|
16
|
+
readonly events: UiRuntimeEvents;
|
|
17
|
+
readonly notify: (message: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): SpokenTurnRuntime {
|
|
21
|
+
const controller = new SpokenTurnController({
|
|
22
|
+
voiceService: options.voiceService,
|
|
23
|
+
configManager: options.configManager,
|
|
24
|
+
player: new LocalStreamingAudioPlayer(),
|
|
25
|
+
notify: options.notify,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const turns = options.events.turns;
|
|
29
|
+
const unsubs = [
|
|
30
|
+
turns.on('TURN_SUBMITTED', (event) => controller.handleTurnEvent(event)),
|
|
31
|
+
turns.on('PREFLIGHT_FAIL', (event) => controller.handleTurnEvent(event)),
|
|
32
|
+
turns.on('STREAM_DELTA', (event) => controller.handleTurnEvent(event)),
|
|
33
|
+
turns.on('STREAM_END', (event) => controller.handleTurnEvent(event)),
|
|
34
|
+
turns.on('TURN_COMPLETED', (event) => controller.handleTurnEvent(event)),
|
|
35
|
+
turns.on('TURN_ERROR', (event) => controller.handleTurnEvent(event)),
|
|
36
|
+
turns.on('TURN_CANCEL', (event) => controller.handleTurnEvent(event)),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
unsubs,
|
|
41
|
+
submitNextTurn: (prompt) => controller.submitNextTurn(prompt),
|
|
42
|
+
stop: (message) => controller.stop(message),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|