@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/LICENSE +22 -0
- package/README.md +116 -0
- package/package.json +30 -0
- package/src/edge-safety.test.ts +146 -0
- package/src/from-grok-realtime.test.ts +279 -0
- package/src/from-grok-realtime.ts +94 -0
- package/src/index.ts +5 -0
- package/src/stt.test.ts +244 -0
- package/src/stt.ts +264 -0
- package/src/tts.test.ts +160 -0
- package/src/tts.ts +319 -0
- package/tsconfig.json +21 -0
package/src/stt.test.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
} from "@kuralle-syrinx/core";
|
|
12
|
+
|
|
13
|
+
import { GrokSTTPlugin } from "./stt.js";
|
|
14
|
+
|
|
15
|
+
let servers: WebSocketServer[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await Promise.all(
|
|
19
|
+
servers.splice(0).map(
|
|
20
|
+
(server) =>
|
|
21
|
+
new Promise<void>((resolve) => {
|
|
22
|
+
for (const client of server.clients) client.terminate();
|
|
23
|
+
server.close(() => resolve());
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
async function createLocalServer(onConnection: (socket: WebSocket) => void): Promise<string> {
|
|
30
|
+
const server = await new Promise<WebSocketServer>((resolve) => {
|
|
31
|
+
let nextServer: WebSocketServer;
|
|
32
|
+
nextServer = new WebSocketServer({ port: 0 }, () => {
|
|
33
|
+
resolve(nextServer);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
servers.push(server);
|
|
37
|
+
server.on("connection", onConnection);
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP server address");
|
|
40
|
+
return `ws://127.0.0.1:${address.port}/stt`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function waitFor<T>(items: T[], count = 1): Promise<void> {
|
|
44
|
+
for (let i = 0; i < 50; i += 1) {
|
|
45
|
+
if (items.length >= count) return;
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("GrokSTTPlugin", () => {
|
|
51
|
+
it("waits for transcript.created before sending binary audio", async () => {
|
|
52
|
+
const binaryFrames: Uint8Array[] = [];
|
|
53
|
+
const controlMessages: string[] = [];
|
|
54
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
55
|
+
socket.on("message", (data, isBinary) => {
|
|
56
|
+
if (isBinary) {
|
|
57
|
+
binaryFrames.push(new Uint8Array(data as Buffer));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
controlMessages.push(data.toString());
|
|
61
|
+
});
|
|
62
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const bus = new PipelineBusImpl();
|
|
66
|
+
const started = bus.start();
|
|
67
|
+
const plugin = new GrokSTTPlugin();
|
|
68
|
+
|
|
69
|
+
await plugin.initialize(bus, {
|
|
70
|
+
api_key: "test",
|
|
71
|
+
endpoint_url: endpointUrl,
|
|
72
|
+
sample_rate: 16000,
|
|
73
|
+
});
|
|
74
|
+
bus.push(Route.Main, {
|
|
75
|
+
kind: "stt.audio",
|
|
76
|
+
contextId: "turn-1",
|
|
77
|
+
timestampMs: Date.now(),
|
|
78
|
+
audio: new Uint8Array(640),
|
|
79
|
+
});
|
|
80
|
+
await waitFor(binaryFrames);
|
|
81
|
+
|
|
82
|
+
expect(binaryFrames).toEqual([new Uint8Array(640)]);
|
|
83
|
+
expect(controlMessages).toEqual([]);
|
|
84
|
+
|
|
85
|
+
await plugin.close();
|
|
86
|
+
await waitFor(controlMessages);
|
|
87
|
+
expect(controlMessages).toContain(JSON.stringify({ type: "audio.done" }));
|
|
88
|
+
bus.stop();
|
|
89
|
+
await started;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("maps transcript.partial to stt.interim and stt.result", async () => {
|
|
93
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
94
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
95
|
+
socket.on("message", (data, isBinary) => {
|
|
96
|
+
if (!isBinary) return;
|
|
97
|
+
socket.send(JSON.stringify({
|
|
98
|
+
type: "transcript.partial",
|
|
99
|
+
text: "hello",
|
|
100
|
+
is_final: false,
|
|
101
|
+
}));
|
|
102
|
+
socket.send(JSON.stringify({
|
|
103
|
+
type: "transcript.partial",
|
|
104
|
+
text: "hello world",
|
|
105
|
+
is_final: true,
|
|
106
|
+
speech_final: false,
|
|
107
|
+
words: [{ word: "hello" }, { word: "world" }],
|
|
108
|
+
start: 0,
|
|
109
|
+
duration: 1.2,
|
|
110
|
+
end_of_turn_confidence: 0.91,
|
|
111
|
+
}));
|
|
112
|
+
socket.send(JSON.stringify({
|
|
113
|
+
type: "transcript.partial",
|
|
114
|
+
text: "hello world",
|
|
115
|
+
is_final: true,
|
|
116
|
+
speech_final: true,
|
|
117
|
+
end_of_turn_confidence: 0.95,
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const bus = new PipelineBusImpl();
|
|
123
|
+
const started = bus.start();
|
|
124
|
+
const plugin = new GrokSTTPlugin();
|
|
125
|
+
const interims: Array<{ text: string }> = [];
|
|
126
|
+
const finals: SttResultPacket[] = [];
|
|
127
|
+
const turnCompletes: Array<{ kind: string }> = [];
|
|
128
|
+
bus.on("stt.interim", (pkt) => {
|
|
129
|
+
interims.push({ text: (pkt as SttInterimPacket).text });
|
|
130
|
+
});
|
|
131
|
+
bus.on("stt.result", (pkt) => {
|
|
132
|
+
finals.push(pkt as SttResultPacket);
|
|
133
|
+
});
|
|
134
|
+
bus.on("eos.turn_complete", (pkt) => {
|
|
135
|
+
turnCompletes.push(pkt as { kind: string });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await plugin.initialize(bus, {
|
|
139
|
+
api_key: "test",
|
|
140
|
+
endpoint_url: endpointUrl,
|
|
141
|
+
sample_rate: 16000,
|
|
142
|
+
});
|
|
143
|
+
bus.push(Route.Main, {
|
|
144
|
+
kind: "user.audio_received",
|
|
145
|
+
contextId: "turn-2",
|
|
146
|
+
timestampMs: Date.now(),
|
|
147
|
+
audio: new Uint8Array(320),
|
|
148
|
+
});
|
|
149
|
+
await waitFor(finals, 2);
|
|
150
|
+
await waitFor(turnCompletes);
|
|
151
|
+
|
|
152
|
+
expect(interims).toEqual([{ text: "hello" }]);
|
|
153
|
+
expect(finals[0]).toEqual(
|
|
154
|
+
expect.objectContaining({
|
|
155
|
+
kind: "stt.result",
|
|
156
|
+
contextId: "turn-2",
|
|
157
|
+
text: "hello world",
|
|
158
|
+
confidence: 0.91,
|
|
159
|
+
provider: expect.objectContaining({
|
|
160
|
+
name: "grok",
|
|
161
|
+
speechFinal: false,
|
|
162
|
+
words: [{ word: "hello" }, { word: "world" }],
|
|
163
|
+
start: 0,
|
|
164
|
+
duration: 1.2,
|
|
165
|
+
}),
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
expect(finals[1]).toEqual(
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
text: "hello world",
|
|
171
|
+
confidence: 0.95,
|
|
172
|
+
provider: expect.objectContaining({ speechFinal: true }),
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
expect(turnCompletes).toHaveLength(1);
|
|
176
|
+
|
|
177
|
+
await plugin.close();
|
|
178
|
+
bus.stop();
|
|
179
|
+
await started;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("sends audio.done on stt.finalize", async () => {
|
|
183
|
+
const controlMessages: string[] = [];
|
|
184
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
185
|
+
socket.send(JSON.stringify({ type: "transcript.created" }));
|
|
186
|
+
socket.on("message", (data, isBinary) => {
|
|
187
|
+
if (!isBinary) controlMessages.push(data.toString());
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const bus = new PipelineBusImpl();
|
|
192
|
+
const started = bus.start();
|
|
193
|
+
const plugin = new GrokSTTPlugin();
|
|
194
|
+
|
|
195
|
+
await plugin.initialize(bus, {
|
|
196
|
+
api_key: "test",
|
|
197
|
+
endpoint_url: endpointUrl,
|
|
198
|
+
sample_rate: 16000,
|
|
199
|
+
});
|
|
200
|
+
bus.push(Route.Main, {
|
|
201
|
+
kind: "stt.finalize",
|
|
202
|
+
contextId: "turn-3",
|
|
203
|
+
timestampMs: Date.now(),
|
|
204
|
+
});
|
|
205
|
+
await waitFor(controlMessages);
|
|
206
|
+
|
|
207
|
+
expect(JSON.parse(controlMessages[0]!)).toEqual({ type: "audio.done" });
|
|
208
|
+
|
|
209
|
+
await plugin.close();
|
|
210
|
+
bus.stop();
|
|
211
|
+
await started;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("maps provider error frames to stt.error", async () => {
|
|
215
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
216
|
+
socket.send(JSON.stringify({ type: "error", message: "bad audio format" }));
|
|
217
|
+
});
|
|
218
|
+
const bus = new PipelineBusImpl();
|
|
219
|
+
const started = bus.start();
|
|
220
|
+
const plugin = new GrokSTTPlugin();
|
|
221
|
+
const errors: SttErrorPacket[] = [];
|
|
222
|
+
bus.on("stt.error", (pkt) => {
|
|
223
|
+
errors.push(pkt as SttErrorPacket);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await plugin.initialize(bus, {
|
|
227
|
+
api_key: "test",
|
|
228
|
+
endpoint_url: endpointUrl,
|
|
229
|
+
sample_rate: 16000,
|
|
230
|
+
});
|
|
231
|
+
await waitFor(errors);
|
|
232
|
+
|
|
233
|
+
expect(errors[0]).toEqual(
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
kind: "stt.error",
|
|
236
|
+
cause: expect.objectContaining({ message: "bad audio format" }),
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await plugin.close();
|
|
241
|
+
bus.stop();
|
|
242
|
+
await started;
|
|
243
|
+
});
|
|
244
|
+
});
|
package/src/stt.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
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 SttErrorPacket,
|
|
9
|
+
type VoicePlugin,
|
|
10
|
+
assertAudioFormat,
|
|
11
|
+
assertAudioPayload,
|
|
12
|
+
categorizeSttError,
|
|
13
|
+
isRecoverable,
|
|
14
|
+
optionalStringConfig,
|
|
15
|
+
readProviderRetryConfig,
|
|
16
|
+
requireStringConfig,
|
|
17
|
+
} from "@kuralle-syrinx/core";
|
|
18
|
+
import { WebSocketConnection, type SocketFactory } from "@kuralle-syrinx/ws";
|
|
19
|
+
|
|
20
|
+
const AUDIO_DONE = JSON.stringify({ type: "audio.done" });
|
|
21
|
+
|
|
22
|
+
export class GrokSTTPlugin implements VoicePlugin {
|
|
23
|
+
readonly endpointingCapability = {
|
|
24
|
+
owner: "provider_stt" as const,
|
|
25
|
+
disableConfig: {
|
|
26
|
+
emit_eos_on_final: false,
|
|
27
|
+
finalize_on_speech_final: false,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
constructor(private readonly socketFactory?: SocketFactory) {}
|
|
32
|
+
|
|
33
|
+
private bus: PipelineBus | null = null;
|
|
34
|
+
private conn: WebSocketConnection | null = null;
|
|
35
|
+
private apiKey = "";
|
|
36
|
+
private sampleRate = 16000;
|
|
37
|
+
private language = "en";
|
|
38
|
+
private endpointUrl = "wss://api.x.ai/v1/stt";
|
|
39
|
+
private encoding = "pcm";
|
|
40
|
+
private interimResults = true;
|
|
41
|
+
private endpointing = 10;
|
|
42
|
+
private smartTurn: number | undefined;
|
|
43
|
+
private smartTurnTimeoutMs: number | undefined;
|
|
44
|
+
private diarize = false;
|
|
45
|
+
private keyterm: string | undefined;
|
|
46
|
+
private emitEosOnFinal = true;
|
|
47
|
+
private audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 16000, channels: 1 };
|
|
48
|
+
private currentContextId = "";
|
|
49
|
+
private transcriptReady = false;
|
|
50
|
+
private disposers: Array<() => void> = [];
|
|
51
|
+
|
|
52
|
+
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
53
|
+
this.bus = bus;
|
|
54
|
+
this.apiKey = requireStringConfig(config, "api_key");
|
|
55
|
+
this.sampleRate = (config["sample_rate"] as number) ?? 16000;
|
|
56
|
+
this.language = optionalStringConfig(config, "language") ?? "en";
|
|
57
|
+
this.endpointUrl = optionalStringConfig(config, "endpoint_url") ?? this.endpointUrl;
|
|
58
|
+
this.encoding = optionalStringConfig(config, "encoding") ?? this.encoding;
|
|
59
|
+
this.interimResults = (config["interim_results"] as boolean) ?? true;
|
|
60
|
+
this.endpointing = (config["endpointing"] as number) ?? 10;
|
|
61
|
+
this.smartTurn = typeof config["smart_turn"] === "number" ? config["smart_turn"] : undefined;
|
|
62
|
+
this.smartTurnTimeoutMs =
|
|
63
|
+
typeof config["smart_turn_timeout"] === "number" ? config["smart_turn_timeout"] : undefined;
|
|
64
|
+
this.diarize = (config["diarize"] as boolean) ?? false;
|
|
65
|
+
this.keyterm = optionalStringConfig(config, "keyterm");
|
|
66
|
+
this.emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
|
|
67
|
+
this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
|
|
68
|
+
assertAudioFormat(this.audioFormat);
|
|
69
|
+
|
|
70
|
+
this.conn = new WebSocketConnection({
|
|
71
|
+
url: () => {
|
|
72
|
+
const params = new URLSearchParams({
|
|
73
|
+
sample_rate: String(this.sampleRate),
|
|
74
|
+
encoding: this.encoding,
|
|
75
|
+
interim_results: String(this.interimResults),
|
|
76
|
+
language: this.language,
|
|
77
|
+
endpointing: String(this.endpointing),
|
|
78
|
+
});
|
|
79
|
+
if (this.smartTurn !== undefined) params.set("smart_turn", String(this.smartTurn));
|
|
80
|
+
if (this.smartTurnTimeoutMs !== undefined) {
|
|
81
|
+
params.set("smart_turn_timeout", String(this.smartTurnTimeoutMs));
|
|
82
|
+
}
|
|
83
|
+
if (this.diarize) params.set("diarize", "true");
|
|
84
|
+
if (this.keyterm) params.set("keyterm", this.keyterm);
|
|
85
|
+
const separator = this.endpointUrl.includes("?") ? "&" : "?";
|
|
86
|
+
return `${this.endpointUrl}${separator}${params.toString()}`;
|
|
87
|
+
},
|
|
88
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
89
|
+
socketFactory: this.socketFactory ?? (await defaultSocketFactory()),
|
|
90
|
+
retry: readProviderRetryConfig(config),
|
|
91
|
+
replayBufferSize: (config["replay_buffer_size"] as number) ?? 64,
|
|
92
|
+
onMessage: (data) => {
|
|
93
|
+
if (typeof data === "string") this.handleProviderMessage(data);
|
|
94
|
+
},
|
|
95
|
+
onConnectionLost: (err) => {
|
|
96
|
+
this.transcriptReady = false;
|
|
97
|
+
this.emitError(this.currentContextId, err);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
await this.conn.connect();
|
|
101
|
+
|
|
102
|
+
this.disposers.push(
|
|
103
|
+
bus.on("stt.audio", (pkt: unknown) => {
|
|
104
|
+
void this.handleAudioPacket(pkt as { audio: Uint8Array; contextId?: string });
|
|
105
|
+
}),
|
|
106
|
+
bus.on("user.audio_received", (pkt: unknown) => {
|
|
107
|
+
void this.handleAudioPacket(pkt as { audio: Uint8Array; contextId?: string });
|
|
108
|
+
}),
|
|
109
|
+
bus.on("turn.change", (pkt: unknown) => {
|
|
110
|
+
this.currentContextId = (pkt as { contextId: string }).contextId;
|
|
111
|
+
}),
|
|
112
|
+
bus.on("interrupt.stt", () => {
|
|
113
|
+
this.currentContextId = "";
|
|
114
|
+
}),
|
|
115
|
+
bus.on("stt.finalize", () => {
|
|
116
|
+
void this.sendAudioDone();
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async handleAudioPacket(pkt: { audio: Uint8Array; contextId?: string }): Promise<void> {
|
|
122
|
+
if (pkt.contextId) this.currentContextId = pkt.contextId;
|
|
123
|
+
await this.sendAudio(pkt.audio, this.currentContextId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async sendAudio(audio: Uint8Array, contextId = this.currentContextId): Promise<boolean> {
|
|
127
|
+
if (audio.byteLength === 0) return true;
|
|
128
|
+
try {
|
|
129
|
+
assertAudioPayload(this.audioFormat, audio);
|
|
130
|
+
if (!this.conn) throw new Error("Grok STT is not connected");
|
|
131
|
+
await this.conn.ensureReady();
|
|
132
|
+
if (!this.transcriptReady) return false;
|
|
133
|
+
this.conn.send(audio);
|
|
134
|
+
return true;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async sendAudioDone(): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
await this.conn?.ensureReady();
|
|
144
|
+
if (this.conn?.isReady) this.conn.send(AUDIO_DONE);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
this.emitError(this.currentContextId, err instanceof Error ? err : new Error(String(err)));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private handleProviderMessage(data: string): void {
|
|
151
|
+
let msg: Record<string, unknown>;
|
|
152
|
+
try {
|
|
153
|
+
msg = JSON.parse(data) as Record<string, unknown>;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
this.emitError(
|
|
156
|
+
this.currentContextId,
|
|
157
|
+
new Error(`Grok STT provider sent malformed JSON: ${err instanceof Error ? err.message : String(err)}`),
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const type = typeof msg["type"] === "string" ? msg["type"] : "";
|
|
163
|
+
switch (type) {
|
|
164
|
+
case "transcript.created":
|
|
165
|
+
this.transcriptReady = true;
|
|
166
|
+
return;
|
|
167
|
+
case "transcript.partial":
|
|
168
|
+
this.handleTranscriptPartial(msg);
|
|
169
|
+
return;
|
|
170
|
+
case "transcript.done":
|
|
171
|
+
return;
|
|
172
|
+
case "error":
|
|
173
|
+
this.emitError(
|
|
174
|
+
this.currentContextId,
|
|
175
|
+
new Error(typeof msg["message"] === "string" ? msg["message"] : "Grok STT provider error"),
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
default:
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private handleTranscriptPartial(msg: Record<string, unknown>): void {
|
|
184
|
+
const text = typeof msg["text"] === "string" ? msg["text"].trim() : "";
|
|
185
|
+
if (!text) return;
|
|
186
|
+
|
|
187
|
+
const isFinal = msg["is_final"] === true;
|
|
188
|
+
const speechFinal = msg["speech_final"] === true;
|
|
189
|
+
const confidence =
|
|
190
|
+
typeof msg["end_of_turn_confidence"] === "number" ? msg["end_of_turn_confidence"] : 0;
|
|
191
|
+
const provider: Record<string, unknown> = {
|
|
192
|
+
name: "grok",
|
|
193
|
+
model: "stt",
|
|
194
|
+
region: "global",
|
|
195
|
+
speechFinal,
|
|
196
|
+
words: msg["words"],
|
|
197
|
+
start: msg["start"],
|
|
198
|
+
duration: msg["duration"],
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (isFinal) {
|
|
202
|
+
this.bus?.push(Route.Main, {
|
|
203
|
+
kind: "stt.result",
|
|
204
|
+
contextId: this.currentContextId,
|
|
205
|
+
timestampMs: Date.now(),
|
|
206
|
+
text,
|
|
207
|
+
confidence,
|
|
208
|
+
language: this.language,
|
|
209
|
+
provider,
|
|
210
|
+
});
|
|
211
|
+
if (this.emitEosOnFinal && speechFinal) {
|
|
212
|
+
this.bus?.push(Route.Main, {
|
|
213
|
+
kind: "eos.turn_complete",
|
|
214
|
+
contextId: this.currentContextId,
|
|
215
|
+
timestampMs: Date.now(),
|
|
216
|
+
text,
|
|
217
|
+
transcripts: [],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.bus?.push(Route.Main, {
|
|
224
|
+
kind: "stt.interim",
|
|
225
|
+
contextId: this.currentContextId,
|
|
226
|
+
timestampMs: Date.now(),
|
|
227
|
+
text,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private emitError(contextId: string, err: Error): void {
|
|
232
|
+
const category = categorizeSttError(err);
|
|
233
|
+
const packet: SttErrorPacket = {
|
|
234
|
+
kind: "stt.error",
|
|
235
|
+
contextId,
|
|
236
|
+
timestampMs: Date.now(),
|
|
237
|
+
component: "stt",
|
|
238
|
+
category,
|
|
239
|
+
cause: err,
|
|
240
|
+
isRecoverable: isRecoverable(category),
|
|
241
|
+
};
|
|
242
|
+
this.bus?.push(Route.Critical, packet);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async close(): Promise<void> {
|
|
246
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
247
|
+
if (this.conn?.isReady) {
|
|
248
|
+
try {
|
|
249
|
+
this.conn.send(AUDIO_DONE);
|
|
250
|
+
} catch {
|
|
251
|
+
// best effort
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
await this.conn?.close();
|
|
255
|
+
this.conn = null;
|
|
256
|
+
this.bus = null;
|
|
257
|
+
this.transcriptReady = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function defaultSocketFactory(): Promise<SocketFactory> {
|
|
262
|
+
const mod = await import("@kuralle-syrinx/ws/node");
|
|
263
|
+
return mod.createNodeWsSocket;
|
|
264
|
+
}
|
package/src/tts.test.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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 TextToSpeechAudioPacket,
|
|
9
|
+
type TextToSpeechEndPacket,
|
|
10
|
+
} from "@kuralle-syrinx/core";
|
|
11
|
+
|
|
12
|
+
import { bytesToBase64 } from "@kuralle-syrinx/realtime";
|
|
13
|
+
import { GrokTTSPlugin } from "./tts.js";
|
|
14
|
+
|
|
15
|
+
let servers: WebSocketServer[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await Promise.all(
|
|
19
|
+
servers.splice(0).map(
|
|
20
|
+
(server) =>
|
|
21
|
+
new Promise<void>((resolve) => {
|
|
22
|
+
for (const client of server.clients) client.terminate();
|
|
23
|
+
server.close(() => resolve());
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
async function createLocalServer(
|
|
30
|
+
onConnection: (socket: WebSocket, requestUrl: string, authHeader: string) => void,
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
const server = await new Promise<WebSocketServer>((resolve) => {
|
|
33
|
+
let next: WebSocketServer;
|
|
34
|
+
next = new WebSocketServer({ port: 0 }, () => resolve(next));
|
|
35
|
+
});
|
|
36
|
+
servers.push(server);
|
|
37
|
+
server.on("connection", (socket, request) => {
|
|
38
|
+
const header = request.headers["authorization"];
|
|
39
|
+
onConnection(
|
|
40
|
+
socket,
|
|
41
|
+
request.url ?? "",
|
|
42
|
+
Array.isArray(header) ? header[0] ?? "" : header ?? "",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
const address = server.address();
|
|
46
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP server address");
|
|
47
|
+
return `ws://127.0.0.1:${address.port}/v1/tts`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
51
|
+
const startedAt = Date.now();
|
|
52
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
53
|
+
if (predicate()) return;
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
55
|
+
}
|
|
56
|
+
throw new Error("Timed out waiting for Grok TTS test condition");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("GrokTTSPlugin", () => {
|
|
60
|
+
it("streams text.delta and maps audio.delta to tts.audio", async () => {
|
|
61
|
+
const received: Array<Record<string, unknown>> = [];
|
|
62
|
+
const endpointUrl = await createLocalServer((socket, requestUrl, authHeader) => {
|
|
63
|
+
expect(requestUrl).toContain("voice=eve");
|
|
64
|
+
expect(requestUrl).toContain("codec=pcm");
|
|
65
|
+
expect(requestUrl).toContain("sample_rate=16000");
|
|
66
|
+
expect(authHeader).toBe("Bearer test-xai-key");
|
|
67
|
+
socket.on("message", (data) => {
|
|
68
|
+
const msg = JSON.parse(data.toString()) as Record<string, unknown>;
|
|
69
|
+
received.push(msg);
|
|
70
|
+
if (msg["type"] === "text.delta") {
|
|
71
|
+
socket.send(JSON.stringify({
|
|
72
|
+
type: "audio.delta",
|
|
73
|
+
delta: bytesToBase64(new Uint8Array([1, 2, 3, 4])),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
if (msg["type"] === "text.done") {
|
|
77
|
+
socket.send(JSON.stringify({ type: "audio.done" }));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const bus = new PipelineBusImpl();
|
|
83
|
+
const started = bus.start();
|
|
84
|
+
const plugin = new GrokTTSPlugin();
|
|
85
|
+
const audio: TextToSpeechAudioPacket[] = [];
|
|
86
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
87
|
+
bus.on("tts.audio", (pkt) => {
|
|
88
|
+
audio.push(pkt as TextToSpeechAudioPacket);
|
|
89
|
+
});
|
|
90
|
+
bus.on("tts.end", (pkt) => {
|
|
91
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await plugin.initialize(bus, {
|
|
95
|
+
api_key: "test-xai-key",
|
|
96
|
+
endpoint_url: endpointUrl,
|
|
97
|
+
voice_id: "eve",
|
|
98
|
+
sample_rate: 16000,
|
|
99
|
+
});
|
|
100
|
+
bus.push(Route.Main, {
|
|
101
|
+
kind: "tts.text",
|
|
102
|
+
contextId: "turn-1",
|
|
103
|
+
timestampMs: Date.now(),
|
|
104
|
+
text: "Hello there.",
|
|
105
|
+
});
|
|
106
|
+
bus.push(Route.Main, { kind: "tts.done", contextId: "turn-1", timestampMs: Date.now() });
|
|
107
|
+
await waitForCondition(() => ends.length >= 1);
|
|
108
|
+
|
|
109
|
+
expect(received).toEqual([
|
|
110
|
+
expect.objectContaining({ type: "text.delta", delta: "Hello there." }),
|
|
111
|
+
expect.objectContaining({ type: "text.done" }),
|
|
112
|
+
]);
|
|
113
|
+
expect(audio).toEqual([
|
|
114
|
+
expect.objectContaining({
|
|
115
|
+
contextId: "turn-1",
|
|
116
|
+
sampleRateHz: 16000,
|
|
117
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
118
|
+
provider: expect.objectContaining({ name: "grok", model: "eve" }),
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
expect(ends).toEqual([expect.objectContaining({ contextId: "turn-1" })]);
|
|
122
|
+
|
|
123
|
+
await plugin.close();
|
|
124
|
+
bus.stop();
|
|
125
|
+
await started;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("sends text.clear on interrupt", async () => {
|
|
129
|
+
const received: Array<Record<string, unknown>> = [];
|
|
130
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
131
|
+
socket.on("message", (data) => {
|
|
132
|
+
received.push(JSON.parse(data.toString()) as Record<string, unknown>);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const bus = new PipelineBusImpl();
|
|
137
|
+
const started = bus.start();
|
|
138
|
+
const plugin = new GrokTTSPlugin();
|
|
139
|
+
|
|
140
|
+
await plugin.initialize(bus, { api_key: "test-xai-key", endpoint_url: endpointUrl });
|
|
141
|
+
bus.push(Route.Main, {
|
|
142
|
+
kind: "tts.text",
|
|
143
|
+
contextId: "turn-x",
|
|
144
|
+
timestampMs: Date.now(),
|
|
145
|
+
text: "Interrupt me.",
|
|
146
|
+
});
|
|
147
|
+
await waitForCondition(() => received.some((m) => m["type"] === "text.delta"));
|
|
148
|
+
bus.push(Route.Critical, { kind: "interrupt.tts", contextId: "turn-x", timestampMs: Date.now() });
|
|
149
|
+
await waitForCondition(() => received.some((m) => m["type"] === "text.clear"));
|
|
150
|
+
|
|
151
|
+
expect(received).toEqual([
|
|
152
|
+
expect.objectContaining({ type: "text.delta", delta: "Interrupt me." }),
|
|
153
|
+
expect.objectContaining({ type: "text.clear" }),
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
await plugin.close();
|
|
157
|
+
bus.stop();
|
|
158
|
+
await started;
|
|
159
|
+
});
|
|
160
|
+
});
|