@kuralle-syrinx/grok 4.4.1 → 4.5.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/dist/from-grok-realtime.d.ts +31 -0
- package/dist/from-grok-realtime.d.ts.map +1 -0
- package/dist/from-grok-realtime.js +88 -0
- package/dist/from-grok-realtime.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/stt.d.ts +17 -0
- package/dist/stt.d.ts.map +1 -0
- package/dist/stt.js +179 -0
- package/dist/stt.js.map +1 -0
- package/dist/tts.d.ts +10 -0
- package/dist/tts.d.ts.map +1 -0
- package/dist/tts.js +178 -0
- package/dist/tts.js.map +1 -0
- package/package.json +32 -13
- package/src/edge-safety.test.ts +146 -0
- package/src/from-grok-realtime.test.ts +317 -0
- package/src/stt.test.ts +418 -0
- package/src/tts.test.ts +320 -0
package/src/stt.test.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { WebSocketServer, type WebSocket } from "ws";
|
|
5
|
+
import {
|
|
6
|
+
PipelineBusImpl,
|
|
7
|
+
Route,
|
|
8
|
+
type SttErrorPacket,
|
|
9
|
+
type SttInterimPacket,
|
|
10
|
+
type SttResultPacket,
|
|
11
|
+
type UsageRecordedPacket,
|
|
12
|
+
} from "@kuralle-syrinx/core";
|
|
13
|
+
|
|
14
|
+
import { GrokSTTPlugin } from "./stt.js";
|
|
15
|
+
|
|
16
|
+
let servers: WebSocketServer[] = [];
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await Promise.all(
|
|
20
|
+
servers.splice(0).map(
|
|
21
|
+
(server) =>
|
|
22
|
+
new Promise<void>((resolve) => {
|
|
23
|
+
for (const client of server.clients) client.terminate();
|
|
24
|
+
server.close(() => resolve());
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function createLocalServer(onConnection: (socket: WebSocket) => void): Promise<string> {
|
|
31
|
+
const server = await new Promise<WebSocketServer>((resolve) => {
|
|
32
|
+
let nextServer: WebSocketServer;
|
|
33
|
+
nextServer = new WebSocketServer({ port: 0 }, () => {
|
|
34
|
+
resolve(nextServer);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
servers.push(server);
|
|
38
|
+
server.on("connection", onConnection);
|
|
39
|
+
const address = server.address();
|
|
40
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP server address");
|
|
41
|
+
return `ws://127.0.0.1:${address.port}/stt`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function waitFor<T>(items: T[], count = 1): Promise<void> {
|
|
45
|
+
for (let i = 0; i < 50; i += 1) {
|
|
46
|
+
if (items.length >= count) return;
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("GrokSTTPlugin", () => {
|
|
52
|
+
it("waits for transcript.created before sending binary audio", async () => {
|
|
53
|
+
const binaryFrames: Uint8Array[] = [];
|
|
54
|
+
const controlMessages: string[] = [];
|
|
55
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
56
|
+
socket.on("message", (data, isBinary) => {
|
|
57
|
+
if (isBinary) {
|
|
58
|
+
binaryFrames.push(new Uint8Array(data as Buffer));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
controlMessages.push(data.toString());
|
|
62
|
+
});
|
|
63
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const bus = new PipelineBusImpl();
|
|
67
|
+
const started = bus.start();
|
|
68
|
+
const plugin = new GrokSTTPlugin();
|
|
69
|
+
|
|
70
|
+
await plugin.initialize(bus, {
|
|
71
|
+
api_key: "test",
|
|
72
|
+
endpoint_url: endpointUrl,
|
|
73
|
+
sample_rate: 16000,
|
|
74
|
+
});
|
|
75
|
+
bus.push(Route.Main, {
|
|
76
|
+
kind: "stt.audio",
|
|
77
|
+
contextId: "turn-1",
|
|
78
|
+
timestampMs: Date.now(),
|
|
79
|
+
audio: new Uint8Array(640),
|
|
80
|
+
});
|
|
81
|
+
await waitFor(binaryFrames);
|
|
82
|
+
|
|
83
|
+
expect(binaryFrames).toEqual([new Uint8Array(640)]);
|
|
84
|
+
expect(controlMessages).toEqual([]);
|
|
85
|
+
|
|
86
|
+
await plugin.close();
|
|
87
|
+
await waitFor(controlMessages);
|
|
88
|
+
expect(controlMessages).toContain(JSON.stringify({ type: "audio.done" }));
|
|
89
|
+
bus.stop();
|
|
90
|
+
await started;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps transcript.partial to stt.interim and stt.result", async () => {
|
|
94
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
95
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
96
|
+
socket.on("message", (data, isBinary) => {
|
|
97
|
+
if (!isBinary) return;
|
|
98
|
+
socket.send(JSON.stringify({
|
|
99
|
+
type: "transcript.partial",
|
|
100
|
+
text: "hello",
|
|
101
|
+
is_final: false,
|
|
102
|
+
}));
|
|
103
|
+
socket.send(JSON.stringify({
|
|
104
|
+
type: "transcript.partial",
|
|
105
|
+
text: "hello world",
|
|
106
|
+
is_final: true,
|
|
107
|
+
speech_final: false,
|
|
108
|
+
words: [{ word: "hello" }, { word: "world" }],
|
|
109
|
+
start: 0,
|
|
110
|
+
duration: 1.2,
|
|
111
|
+
end_of_turn_confidence: 0.91,
|
|
112
|
+
}));
|
|
113
|
+
socket.send(JSON.stringify({
|
|
114
|
+
type: "transcript.partial",
|
|
115
|
+
text: "hello world",
|
|
116
|
+
is_final: true,
|
|
117
|
+
speech_final: true,
|
|
118
|
+
end_of_turn_confidence: 0.95,
|
|
119
|
+
}));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const bus = new PipelineBusImpl();
|
|
124
|
+
const started = bus.start();
|
|
125
|
+
const plugin = new GrokSTTPlugin();
|
|
126
|
+
const interims: Array<{ text: string }> = [];
|
|
127
|
+
const finals: SttResultPacket[] = [];
|
|
128
|
+
const turnCompletes: Array<{ kind: string }> = [];
|
|
129
|
+
bus.on("stt.interim", (pkt) => {
|
|
130
|
+
interims.push({ text: (pkt as SttInterimPacket).text });
|
|
131
|
+
});
|
|
132
|
+
bus.on("stt.result", (pkt) => {
|
|
133
|
+
finals.push(pkt as SttResultPacket);
|
|
134
|
+
});
|
|
135
|
+
bus.on("eos.turn_complete", (pkt) => {
|
|
136
|
+
turnCompletes.push(pkt as { kind: string });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await plugin.initialize(bus, {
|
|
140
|
+
api_key: "test",
|
|
141
|
+
endpoint_url: endpointUrl,
|
|
142
|
+
sample_rate: 16000,
|
|
143
|
+
});
|
|
144
|
+
bus.push(Route.Main, {
|
|
145
|
+
kind: "stt.audio",
|
|
146
|
+
contextId: "turn-2",
|
|
147
|
+
timestampMs: Date.now(),
|
|
148
|
+
audio: new Uint8Array(320),
|
|
149
|
+
});
|
|
150
|
+
await waitFor(finals, 2);
|
|
151
|
+
await waitFor(turnCompletes);
|
|
152
|
+
|
|
153
|
+
expect(interims).toEqual([{ text: "hello" }]);
|
|
154
|
+
expect(finals[0]).toEqual(
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
kind: "stt.result",
|
|
157
|
+
contextId: "turn-2",
|
|
158
|
+
text: "hello world",
|
|
159
|
+
confidence: 0.91,
|
|
160
|
+
provider: expect.objectContaining({
|
|
161
|
+
name: "grok",
|
|
162
|
+
speechFinal: false,
|
|
163
|
+
words: [{ word: "hello" }, { word: "world" }],
|
|
164
|
+
start: 0,
|
|
165
|
+
duration: 1.2,
|
|
166
|
+
}),
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
expect(finals[1]).toEqual(
|
|
170
|
+
expect.objectContaining({
|
|
171
|
+
text: "hello world",
|
|
172
|
+
confidence: 0.95,
|
|
173
|
+
provider: expect.objectContaining({ speechFinal: true }),
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
expect(turnCompletes).toHaveLength(1);
|
|
177
|
+
|
|
178
|
+
await plugin.close();
|
|
179
|
+
bus.stop();
|
|
180
|
+
await started;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("sends audio.done on stt.finalize", async () => {
|
|
184
|
+
const controlMessages: string[] = [];
|
|
185
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
186
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
187
|
+
socket.on("message", (data, isBinary) => {
|
|
188
|
+
if (!isBinary) controlMessages.push(data.toString());
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const bus = new PipelineBusImpl();
|
|
193
|
+
const started = bus.start();
|
|
194
|
+
const plugin = new GrokSTTPlugin();
|
|
195
|
+
|
|
196
|
+
await plugin.initialize(bus, {
|
|
197
|
+
api_key: "test",
|
|
198
|
+
endpoint_url: endpointUrl,
|
|
199
|
+
sample_rate: 16000,
|
|
200
|
+
});
|
|
201
|
+
bus.push(Route.Main, {
|
|
202
|
+
kind: "stt.finalize",
|
|
203
|
+
contextId: "turn-3",
|
|
204
|
+
timestampMs: Date.now(),
|
|
205
|
+
});
|
|
206
|
+
await waitFor(controlMessages);
|
|
207
|
+
|
|
208
|
+
expect(JSON.parse(controlMessages[0]!)).toEqual({ type: "audio.done" });
|
|
209
|
+
|
|
210
|
+
await plugin.close();
|
|
211
|
+
bus.stop();
|
|
212
|
+
await started;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("emits usage.recorded with stage stt and audioSeconds from provider duration at final", async () => {
|
|
216
|
+
let binarySeen = 0;
|
|
217
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
218
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
219
|
+
socket.on("message", (data, isBinary) => {
|
|
220
|
+
if (!isBinary) return;
|
|
221
|
+
binarySeen += 1;
|
|
222
|
+
socket.send(JSON.stringify({
|
|
223
|
+
type: "transcript.partial",
|
|
224
|
+
text: "hello world",
|
|
225
|
+
is_final: true,
|
|
226
|
+
speech_final: true,
|
|
227
|
+
duration: 1.2,
|
|
228
|
+
end_of_turn_confidence: 0.95,
|
|
229
|
+
}));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const bus = new PipelineBusImpl();
|
|
234
|
+
const started = bus.start();
|
|
235
|
+
const plugin = new GrokSTTPlugin();
|
|
236
|
+
const usage: UsageRecordedPacket[] = [];
|
|
237
|
+
bus.on("usage.recorded", (pkt) => {
|
|
238
|
+
usage.push(pkt as UsageRecordedPacket);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await plugin.initialize(bus, {
|
|
242
|
+
api_key: "test",
|
|
243
|
+
endpoint_url: endpointUrl,
|
|
244
|
+
sample_rate: 16000,
|
|
245
|
+
});
|
|
246
|
+
// transcript.created must land before binary audio is accepted.
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
248
|
+
bus.push(Route.Main, {
|
|
249
|
+
kind: "stt.audio",
|
|
250
|
+
contextId: "turn-usage",
|
|
251
|
+
timestampMs: Date.now(),
|
|
252
|
+
audio: new Uint8Array(640),
|
|
253
|
+
});
|
|
254
|
+
for (let i = 0; i < 100 && (binarySeen < 1 || usage.length < 1); i += 1) {
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
expect(usage).toEqual([
|
|
259
|
+
expect.objectContaining({
|
|
260
|
+
kind: "usage.recorded",
|
|
261
|
+
contextId: "turn-usage",
|
|
262
|
+
stage: "stt",
|
|
263
|
+
provider: "grok",
|
|
264
|
+
model: "stt",
|
|
265
|
+
audioSeconds: 1.2,
|
|
266
|
+
}),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
await plugin.close();
|
|
270
|
+
bus.stop();
|
|
271
|
+
await started;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("emits usage.recorded under smart-turn endpointing (emit_eos_on_final=false)", async () => {
|
|
275
|
+
let binarySeen = 0;
|
|
276
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
277
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
278
|
+
socket.on("message", (data, isBinary) => {
|
|
279
|
+
if (!isBinary) return;
|
|
280
|
+
binarySeen += 1;
|
|
281
|
+
socket.send(JSON.stringify({
|
|
282
|
+
type: "transcript.partial",
|
|
283
|
+
text: "account number please",
|
|
284
|
+
is_final: true,
|
|
285
|
+
speech_final: false,
|
|
286
|
+
duration: 0.8,
|
|
287
|
+
end_of_turn_confidence: 0.9,
|
|
288
|
+
}));
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const bus = new PipelineBusImpl();
|
|
293
|
+
const started = bus.start();
|
|
294
|
+
const plugin = new GrokSTTPlugin();
|
|
295
|
+
const usage: UsageRecordedPacket[] = [];
|
|
296
|
+
bus.on("usage.recorded", (pkt) => {
|
|
297
|
+
usage.push(pkt as UsageRecordedPacket);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await plugin.initialize(bus, {
|
|
301
|
+
api_key: "test",
|
|
302
|
+
endpoint_url: endpointUrl,
|
|
303
|
+
sample_rate: 16000,
|
|
304
|
+
emit_eos_on_final: false,
|
|
305
|
+
});
|
|
306
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
307
|
+
bus.push(Route.Main, {
|
|
308
|
+
kind: "stt.audio",
|
|
309
|
+
contextId: "turn-smartturn",
|
|
310
|
+
timestampMs: Date.now(),
|
|
311
|
+
audio: new Uint8Array(320),
|
|
312
|
+
});
|
|
313
|
+
for (let i = 0; i < 100 && (binarySeen < 1 || usage.length < 1); i += 1) {
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
expect(usage).toEqual([
|
|
318
|
+
expect.objectContaining({
|
|
319
|
+
kind: "usage.recorded",
|
|
320
|
+
contextId: "turn-smartturn",
|
|
321
|
+
stage: "stt",
|
|
322
|
+
provider: "grok",
|
|
323
|
+
model: "stt",
|
|
324
|
+
audioSeconds: 0.8,
|
|
325
|
+
}),
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
await plugin.close();
|
|
329
|
+
bus.stop();
|
|
330
|
+
await started;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("maps provider error frames to stt.error", async () => {
|
|
334
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
335
|
+
socket.send(JSON.stringify({ type: "error", message: "bad audio format" }));
|
|
336
|
+
});
|
|
337
|
+
const bus = new PipelineBusImpl();
|
|
338
|
+
const started = bus.start();
|
|
339
|
+
const plugin = new GrokSTTPlugin();
|
|
340
|
+
const errors: SttErrorPacket[] = [];
|
|
341
|
+
bus.on("stt.error", (pkt) => {
|
|
342
|
+
errors.push(pkt as SttErrorPacket);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await plugin.initialize(bus, {
|
|
346
|
+
api_key: "test",
|
|
347
|
+
endpoint_url: endpointUrl,
|
|
348
|
+
sample_rate: 16000,
|
|
349
|
+
});
|
|
350
|
+
await waitFor(errors);
|
|
351
|
+
|
|
352
|
+
expect(errors[0]).toEqual(
|
|
353
|
+
expect.objectContaining({
|
|
354
|
+
kind: "stt.error",
|
|
355
|
+
cause: expect.objectContaining({ message: "bad audio format" }),
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await plugin.close();
|
|
360
|
+
bus.stop();
|
|
361
|
+
await started;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("puts named knobs and query_params on the STT websocket URL", async () => {
|
|
365
|
+
const connectionUrls: string[] = [];
|
|
366
|
+
const server = await new Promise<WebSocketServer>((resolve) => {
|
|
367
|
+
let nextServer: WebSocketServer;
|
|
368
|
+
nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
|
|
369
|
+
});
|
|
370
|
+
servers.push(server);
|
|
371
|
+
server.on("connection", (socket, req) => {
|
|
372
|
+
connectionUrls.push(req.url ?? "");
|
|
373
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
374
|
+
});
|
|
375
|
+
const address = server.address();
|
|
376
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP server address");
|
|
377
|
+
const endpointUrl = `ws://127.0.0.1:${address.port}/stt`;
|
|
378
|
+
|
|
379
|
+
const bus = new PipelineBusImpl();
|
|
380
|
+
const started = bus.start();
|
|
381
|
+
const plugin = new GrokSTTPlugin();
|
|
382
|
+
await plugin.initialize(bus, {
|
|
383
|
+
api_key: "test",
|
|
384
|
+
endpoint_url: endpointUrl,
|
|
385
|
+
sample_rate: 8000,
|
|
386
|
+
encoding: "mulaw",
|
|
387
|
+
language: "es",
|
|
388
|
+
endpointing: 25,
|
|
389
|
+
interim_results: false,
|
|
390
|
+
diarize: true,
|
|
391
|
+
keyterm: "Syrinx",
|
|
392
|
+
smart_turn: 0.8,
|
|
393
|
+
smart_turn_timeout: 1500,
|
|
394
|
+
query_params: {
|
|
395
|
+
custom_flag: "on",
|
|
396
|
+
tag: ["a", "b"],
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
await waitFor(connectionUrls);
|
|
400
|
+
await plugin.close();
|
|
401
|
+
bus.stop();
|
|
402
|
+
await started;
|
|
403
|
+
|
|
404
|
+
const url = connectionUrls[0]!;
|
|
405
|
+
expect(url).toContain("sample_rate=8000");
|
|
406
|
+
expect(url).toContain("encoding=mulaw");
|
|
407
|
+
expect(url).toContain("language=es");
|
|
408
|
+
expect(url).toContain("endpointing=25");
|
|
409
|
+
expect(url).toContain("interim_results=false");
|
|
410
|
+
expect(url).toContain("diarize=true");
|
|
411
|
+
expect(url).toContain("keyterm=Syrinx");
|
|
412
|
+
expect(url).toContain("smart_turn=0.8");
|
|
413
|
+
expect(url).toContain("smart_turn_timeout=1500");
|
|
414
|
+
expect(url).toContain("custom_flag=on");
|
|
415
|
+
expect(url).toContain("tag=a");
|
|
416
|
+
expect(url).toContain("tag=b");
|
|
417
|
+
});
|
|
418
|
+
});
|