@kuralle-syrinx/grok 4.2.0 → 4.4.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 +8 -7
- package/src/from-grok-realtime.ts +38 -2
- package/src/stt.ts +154 -209
- package/src/tts.ts +44 -5
- package/src/edge-safety.test.ts +0 -146
- package/src/from-grok-realtime.test.ts +0 -279
- package/src/stt.test.ts +0 -244
- package/src/tts.test.ts +0 -160
- package/tsconfig.json +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/grok",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "xAI Grok adapters for Syrinx — STT, TTS, and realtime",
|
|
6
6
|
"keywords": [
|
|
@@ -32,16 +32,17 @@
|
|
|
32
32
|
"./realtime": "./src/from-grok-realtime.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@kuralle-syrinx/
|
|
36
|
-
"@kuralle-syrinx/
|
|
37
|
-
"@kuralle-syrinx/tts-core": "4.
|
|
38
|
-
"@kuralle-syrinx/
|
|
35
|
+
"@kuralle-syrinx/realtime": "4.4.0",
|
|
36
|
+
"@kuralle-syrinx/core": "4.4.0",
|
|
37
|
+
"@kuralle-syrinx/tts-core": "4.4.0",
|
|
38
|
+
"@kuralle-syrinx/stt-core": "4.4.0",
|
|
39
|
+
"@kuralle-syrinx/ws": "4.4.0"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@types/ws": "^8.5.0",
|
|
42
43
|
"typescript": "^5.7.0",
|
|
43
|
-
"vitest": "^2.
|
|
44
|
-
"ws": "^8.
|
|
44
|
+
"vitest": "^3.2.6",
|
|
45
|
+
"ws": "^8.21.0"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
|
47
48
|
"typecheck": "tsc --noEmit",
|
|
@@ -25,6 +25,19 @@ export interface GrokRealtimeOptions {
|
|
|
25
25
|
readonly instructions?: string;
|
|
26
26
|
readonly inputRateHz?: number;
|
|
27
27
|
readonly outputRateHz?: number;
|
|
28
|
+
/** Input audio format. Defaults to `{ type: "audio/pcm", rate: inputRateHz }`. */
|
|
29
|
+
readonly inputAudioFormat?: Record<string, unknown>;
|
|
30
|
+
/** Output audio format. Defaults to `{ type: "audio/pcm", rate: outputRateHz }`. */
|
|
31
|
+
readonly outputAudioFormat?: Record<string, unknown>;
|
|
32
|
+
readonly temperature?: number;
|
|
33
|
+
readonly modalities?: readonly string[];
|
|
34
|
+
readonly inputTranscription?: Record<string, unknown> | boolean;
|
|
35
|
+
readonly toolChoice?: string | Record<string, unknown>;
|
|
36
|
+
/**
|
|
37
|
+
* Merged last into `session.update.session` for provider-specific fields the
|
|
38
|
+
* adapter does not enumerate. Overrides same-key defaults when both set.
|
|
39
|
+
*/
|
|
40
|
+
readonly sessionConfig?: Record<string, unknown>;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
export function fromGrokRealtime(opts: GrokRealtimeOptions): RealtimeAdapter {
|
|
@@ -52,6 +65,14 @@ export function fromGrokRealtime(opts: GrokRealtimeOptions): RealtimeAdapter {
|
|
|
52
65
|
const turnDetection =
|
|
53
66
|
"turnDetection" in opts ? opts.turnDetection : { type: "server_vad" };
|
|
54
67
|
|
|
68
|
+
const inputAudio: Record<string, unknown> = {
|
|
69
|
+
format: opts.inputAudioFormat ?? { type: "audio/pcm", rate: inputRateHz },
|
|
70
|
+
};
|
|
71
|
+
if (opts.inputTranscription !== undefined) {
|
|
72
|
+
inputAudio["transcription"] =
|
|
73
|
+
opts.inputTranscription === true ? {} : opts.inputTranscription;
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
const session: Record<string, unknown> = {
|
|
56
77
|
voice,
|
|
57
78
|
turn_detection: turnDetection,
|
|
@@ -62,14 +83,29 @@ export function fromGrokRealtime(opts: GrokRealtimeOptions): RealtimeAdapter {
|
|
|
62
83
|
parameters: t.parameters,
|
|
63
84
|
})),
|
|
64
85
|
audio: {
|
|
65
|
-
input:
|
|
66
|
-
output: {
|
|
86
|
+
input: inputAudio,
|
|
87
|
+
output: {
|
|
88
|
+
format: opts.outputAudioFormat ?? { type: "audio/pcm", rate: outputRateHz },
|
|
89
|
+
voice,
|
|
90
|
+
},
|
|
67
91
|
},
|
|
68
92
|
};
|
|
69
93
|
|
|
70
94
|
if (opts.instructions !== undefined) {
|
|
71
95
|
session["instructions"] = opts.instructions;
|
|
72
96
|
}
|
|
97
|
+
if (opts.temperature !== undefined) {
|
|
98
|
+
session["temperature"] = opts.temperature;
|
|
99
|
+
}
|
|
100
|
+
if (opts.modalities !== undefined) {
|
|
101
|
+
session["output_modalities"] = opts.modalities;
|
|
102
|
+
}
|
|
103
|
+
if (opts.toolChoice !== undefined) {
|
|
104
|
+
session["tool_choice"] = opts.toolChoice;
|
|
105
|
+
}
|
|
106
|
+
if (opts.sessionConfig) {
|
|
107
|
+
Object.assign(session, opts.sessionConfig);
|
|
108
|
+
}
|
|
73
109
|
|
|
74
110
|
return session;
|
|
75
111
|
},
|
package/src/stt.ts
CHANGED
|
@@ -1,264 +1,209 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Grok (xAI) STT Plugin. The streaming lifecycle lives in @kuralle-syrinx/stt-core. This
|
|
4
|
+
// file is the Grok wire protocol: connect URL + query knobs, audio.done finalize, and
|
|
5
|
+
// decode of transcript.created / transcript.partial (is_final / duration / speech_final).
|
|
2
6
|
|
|
3
|
-
import type { PipelineBus } from "@kuralle-syrinx/core";
|
|
4
7
|
import {
|
|
5
|
-
Route,
|
|
6
|
-
type AudioFormat,
|
|
7
|
-
type PluginConfig,
|
|
8
|
-
type SttErrorPacket,
|
|
9
|
-
type VoicePlugin,
|
|
10
8
|
assertAudioFormat,
|
|
11
|
-
assertAudioPayload,
|
|
12
|
-
categorizeSttError,
|
|
13
|
-
isRecoverable,
|
|
14
9
|
optionalStringConfig,
|
|
15
10
|
readProviderRetryConfig,
|
|
16
11
|
requireStringConfig,
|
|
12
|
+
type AudioFormat,
|
|
13
|
+
type PipelineBus,
|
|
14
|
+
type PluginConfig,
|
|
15
|
+
type VoicePlugin,
|
|
17
16
|
} from "@kuralle-syrinx/core";
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
defaultNodeSocketFactory,
|
|
19
|
+
startStreamingSttSession,
|
|
20
|
+
type SttEvent,
|
|
21
|
+
type SttWireProtocol,
|
|
22
|
+
type StreamingSttSession,
|
|
23
|
+
} from "@kuralle-syrinx/stt-core";
|
|
24
|
+
import type { SocketData, SocketFactory } from "@kuralle-syrinx/ws";
|
|
19
25
|
|
|
20
26
|
const AUDIO_DONE = JSON.stringify({ type: "audio.done" });
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
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);
|
|
28
|
+
class GrokSttWireProtocol implements SttWireProtocol {
|
|
29
|
+
private ready = false;
|
|
69
30
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
);
|
|
31
|
+
isReady(): boolean {
|
|
32
|
+
return this.ready;
|
|
119
33
|
}
|
|
120
34
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
await this.sendAudio(pkt.audio, this.currentContextId);
|
|
35
|
+
onConnectionLost(): void {
|
|
36
|
+
this.ready = false;
|
|
124
37
|
}
|
|
125
38
|
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
}
|
|
39
|
+
encodeFinalize(): SocketData[] {
|
|
40
|
+
return [AUDIO_DONE];
|
|
139
41
|
}
|
|
140
42
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
}
|
|
43
|
+
encodeClose(): SocketData[] {
|
|
44
|
+
return [AUDIO_DONE];
|
|
148
45
|
}
|
|
149
46
|
|
|
150
|
-
|
|
47
|
+
decode(data: SocketData, _isBinary: boolean): readonly SttEvent[] {
|
|
48
|
+
if (typeof data !== "string") return [];
|
|
151
49
|
let msg: Record<string, unknown>;
|
|
152
50
|
try {
|
|
153
51
|
msg = JSON.parse(data) as Record<string, unknown>;
|
|
154
52
|
} catch (err) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
type: "error",
|
|
56
|
+
error: new Error(
|
|
57
|
+
`Grok STT provider sent malformed JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
160
61
|
}
|
|
161
62
|
|
|
162
63
|
const type = typeof msg["type"] === "string" ? msg["type"] : "";
|
|
163
64
|
switch (type) {
|
|
164
65
|
case "transcript.created":
|
|
165
|
-
this.
|
|
166
|
-
return;
|
|
66
|
+
this.ready = true;
|
|
67
|
+
return [];
|
|
167
68
|
case "transcript.partial":
|
|
168
|
-
this.
|
|
169
|
-
return;
|
|
69
|
+
return this.decodePartial(msg);
|
|
170
70
|
case "transcript.done":
|
|
171
|
-
return;
|
|
71
|
+
return [];
|
|
172
72
|
case "error":
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
type: "error",
|
|
76
|
+
error: new Error(
|
|
77
|
+
typeof msg["message"] === "string" ? msg["message"] : "Grok STT provider error",
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
];
|
|
178
81
|
default:
|
|
179
|
-
return;
|
|
82
|
+
return [];
|
|
180
83
|
}
|
|
181
84
|
}
|
|
182
85
|
|
|
183
|
-
private
|
|
86
|
+
private decodePartial(msg: Record<string, unknown>): readonly SttEvent[] {
|
|
184
87
|
const text = typeof msg["text"] === "string" ? msg["text"].trim() : "";
|
|
185
|
-
if (!text) return;
|
|
88
|
+
if (!text) return [];
|
|
186
89
|
|
|
187
90
|
const isFinal = msg["is_final"] === true;
|
|
188
91
|
const speechFinal = msg["speech_final"] === true;
|
|
189
92
|
const confidence =
|
|
190
93
|
typeof msg["end_of_turn_confidence"] === "number" ? msg["end_of_turn_confidence"] : 0;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
model: "stt",
|
|
194
|
-
region: "global",
|
|
195
|
-
speechFinal,
|
|
196
|
-
words: msg["words"],
|
|
197
|
-
start: msg["start"],
|
|
198
|
-
duration: msg["duration"],
|
|
199
|
-
};
|
|
94
|
+
// contextId is stamped by the session from the audio/turn context.
|
|
95
|
+
const contextId = "";
|
|
200
96
|
|
|
201
|
-
if (isFinal) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
97
|
+
if (!isFinal) {
|
|
98
|
+
return [{ type: "interim", contextId, text }];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
type: "final",
|
|
104
|
+
contextId,
|
|
206
105
|
text,
|
|
207
106
|
confidence,
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
107
|
+
speechFinal,
|
|
108
|
+
audioSeconds: typeof msg["duration"] === "number" ? msg["duration"] : undefined,
|
|
109
|
+
provider: {
|
|
110
|
+
speechFinal,
|
|
111
|
+
words: msg["words"],
|
|
112
|
+
start: msg["start"],
|
|
113
|
+
duration: msg["duration"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class GrokSTTPlugin implements VoicePlugin {
|
|
121
|
+
readonly endpointingCapability = {
|
|
122
|
+
owner: "provider_stt" as const,
|
|
123
|
+
disableConfig: {
|
|
124
|
+
emit_eos_on_final: false,
|
|
125
|
+
finalize_on_speech_final: false,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
constructor(private readonly socketFactory?: SocketFactory) {}
|
|
222
130
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
131
|
+
private session: StreamingSttSession | null = null;
|
|
132
|
+
|
|
133
|
+
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
134
|
+
const apiKey = requireStringConfig(config, "api_key");
|
|
135
|
+
const sampleRate = (config["sample_rate"] as number) ?? 16000;
|
|
136
|
+
const language = optionalStringConfig(config, "language") ?? "en";
|
|
137
|
+
const endpointUrl = optionalStringConfig(config, "endpoint_url") ?? "wss://api.x.ai/v1/stt";
|
|
138
|
+
const encoding = optionalStringConfig(config, "encoding") ?? "pcm";
|
|
139
|
+
const interimResults = (config["interim_results"] as boolean) ?? true;
|
|
140
|
+
const endpointing = (config["endpointing"] as number) ?? 10;
|
|
141
|
+
const smartTurn = typeof config["smart_turn"] === "number" ? config["smart_turn"] : undefined;
|
|
142
|
+
const smartTurnTimeoutMs =
|
|
143
|
+
typeof config["smart_turn_timeout"] === "number" ? config["smart_turn_timeout"] : undefined;
|
|
144
|
+
const diarize = (config["diarize"] as boolean) ?? false;
|
|
145
|
+
const keyterm = optionalStringConfig(config, "keyterm");
|
|
146
|
+
const emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
|
|
147
|
+
const queryParams = readPlainObject(config["query_params"]);
|
|
148
|
+
const audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: sampleRate, channels: 1 };
|
|
149
|
+
assertAudioFormat(audioFormat);
|
|
150
|
+
|
|
151
|
+
this.session = await startStreamingSttSession(bus, {
|
|
152
|
+
protocol: new GrokSttWireProtocol(),
|
|
153
|
+
provider: { name: "grok", model: "stt", region: "global" },
|
|
154
|
+
format: audioFormat,
|
|
155
|
+
language,
|
|
156
|
+
emitEosOnFinal,
|
|
157
|
+
url: () => {
|
|
158
|
+
const params = new URLSearchParams({
|
|
159
|
+
sample_rate: String(sampleRate),
|
|
160
|
+
encoding,
|
|
161
|
+
interim_results: String(interimResults),
|
|
162
|
+
language,
|
|
163
|
+
endpointing: String(endpointing),
|
|
164
|
+
});
|
|
165
|
+
if (smartTurn !== undefined) params.set("smart_turn", String(smartTurn));
|
|
166
|
+
if (smartTurnTimeoutMs !== undefined) {
|
|
167
|
+
params.set("smart_turn_timeout", String(smartTurnTimeoutMs));
|
|
168
|
+
}
|
|
169
|
+
if (diarize) params.set("diarize", "true");
|
|
170
|
+
if (keyterm) params.set("keyterm", keyterm);
|
|
171
|
+
applyQueryParams(params, queryParams);
|
|
172
|
+
const separator = endpointUrl.includes("?") ? "&" : "?";
|
|
173
|
+
return `${endpointUrl}${separator}${params.toString()}`;
|
|
174
|
+
},
|
|
175
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
176
|
+
socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
|
|
177
|
+
retry: readProviderRetryConfig(config),
|
|
178
|
+
replayBufferSize: (config["replay_buffer_size"] as number) ?? 64,
|
|
179
|
+
metricPrefix: "stt.grok",
|
|
228
180
|
});
|
|
229
181
|
}
|
|
230
182
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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);
|
|
183
|
+
async close(): Promise<void> {
|
|
184
|
+
await this.session?.dispose();
|
|
185
|
+
this.session = null;
|
|
243
186
|
}
|
|
187
|
+
}
|
|
244
188
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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;
|
|
189
|
+
function readPlainObject(value: unknown): Record<string, unknown> | undefined {
|
|
190
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
191
|
+
return value as Record<string, unknown>;
|
|
258
192
|
}
|
|
193
|
+
return undefined;
|
|
259
194
|
}
|
|
260
195
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
196
|
+
function applyQueryParams(params: URLSearchParams, extra: Record<string, unknown> | undefined): void {
|
|
197
|
+
if (!extra) return;
|
|
198
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
199
|
+
if (value === undefined || value === null) continue;
|
|
200
|
+
if (Array.isArray(value)) {
|
|
201
|
+
for (const item of value) {
|
|
202
|
+
if (item === undefined || item === null) continue;
|
|
203
|
+
params.append(key, String(item));
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
params.set(key, String(value));
|
|
208
|
+
}
|
|
264
209
|
}
|
package/src/tts.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Grok relies on the engine's streaming carry.
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
Route,
|
|
10
11
|
assertAudioFormat,
|
|
11
12
|
optionalStringConfig,
|
|
12
13
|
readProviderRetryConfig,
|
|
@@ -35,13 +36,17 @@ class GrokWireProtocol implements WireProtocol {
|
|
|
35
36
|
// to the current one. (Audio for an interrupted context is dropped by the engine's
|
|
36
37
|
// cancelled-key tracking, which subsumes Grok's old `clearedPending` race guard.)
|
|
37
38
|
private current: AttributionKey | null = null;
|
|
39
|
+
private readonly charactersByKey = new Map<AttributionKey, number>();
|
|
40
|
+
|
|
41
|
+
constructor(private readonly modelId: string) {}
|
|
38
42
|
|
|
39
43
|
attributionFor(contextId: string): { key: AttributionKey; contextId: string } {
|
|
40
44
|
this.current = attributionKey(contextId);
|
|
41
45
|
return { key: this.current, contextId };
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
encodeText(
|
|
48
|
+
encodeText(key: AttributionKey, text: string): SocketData[] {
|
|
49
|
+
this.charactersByKey.set(key, (this.charactersByKey.get(key) ?? 0) + text.length);
|
|
45
50
|
return [JSON.stringify({ type: "text.delta", delta: text })];
|
|
46
51
|
}
|
|
47
52
|
|
|
@@ -49,7 +54,8 @@ class GrokWireProtocol implements WireProtocol {
|
|
|
49
54
|
return [JSON.stringify({ type: "text.done" })];
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
encodeCancel(): SocketData[] {
|
|
57
|
+
encodeCancel(key: AttributionKey): SocketData[] {
|
|
58
|
+
this.charactersByKey.delete(key);
|
|
53
59
|
return [JSON.stringify({ type: "text.clear" })];
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -78,14 +84,38 @@ class GrokWireProtocol implements WireProtocol {
|
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
86
|
case "audio.done": {
|
|
87
|
+
const characters = key ? (this.charactersByKey.get(key) ?? 0) : 0;
|
|
88
|
+
if (key) this.charactersByKey.delete(key);
|
|
81
89
|
this.current = null;
|
|
82
|
-
|
|
90
|
+
if (!key) return [];
|
|
91
|
+
const events: WireEvent[] = [];
|
|
92
|
+
// Sideband before context_end so the engine still has the key→context mapping.
|
|
93
|
+
if (characters > 0) {
|
|
94
|
+
const modelId = this.modelId;
|
|
95
|
+
events.push({
|
|
96
|
+
type: "sideband",
|
|
97
|
+
key,
|
|
98
|
+
route: Route.Background,
|
|
99
|
+
build: (ctxId, timestampMs) => ({
|
|
100
|
+
kind: "usage.recorded",
|
|
101
|
+
contextId: ctxId,
|
|
102
|
+
timestampMs,
|
|
103
|
+
stage: "tts" as const,
|
|
104
|
+
provider: "grok",
|
|
105
|
+
model: modelId,
|
|
106
|
+
characters,
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
events.push({ type: "context_end", key });
|
|
111
|
+
return events;
|
|
83
112
|
}
|
|
84
113
|
case "audio.clear":
|
|
85
114
|
// Provider acknowledged a text.clear. Audio for the interrupted context is already
|
|
86
115
|
// dropped via the engine's cancelled tracking; nothing more to do.
|
|
87
116
|
return [];
|
|
88
117
|
case "error":
|
|
118
|
+
if (key) this.charactersByKey.delete(key);
|
|
89
119
|
return key ? [{ type: "error", key, error: grokProviderError(msg) }] : [];
|
|
90
120
|
default:
|
|
91
121
|
return [];
|
|
@@ -104,11 +134,13 @@ export class GrokTTSPlugin implements VoicePlugin {
|
|
|
104
134
|
const language = optionalStringConfig(config, "language") ?? "en";
|
|
105
135
|
const endpointUrl = optionalStringConfig(config, "endpoint_url") ?? "wss://api.x.ai/v1/tts";
|
|
106
136
|
const sampleRate = readPositiveInteger(config["sample_rate"], 16000);
|
|
137
|
+
const codec = optionalStringConfig(config, "codec") ?? "pcm";
|
|
138
|
+
const bitRate = readPositiveIntegerOrUndefined(config["bit_rate"]);
|
|
107
139
|
const audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: sampleRate, channels: 1 };
|
|
108
140
|
assertAudioFormat(audioFormat);
|
|
109
141
|
|
|
110
142
|
this.session = await startStreamingTtsSession(bus, {
|
|
111
|
-
protocol: new GrokWireProtocol(),
|
|
143
|
+
protocol: new GrokWireProtocol(voiceId),
|
|
112
144
|
provider: { name: "grok", model: voiceId, region: "global" },
|
|
113
145
|
format: audioFormat,
|
|
114
146
|
sampleRateHz: sampleRate,
|
|
@@ -116,9 +148,10 @@ export class GrokTTSPlugin implements VoicePlugin {
|
|
|
116
148
|
const params = new URLSearchParams({
|
|
117
149
|
language,
|
|
118
150
|
voice: voiceId,
|
|
119
|
-
codec
|
|
151
|
+
codec,
|
|
120
152
|
sample_rate: String(sampleRate),
|
|
121
153
|
});
|
|
154
|
+
if (bitRate !== undefined) params.set("bit_rate", String(bitRate));
|
|
122
155
|
const separator = endpointUrl.includes("?") ? "&" : "?";
|
|
123
156
|
return `${endpointUrl}${separator}${params.toString()}`;
|
|
124
157
|
},
|
|
@@ -152,6 +185,12 @@ function readPositiveInteger(value: unknown, fallback: number): number {
|
|
|
152
185
|
return integer > 0 ? integer : fallback;
|
|
153
186
|
}
|
|
154
187
|
|
|
188
|
+
function readPositiveIntegerOrUndefined(value: unknown): number | undefined {
|
|
189
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
190
|
+
const integer = Math.floor(value);
|
|
191
|
+
return integer > 0 ? integer : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
155
194
|
function readNonNegativeInteger(value: unknown, fallback: number): number {
|
|
156
195
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
157
196
|
const integer = Math.floor(value);
|