@kuralle-syrinx/grok 2.1.1 → 3.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.
Files changed (2) hide show
  1. package/package.json +5 -4
  2. package/src/tts.ts +96 -256
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/grok",
3
- "version": "2.1.1",
3
+ "version": "3.1.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/core": "2.1.1",
17
- "@kuralle-syrinx/ws": "2.1.1",
18
- "@kuralle-syrinx/realtime": "2.1.1"
16
+ "@kuralle-syrinx/core": "3.1.0",
17
+ "@kuralle-syrinx/tts-core": "3.1.0",
18
+ "@kuralle-syrinx/ws": "3.1.0",
19
+ "@kuralle-syrinx/realtime": "3.1.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 { WebSocketConnection, type SocketFactory } from "@kuralle-syrinx/ws";
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
- 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);
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
- 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
- }
39
+ attributionFor(contextId: string): { key: AttributionKey; contextId: string } {
40
+ this.current = attributionKey(contextId);
41
+ return { key: this.current, contextId };
120
42
  }
121
43
 
122
- async flush(): Promise<void> {
123
- await this.cancelActiveContexts();
44
+ encodeText(_key: AttributionKey, text: string): SocketData[] {
45
+ return [JSON.stringify({ type: "text.delta", delta: text })];
124
46
  }
125
47
 
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;
48
+ encodeFinish(): SocketData[] {
49
+ return [JSON.stringify({ type: "text.done" })];
138
50
  }
139
51
 
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) ?? "");
52
+ encodeCancel(): SocketData[] {
53
+ return [JSON.stringify({ type: "text.clear" })];
149
54
  }
150
55
 
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
- }
56
+ encodeClose(): SocketData[] {
57
+ return [];
160
58
  }
161
59
 
162
- private handleProviderMessage(data: unknown): void {
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
- 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);
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
- this.currentContextId = "";
184
- return;
79
+ }
80
+ case "audio.done": {
81
+ this.current = null;
82
+ return key ? [{ type: "context_end", key }] : [];
185
83
  }
186
84
  case "audio.clear":
187
- this.carry = EMPTY;
188
- this.clearedPending = false;
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
- this.emitError(this.currentContextId, grokProviderError(msg));
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
- 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
- }
96
+ export class GrokTTSPlugin implements VoicePlugin {
97
+ constructor(private readonly socketFactory?: SocketFactory) {}
259
98
 
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
- }
99
+ private session: StreamingTtsSession | null = null;
272
100
 
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);
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
- private emitEnd(contextId: string): void {
281
- this.bus?.push(Route.Main, {
282
- kind: "tts.end",
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"]) ||