@kuralle-syrinx/grok 2.1.1 → 3.0.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/package.json +5 -4
- package/src/tts.ts +96 -256
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/grok",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
"./realtime": "./src/from-grok-realtime.ts"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@kuralle-syrinx/
|
|
17
|
-
"@kuralle-syrinx/ws": "
|
|
18
|
-
"@kuralle-syrinx/
|
|
16
|
+
"@kuralle-syrinx/realtime": "3.0.0",
|
|
17
|
+
"@kuralle-syrinx/ws": "3.0.0",
|
|
18
|
+
"@kuralle-syrinx/tts-core": "3.0.0",
|
|
19
|
+
"@kuralle-syrinx/core": "3.0.0"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@types/ws": "^8.5.0",
|
package/src/tts.ts
CHANGED
|
@@ -1,303 +1,143 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Grok (xAI) TTS Plugin. The streaming lifecycle lives in @kuralle-syrinx/tts-core. This
|
|
4
|
+
// file is the Grok wire protocol: single-context streaming (one active utterance at a time),
|
|
5
|
+
// text.delta/text.done/text.clear out, base64 audio.delta in. Grok frames carry no key, so
|
|
6
|
+
// inbound audio is attributed to the one current context. Raw PCM may split mid-sample, so
|
|
7
|
+
// Grok relies on the engine's streaming carry.
|
|
2
8
|
|
|
3
|
-
import type { PipelineBus } from "@kuralle-syrinx/core";
|
|
4
9
|
import {
|
|
5
|
-
Route,
|
|
6
|
-
type AudioFormat,
|
|
7
|
-
type PluginConfig,
|
|
8
|
-
type RetryConfig,
|
|
9
|
-
type TextToSpeechAudioPacket,
|
|
10
|
-
type TextToSpeechEndPacket,
|
|
11
|
-
type TtsErrorPacket,
|
|
12
|
-
type VoicePlugin,
|
|
13
10
|
assertAudioFormat,
|
|
14
|
-
assertAudioPayload,
|
|
15
|
-
categorizeTtsError,
|
|
16
|
-
isRecoverable,
|
|
17
11
|
optionalStringConfig,
|
|
18
12
|
readProviderRetryConfig,
|
|
19
13
|
requireStringConfig,
|
|
14
|
+
type AudioFormat,
|
|
15
|
+
type PipelineBus,
|
|
16
|
+
type PluginConfig,
|
|
17
|
+
type VoicePlugin,
|
|
20
18
|
} from "@kuralle-syrinx/core";
|
|
21
|
-
import {
|
|
22
|
-
|
|
19
|
+
import {
|
|
20
|
+
attributionKey,
|
|
21
|
+
defaultNodeSocketFactory,
|
|
22
|
+
startStreamingTtsSession,
|
|
23
|
+
type AttributionKey,
|
|
24
|
+
type StreamingTtsSession,
|
|
25
|
+
type WireEvent,
|
|
26
|
+
type WireProtocol,
|
|
27
|
+
} from "@kuralle-syrinx/tts-core";
|
|
28
|
+
import type { SocketData, SocketFactory } from "@kuralle-syrinx/ws";
|
|
23
29
|
import { base64ToBytes } from "@kuralle-syrinx/realtime";
|
|
24
30
|
|
|
25
31
|
const KEEP_ALIVE_INTERVAL_MS = 10_000;
|
|
26
|
-
const EMPTY = new Uint8Array(0);
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
private
|
|
33
|
-
private apiKey = "";
|
|
34
|
-
private voiceId = "eve";
|
|
35
|
-
private language = "en";
|
|
36
|
-
private endpointUrl = "wss://api.x.ai/v1/tts";
|
|
37
|
-
private sampleRate = 16000;
|
|
38
|
-
private retryConfig: RetryConfig = readProviderRetryConfig({});
|
|
39
|
-
private currentContextId = "";
|
|
40
|
-
private carry: Uint8Array = EMPTY;
|
|
41
|
-
private activeContexts = new Set<string>();
|
|
42
|
-
private cancelledContexts = new Set<string>();
|
|
43
|
-
private clearedPending = false;
|
|
44
|
-
private finishTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
45
|
-
private disposers: Array<() => void> = [];
|
|
46
|
-
private audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 16000, channels: 1 };
|
|
47
|
-
|
|
48
|
-
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
49
|
-
this.bus = bus;
|
|
50
|
-
this.apiKey = requireStringConfig(config, "api_key");
|
|
51
|
-
this.voiceId = optionalStringConfig(config, "voice_id") ?? this.voiceId;
|
|
52
|
-
this.language = optionalStringConfig(config, "language") ?? this.language;
|
|
53
|
-
this.endpointUrl = optionalStringConfig(config, "endpoint_url") ?? this.endpointUrl;
|
|
54
|
-
this.sampleRate = readPositiveInteger(config["sample_rate"], this.sampleRate);
|
|
55
|
-
this.retryConfig = readProviderRetryConfig(config);
|
|
56
|
-
const finishTimeoutMs = readNonNegativeInteger(config["finish_timeout_ms"], 2000);
|
|
57
|
-
this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
|
|
58
|
-
assertAudioFormat(this.audioFormat);
|
|
33
|
+
class GrokWireProtocol implements WireProtocol {
|
|
34
|
+
// Grok streams one context at a time; inbound frames carry no key, so they're attributed
|
|
35
|
+
// to the current one. (Audio for an interrupted context is dropped by the engine's
|
|
36
|
+
// cancelled-key tracking, which subsumes Grok's old `clearedPending` race guard.)
|
|
37
|
+
private current: AttributionKey | null = null;
|
|
59
38
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
language: this.language,
|
|
64
|
-
voice: this.voiceId,
|
|
65
|
-
codec: "pcm",
|
|
66
|
-
sample_rate: String(this.sampleRate),
|
|
67
|
-
});
|
|
68
|
-
const separator = this.endpointUrl.includes("?") ? "&" : "?";
|
|
69
|
-
return `${this.endpointUrl}${separator}${params.toString()}`;
|
|
70
|
-
},
|
|
71
|
-
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
72
|
-
retry: this.retryConfig,
|
|
73
|
-
socketFactory: this.socketFactory ?? (await defaultSocketFactory()),
|
|
74
|
-
replayBufferSize: (config["replay_buffer_size"] as number) ?? 32,
|
|
75
|
-
keepAliveIntervalMs: KEEP_ALIVE_INTERVAL_MS,
|
|
76
|
-
onMessage: (data) => this.handleProviderMessage(data),
|
|
77
|
-
onConnectionLost: (err) => this.failActiveContexts(err),
|
|
78
|
-
onUnrecoverable: (err) => this.failActiveContexts(err),
|
|
79
|
-
});
|
|
80
|
-
await this.conn.connect();
|
|
81
|
-
|
|
82
|
-
this.disposers.push(
|
|
83
|
-
bus.on("tts.text", async (pkt: unknown) => {
|
|
84
|
-
const textPkt = pkt as { text: string; contextId: string };
|
|
85
|
-
await this.speak(textPkt.text, textPkt.contextId);
|
|
86
|
-
}),
|
|
87
|
-
bus.on("tts.done", async (pkt: unknown) => {
|
|
88
|
-
const donePkt = pkt as { contextId: string };
|
|
89
|
-
if (finishTimeoutMs > 0 && this.activeContexts.has(donePkt.contextId)) {
|
|
90
|
-
this.scheduleFinishTimeout(donePkt.contextId, finishTimeoutMs);
|
|
91
|
-
}
|
|
92
|
-
await this.finishContext(donePkt.contextId);
|
|
93
|
-
}),
|
|
94
|
-
bus.on("interrupt.tts", () => {
|
|
95
|
-
this.cancelActiveContexts().catch(() => {
|
|
96
|
-
// Best-effort interruption.
|
|
97
|
-
});
|
|
98
|
-
}),
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
private async speak(text: string, contextId: string): Promise<void> {
|
|
103
|
-
if (!text.trim()) return;
|
|
104
|
-
if (this.cancelledContexts.has(contextId)) return;
|
|
105
|
-
this.activeContexts.add(contextId);
|
|
106
|
-
this.currentContextId = contextId;
|
|
107
|
-
const sent = await this.trySend(JSON.stringify({ type: "text.delta", delta: text }), contextId);
|
|
108
|
-
if (!sent) this.activeContexts.delete(contextId);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
private async finishContext(contextId: string): Promise<void> {
|
|
112
|
-
if (this.cancelledContexts.has(contextId)) return;
|
|
113
|
-
if (!this.activeContexts.has(contextId)) {
|
|
114
|
-
this.emitEnd(contextId);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (!(await this.trySend(JSON.stringify({ type: "text.done" }), contextId))) {
|
|
118
|
-
this.activeContexts.delete(contextId);
|
|
119
|
-
}
|
|
39
|
+
attributionFor(contextId: string): { key: AttributionKey; contextId: string } {
|
|
40
|
+
this.current = attributionKey(contextId);
|
|
41
|
+
return { key: this.current, contextId };
|
|
120
42
|
}
|
|
121
43
|
|
|
122
|
-
|
|
123
|
-
|
|
44
|
+
encodeText(_key: AttributionKey, text: string): SocketData[] {
|
|
45
|
+
return [JSON.stringify({ type: "text.delta", delta: text })];
|
|
124
46
|
}
|
|
125
47
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.activeContexts.clear();
|
|
129
|
-
this.cancelledContexts.clear();
|
|
130
|
-
for (const timer of this.finishTimers.values()) clearTimeout(timer);
|
|
131
|
-
this.finishTimers.clear();
|
|
132
|
-
this.currentContextId = "";
|
|
133
|
-
this.carry = EMPTY;
|
|
134
|
-
this.clearedPending = false;
|
|
135
|
-
await this.conn?.close();
|
|
136
|
-
this.conn = null;
|
|
137
|
-
this.bus = null;
|
|
48
|
+
encodeFinish(): SocketData[] {
|
|
49
|
+
return [JSON.stringify({ type: "text.done" })];
|
|
138
50
|
}
|
|
139
51
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
for (const contextId of contextIds) this.cancelledContexts.add(contextId);
|
|
143
|
-
this.activeContexts.clear();
|
|
144
|
-
for (const contextId of contextIds) this.clearFinishTimeout(contextId);
|
|
145
|
-
this.currentContextId = "";
|
|
146
|
-
this.carry = EMPTY;
|
|
147
|
-
if (contextIds.length === 0) return;
|
|
148
|
-
this.clearedPending = await this.trySend(JSON.stringify({ type: "text.clear" }), contextIds.at(-1) ?? "");
|
|
52
|
+
encodeCancel(): SocketData[] {
|
|
53
|
+
return [JSON.stringify({ type: "text.clear" })];
|
|
149
54
|
}
|
|
150
55
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await this.conn?.ensureReady();
|
|
154
|
-
this.conn?.send(payload);
|
|
155
|
-
return true;
|
|
156
|
-
} catch (err) {
|
|
157
|
-
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
56
|
+
encodeClose(): SocketData[] {
|
|
57
|
+
return [];
|
|
160
58
|
}
|
|
161
59
|
|
|
162
|
-
|
|
163
|
-
if (typeof data !== "string") return;
|
|
60
|
+
decode(data: SocketData): WireEvent[] {
|
|
61
|
+
if (typeof data !== "string") return [];
|
|
164
62
|
let msg: Record<string, unknown>;
|
|
165
63
|
try {
|
|
166
64
|
msg = JSON.parse(data) as Record<string, unknown>;
|
|
167
65
|
} catch {
|
|
168
|
-
return;
|
|
66
|
+
return []; // Grok ignores unparseable frames (does not fail the session).
|
|
169
67
|
}
|
|
170
|
-
|
|
68
|
+
const key = this.current;
|
|
171
69
|
switch (msg["type"]) {
|
|
172
|
-
case "audio.delta":
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.clearFinishTimeout(contextId);
|
|
181
|
-
this.emitEnd(contextId);
|
|
70
|
+
case "audio.delta": {
|
|
71
|
+
if (!key) return [];
|
|
72
|
+
const delta = typeof msg["delta"] === "string" ? msg["delta"] : "";
|
|
73
|
+
if (delta.length === 0) return [];
|
|
74
|
+
try {
|
|
75
|
+
return [{ type: "audio", key, pcm: base64ToBytes(delta) }];
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return [{ type: "error", key, error: err instanceof Error ? err : new Error(String(err)) }];
|
|
182
78
|
}
|
|
183
|
-
|
|
184
|
-
|
|
79
|
+
}
|
|
80
|
+
case "audio.done": {
|
|
81
|
+
this.current = null;
|
|
82
|
+
return key ? [{ type: "context_end", key }] : [];
|
|
185
83
|
}
|
|
186
84
|
case "audio.clear":
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return;
|
|
85
|
+
// Provider acknowledged a text.clear. Audio for the interrupted context is already
|
|
86
|
+
// dropped via the engine's cancelled tracking; nothing more to do.
|
|
87
|
+
return [];
|
|
190
88
|
case "error":
|
|
191
|
-
|
|
192
|
-
return;
|
|
89
|
+
return key ? [{ type: "error", key, error: grokProviderError(msg) }] : [];
|
|
193
90
|
default:
|
|
194
|
-
return;
|
|
91
|
+
return [];
|
|
195
92
|
}
|
|
196
93
|
}
|
|
94
|
+
}
|
|
197
95
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!contextId || this.clearedPending || this.cancelledContexts.has(contextId)) return;
|
|
201
|
-
const delta = typeof msg["delta"] === "string" ? msg["delta"] : "";
|
|
202
|
-
if (delta.length === 0) return;
|
|
203
|
-
|
|
204
|
-
let frame: Uint8Array;
|
|
205
|
-
try {
|
|
206
|
-
frame = base64ToBytes(delta);
|
|
207
|
-
} catch (err) {
|
|
208
|
-
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (frame.byteLength === 0) return;
|
|
212
|
-
|
|
213
|
-
const buf = this.carry.byteLength === 0 ? frame : concatBytes(this.carry, frame);
|
|
214
|
-
const evenLen = buf.byteLength - (buf.byteLength % 2);
|
|
215
|
-
if (evenLen > 0) {
|
|
216
|
-
const audio = buf.subarray(0, evenLen);
|
|
217
|
-
try {
|
|
218
|
-
assertAudioPayload(this.audioFormat, audio);
|
|
219
|
-
} catch (err) {
|
|
220
|
-
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
const packet: TextToSpeechAudioPacket = {
|
|
224
|
-
kind: "tts.audio",
|
|
225
|
-
contextId,
|
|
226
|
-
timestampMs: Date.now(),
|
|
227
|
-
audio,
|
|
228
|
-
sampleRateHz: this.sampleRate,
|
|
229
|
-
provider: { name: "grok", model: this.voiceId, region: "global", cancelled: false },
|
|
230
|
-
};
|
|
231
|
-
this.bus?.push(Route.Main, packet);
|
|
232
|
-
}
|
|
233
|
-
this.carry = evenLen < buf.byteLength ? buf.subarray(evenLen) : EMPTY;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private emitError(contextId: string, err: Error): void {
|
|
237
|
-
const category = categorizeTtsError(err);
|
|
238
|
-
const packet: TtsErrorPacket = {
|
|
239
|
-
kind: "tts.error",
|
|
240
|
-
contextId,
|
|
241
|
-
timestampMs: Date.now(),
|
|
242
|
-
component: "tts",
|
|
243
|
-
category,
|
|
244
|
-
cause: err,
|
|
245
|
-
isRecoverable: isRecoverable(category),
|
|
246
|
-
};
|
|
247
|
-
this.bus?.push(Route.Critical, packet);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
private failActiveContexts(err: Error): void {
|
|
251
|
-
const contextIds = [...this.activeContexts];
|
|
252
|
-
this.activeContexts.clear();
|
|
253
|
-
for (const contextId of contextIds) this.clearFinishTimeout(contextId);
|
|
254
|
-
this.currentContextId = "";
|
|
255
|
-
this.carry = EMPTY;
|
|
256
|
-
this.clearedPending = false;
|
|
257
|
-
for (const contextId of contextIds) this.emitError(contextId, err);
|
|
258
|
-
}
|
|
96
|
+
export class GrokTTSPlugin implements VoicePlugin {
|
|
97
|
+
constructor(private readonly socketFactory?: SocketFactory) {}
|
|
259
98
|
|
|
260
|
-
private
|
|
261
|
-
this.clearFinishTimeout(contextId);
|
|
262
|
-
const timer = setTimeout(() => {
|
|
263
|
-
this.finishTimers.delete(contextId);
|
|
264
|
-
if (!this.activeContexts.has(contextId)) return;
|
|
265
|
-
this.activeContexts.delete(contextId);
|
|
266
|
-
if (this.currentContextId === contextId) this.currentContextId = "";
|
|
267
|
-
this.carry = EMPTY;
|
|
268
|
-
this.emitEnd(contextId);
|
|
269
|
-
}, timeoutMs);
|
|
270
|
-
this.finishTimers.set(contextId, timer);
|
|
271
|
-
}
|
|
99
|
+
private session: StreamingTtsSession | null = null;
|
|
272
100
|
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
101
|
+
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
102
|
+
const apiKey = requireStringConfig(config, "api_key");
|
|
103
|
+
const voiceId = optionalStringConfig(config, "voice_id") ?? "eve";
|
|
104
|
+
const language = optionalStringConfig(config, "language") ?? "en";
|
|
105
|
+
const endpointUrl = optionalStringConfig(config, "endpoint_url") ?? "wss://api.x.ai/v1/tts";
|
|
106
|
+
const sampleRate = readPositiveInteger(config["sample_rate"], 16000);
|
|
107
|
+
const audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: sampleRate, channels: 1 };
|
|
108
|
+
assertAudioFormat(audioFormat);
|
|
109
|
+
|
|
110
|
+
this.session = await startStreamingTtsSession(bus, {
|
|
111
|
+
protocol: new GrokWireProtocol(),
|
|
112
|
+
provider: { name: "grok", model: voiceId, region: "global" },
|
|
113
|
+
format: audioFormat,
|
|
114
|
+
sampleRateHz: sampleRate,
|
|
115
|
+
url: () => {
|
|
116
|
+
const params = new URLSearchParams({
|
|
117
|
+
language,
|
|
118
|
+
voice: voiceId,
|
|
119
|
+
codec: "pcm",
|
|
120
|
+
sample_rate: String(sampleRate),
|
|
121
|
+
});
|
|
122
|
+
const separator = endpointUrl.includes("?") ? "&" : "?";
|
|
123
|
+
return `${endpointUrl}${separator}${params.toString()}`;
|
|
124
|
+
},
|
|
125
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
126
|
+
retry: readProviderRetryConfig(config),
|
|
127
|
+
finishTimeoutMs: readNonNegativeInteger(config["finish_timeout_ms"], 2000),
|
|
128
|
+
metricPrefix: "tts.grok",
|
|
129
|
+
socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
|
|
130
|
+
replayBufferSize: (config["replay_buffer_size"] as number) ?? 32,
|
|
131
|
+
keepAliveIntervalMs: KEEP_ALIVE_INTERVAL_MS,
|
|
132
|
+
});
|
|
278
133
|
}
|
|
279
134
|
|
|
280
|
-
|
|
281
|
-
this.
|
|
282
|
-
|
|
283
|
-
contextId,
|
|
284
|
-
timestampMs: Date.now(),
|
|
285
|
-
} satisfies TextToSpeechEndPacket);
|
|
135
|
+
async close(): Promise<void> {
|
|
136
|
+
await this.session?.dispose();
|
|
137
|
+
this.session = null;
|
|
286
138
|
}
|
|
287
139
|
}
|
|
288
140
|
|
|
289
|
-
async function defaultSocketFactory(): Promise<SocketFactory> {
|
|
290
|
-
const mod = await import("@kuralle-syrinx/ws/node");
|
|
291
|
-
return mod.createNodeWsSocket;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
295
|
-
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
296
|
-
out.set(a, 0);
|
|
297
|
-
out.set(b, a.byteLength);
|
|
298
|
-
return out;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
141
|
function grokProviderError(msg: Record<string, unknown>): Error {
|
|
302
142
|
const message =
|
|
303
143
|
(typeof msg["message"] === "string" && msg["message"]) ||
|