@kuralle-syrinx/grok 4.1.0 → 4.3.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 CHANGED
@@ -1,9 +1,28 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/grok",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "xAI Grok adapters for Syrinx — STT, TTS, and realtime",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "grok",
12
+ "xai",
13
+ "realtime"
14
+ ],
6
15
  "license": "MIT",
16
+ "homepage": "https://github.com/kuralle/syrinx#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuralle/syrinx.git",
20
+ "directory": "packages/grok"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kuralle/syrinx/issues"
24
+ },
25
+ "type": "module",
7
26
  "main": "./src/index.ts",
8
27
  "types": "./src/index.ts",
9
28
  "exports": {
@@ -13,16 +32,17 @@
13
32
  "./realtime": "./src/from-grok-realtime.ts"
14
33
  },
15
34
  "dependencies": {
16
- "@kuralle-syrinx/core": "4.1.0",
17
- "@kuralle-syrinx/realtime": "4.1.0",
18
- "@kuralle-syrinx/ws": "4.1.0",
19
- "@kuralle-syrinx/tts-core": "4.1.0"
35
+ "@kuralle-syrinx/core": "4.3.0",
36
+ "@kuralle-syrinx/realtime": "4.3.0",
37
+ "@kuralle-syrinx/stt-core": "4.3.0",
38
+ "@kuralle-syrinx/ws": "4.3.0",
39
+ "@kuralle-syrinx/tts-core": "4.3.0"
20
40
  },
21
41
  "devDependencies": {
22
42
  "@types/ws": "^8.5.0",
23
43
  "typescript": "^5.7.0",
24
- "vitest": "^2.1.0",
25
- "ws": "^8.18.0"
44
+ "vitest": "^3.2.6",
45
+ "ws": "^8.21.0"
26
46
  },
27
47
  "scripts": {
28
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: { format: { type: "audio/pcm", rate: inputRateHz } },
66
- output: { format: { type: "audio/pcm", rate: outputRateHz }, voice },
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 { WebSocketConnection, type SocketFactory } from "@kuralle-syrinx/ws";
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
- 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);
28
+ class GrokSttWireProtocol implements SttWireProtocol {
29
+ private ready = false;
69
30
 
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
- );
31
+ isReady(): boolean {
32
+ return this.ready;
119
33
  }
120
34
 
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);
35
+ onConnectionLost(): void {
36
+ this.ready = false;
124
37
  }
125
38
 
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
- }
39
+ encodeFinalize(): SocketData[] {
40
+ return [AUDIO_DONE];
139
41
  }
140
42
 
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
- }
43
+ encodeClose(): SocketData[] {
44
+ return [AUDIO_DONE];
148
45
  }
149
46
 
150
- private handleProviderMessage(data: string): void {
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
- this.emitError(
156
- this.currentContextId,
157
- new Error(`Grok STT provider sent malformed JSON: ${err instanceof Error ? err.message : String(err)}`),
158
- );
159
- return;
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.transcriptReady = true;
166
- return;
66
+ this.ready = true;
67
+ return [];
167
68
  case "transcript.partial":
168
- this.handleTranscriptPartial(msg);
169
- return;
69
+ return this.decodePartial(msg);
170
70
  case "transcript.done":
171
- return;
71
+ return [];
172
72
  case "error":
173
- this.emitError(
174
- this.currentContextId,
175
- new Error(typeof msg["message"] === "string" ? msg["message"] : "Grok STT provider error"),
176
- );
177
- return;
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 handleTranscriptPartial(msg: Record<string, unknown>): void {
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
- 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
- };
94
+ // contextId is stamped by the session from the audio/turn context.
95
+ const contextId = "";
200
96
 
201
- if (isFinal) {
202
- this.bus?.push(Route.Main, {
203
- kind: "stt.result",
204
- contextId: this.currentContextId,
205
- timestampMs: Date.now(),
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
- 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
- }
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
- this.bus?.push(Route.Main, {
224
- kind: "stt.interim",
225
- contextId: this.currentContextId,
226
- timestampMs: Date.now(),
227
- text,
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
- 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);
183
+ async close(): Promise<void> {
184
+ await this.session?.dispose();
185
+ this.session = null;
243
186
  }
187
+ }
244
188
 
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;
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
- async function defaultSocketFactory(): Promise<SocketFactory> {
262
- const mod = await import("@kuralle-syrinx/ws/node");
263
- return mod.createNodeWsSocket;
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(_key: AttributionKey, text: string): SocketData[] {
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
- return key ? [{ type: "context_end", key }] : [];
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: "pcm",
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);