@kuralle-syrinx/grok 2.1.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/src/tts.ts ADDED
@@ -0,0 +1,319 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { PipelineBus } from "@kuralle-syrinx/core";
4
+ 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
+ assertAudioFormat,
14
+ assertAudioPayload,
15
+ categorizeTtsError,
16
+ isRecoverable,
17
+ optionalStringConfig,
18
+ readProviderRetryConfig,
19
+ requireStringConfig,
20
+ } from "@kuralle-syrinx/core";
21
+ import { WebSocketConnection, type SocketFactory } from "@kuralle-syrinx/ws";
22
+
23
+ import { base64ToBytes } from "@kuralle-syrinx/realtime";
24
+
25
+ const KEEP_ALIVE_INTERVAL_MS = 10_000;
26
+ const EMPTY = new Uint8Array(0);
27
+
28
+ export class GrokTTSPlugin implements VoicePlugin {
29
+ constructor(private readonly socketFactory?: SocketFactory) {}
30
+
31
+ private bus: PipelineBus | null = null;
32
+ private conn: WebSocketConnection | null = null;
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);
59
+
60
+ this.conn = new WebSocketConnection({
61
+ url: () => {
62
+ const params = new URLSearchParams({
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
+ }
120
+ }
121
+
122
+ async flush(): Promise<void> {
123
+ await this.cancelActiveContexts();
124
+ }
125
+
126
+ async close(): Promise<void> {
127
+ for (const dispose of this.disposers.splice(0)) dispose();
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;
138
+ }
139
+
140
+ private async cancelActiveContexts(): Promise<void> {
141
+ const contextIds = [...this.activeContexts];
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) ?? "");
149
+ }
150
+
151
+ private async trySend(payload: string, contextId: string): Promise<boolean> {
152
+ try {
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
+ }
160
+ }
161
+
162
+ private handleProviderMessage(data: unknown): void {
163
+ if (typeof data !== "string") return;
164
+ let msg: Record<string, unknown>;
165
+ try {
166
+ msg = JSON.parse(data) as Record<string, unknown>;
167
+ } catch {
168
+ return;
169
+ }
170
+
171
+ switch (msg["type"]) {
172
+ case "audio.delta":
173
+ this.handleAudioDelta(msg);
174
+ return;
175
+ case "audio.done": {
176
+ const contextId = this.currentContextId;
177
+ this.carry = EMPTY;
178
+ if (contextId && this.activeContexts.has(contextId)) {
179
+ this.activeContexts.delete(contextId);
180
+ this.clearFinishTimeout(contextId);
181
+ this.emitEnd(contextId);
182
+ }
183
+ this.currentContextId = "";
184
+ return;
185
+ }
186
+ case "audio.clear":
187
+ this.carry = EMPTY;
188
+ this.clearedPending = false;
189
+ return;
190
+ case "error":
191
+ this.emitError(this.currentContextId, grokProviderError(msg));
192
+ return;
193
+ default:
194
+ return;
195
+ }
196
+ }
197
+
198
+ private handleAudioDelta(msg: Record<string, unknown>): void {
199
+ const contextId = this.currentContextId;
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
+ }
259
+
260
+ private scheduleFinishTimeout(contextId: string, timeoutMs: number): void {
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
+ }
272
+
273
+ private clearFinishTimeout(contextId: string): void {
274
+ const timer = this.finishTimers.get(contextId);
275
+ if (!timer) return;
276
+ clearTimeout(timer);
277
+ this.finishTimers.delete(contextId);
278
+ }
279
+
280
+ private emitEnd(contextId: string): void {
281
+ this.bus?.push(Route.Main, {
282
+ kind: "tts.end",
283
+ contextId,
284
+ timestampMs: Date.now(),
285
+ } satisfies TextToSpeechEndPacket);
286
+ }
287
+ }
288
+
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
+ function grokProviderError(msg: Record<string, unknown>): Error {
302
+ const message =
303
+ (typeof msg["message"] === "string" && msg["message"]) ||
304
+ (typeof msg["error"] === "string" && msg["error"]) ||
305
+ "Grok TTS provider error";
306
+ return new Error(message);
307
+ }
308
+
309
+ function readPositiveInteger(value: unknown, fallback: number): number {
310
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
311
+ const integer = Math.floor(value);
312
+ return integer > 0 ? integer : fallback;
313
+ }
314
+
315
+ function readNonNegativeInteger(value: unknown, fallback: number): number {
316
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
317
+ const integer = Math.floor(value);
318
+ return integer >= 0 ? integer : fallback;
319
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "noImplicitReturns": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "sourceMap": true,
13
+ "outDir": "./dist",
14
+ "rootDir": "./src",
15
+ "esModuleInterop": true,
16
+ "skipLibCheck": true,
17
+ "forceConsistentCasingInFileNames": true
18
+ },
19
+ "include": ["src/**/*.ts"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }