@kuralle-syrinx/gemini 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.
@@ -0,0 +1,31 @@
1
+ import type { PipelineBus } from "@kuralle-syrinx/core";
2
+ import { type VoicePlugin, type PluginConfig } from "@kuralle-syrinx/core";
3
+ export type GeminiVoice = "Kore" | "Puck" | "Charon" | "Fenrir" | "Leda" | "Aoede" | "Zephyr" | "Orus";
4
+ export type GeminiTTSModel = "gemini-3.1-flash-tts-preview" | "gemini-2.5-flash-preview-tts" | "gemini-2.5-pro-preview-tts";
5
+ export declare class GeminiTTSPlugin implements VoicePlugin {
6
+ private bus;
7
+ private apiKey;
8
+ private model;
9
+ private voiceName;
10
+ private instruction;
11
+ private languageCode;
12
+ private timeoutMs;
13
+ private sampleRate;
14
+ private generationConfig;
15
+ private readonly abortControllers;
16
+ private textByContextId;
17
+ /** Synthesized character counts per contextId — billed on successful tts.end. */
18
+ private charactersByContextId;
19
+ private retryConfig;
20
+ private disposers;
21
+ private audioFormat;
22
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
23
+ private synthesize;
24
+ private synthesizeWithRetry;
25
+ private emitEnd;
26
+ private synthesizeOnce;
27
+ /** Flush/cancel all in-flight synthesis (called on interrupt/shutdown). */
28
+ flush(): void;
29
+ close(): Promise<void>;
30
+ }
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EAWlB,MAAM,sBAAsB,CAAC;AAM9B,MAAM,MAAM,WAAW,GACnB,MAAM,GACN,MAAM,GACN,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,OAAO,GACP,QAAQ,GACR,MAAM,CAAC;AAEX,MAAM,MAAM,cAAc,GACtB,8BAA8B,GAC9B,8BAA8B,GAC9B,4BAA4B,CAAC;AAUjC,qBAAa,eAAgB,YAAW,WAAW;IACjD,OAAO,CAAC,GAAG,CAA4B;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,KAAK,CAAyB;IACtC,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,SAAS,CAAU;IAC3B,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,gBAAgB,CAAsC;IAI9D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAsC;IACvE,OAAO,CAAC,eAAe,CAA6B;IACpD,iFAAiF;IACjF,OAAO,CAAC,qBAAqB,CAA6B;IAC1D,OAAO,CAAC,WAAW,CAAoC;IACvD,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,WAAW,CAIjB;IAEI,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;YAmDzD,UAAU;YAiBV,mBAAmB;IA+CjC,OAAO,CAAC,OAAO;YAqBD,cAAc;IAwD5B,2EAA2E;IAC3E,KAAK,IAAI,IAAI;IAMP,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAO7B"}
package/dist/index.js ADDED
@@ -0,0 +1,266 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Gemini TTS Plugin
4
+ //
5
+ // Uses Google Gemini's multimodal API (`responseModalities: ['AUDIO']`) to
6
+ // synthesize speech. Non-streaming (chunked) — sends full text, receives
7
+ // complete audio. Modeled after LiveKit's google.gemini.TTS implementation.
8
+ //
9
+ // Reference: LiveKit agents-js plugins/google/src/beta/gemini_tts.ts
10
+ // Reference: Rapida transformer/google/tts.go (GCP TTS API, not Gemini)
11
+ import { Route, assertAudioFormat, assertAudioPayload, requireStringConfig, optionalStringConfig, categorizeTtsError, isRecoverable, readRetryConfig, waitForRetryDelay, } from "@kuralle-syrinx/core";
12
+ const DEFAULT_MODEL = "gemini-3.1-flash-tts-preview";
13
+ const DEFAULT_VOICE = "Kore";
14
+ const DEFAULT_SAMPLE_RATE = 24000;
15
+ // =============================================================================
16
+ // Plugin
17
+ // =============================================================================
18
+ export class GeminiTTSPlugin {
19
+ bus = null;
20
+ apiKey = "";
21
+ model = DEFAULT_MODEL;
22
+ voiceName = DEFAULT_VOICE;
23
+ instruction = "";
24
+ languageCode;
25
+ timeoutMs = 45_000;
26
+ sampleRate = DEFAULT_SAMPLE_RATE;
27
+ generationConfig;
28
+ // One controller per in-flight turn. A single shared field let a second turn's
29
+ // synthesize() overwrite the first's controller, so a barge-in on turn N aborted
30
+ // only turn N+1 and turn N's stale audio could still stream after the interrupt.
31
+ abortControllers = new Map();
32
+ textByContextId = new Map();
33
+ /** Synthesized character counts per contextId — billed on successful tts.end. */
34
+ charactersByContextId = new Map();
35
+ retryConfig = readRetryConfig({});
36
+ disposers = [];
37
+ audioFormat = {
38
+ encoding: "pcm_s16le",
39
+ sampleRateHz: DEFAULT_SAMPLE_RATE,
40
+ channels: 1,
41
+ };
42
+ async initialize(bus, config) {
43
+ this.bus = bus;
44
+ this.apiKey = requireStringConfig(config, "api_key");
45
+ this.model = optionalStringConfig(config, "model") ?? DEFAULT_MODEL;
46
+ this.voiceName = optionalStringConfig(config, "voice_name") ?? DEFAULT_VOICE;
47
+ this.instruction = optionalStringConfig(config, "instruction") ?? "";
48
+ this.languageCode =
49
+ optionalStringConfig(config, "language_code") ?? optionalStringConfig(config, "language");
50
+ this.timeoutMs = readPositiveInteger(config["timeout_ms"], 45_000);
51
+ this.sampleRate = readPositiveInteger(config["sample_rate"], DEFAULT_SAMPLE_RATE);
52
+ this.generationConfig = readPlainObject(config["generation_config"]);
53
+ this.retryConfig = readRetryConfig(config);
54
+ this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
55
+ assertAudioFormat(this.audioFormat);
56
+ // Accumulate streaming text deltas; Gemini TTS returns chunked audio for
57
+ // complete text, not true token-by-token low-latency streaming.
58
+ this.disposers.push(bus.on("tts.text", (pkt) => {
59
+ const textPkt = pkt;
60
+ const current = this.textByContextId.get(textPkt.contextId) ?? "";
61
+ this.textByContextId.set(textPkt.contextId, current + textPkt.text);
62
+ }), bus.on("tts.done", async (pkt) => {
63
+ const donePkt = pkt;
64
+ const buffered = this.textByContextId.get(donePkt.contextId) ?? "";
65
+ this.textByContextId.delete(donePkt.contextId);
66
+ const text = donePkt.text || buffered;
67
+ if (!text.trim()) {
68
+ this.charactersByContextId.delete(donePkt.contextId);
69
+ this.emitEnd(donePkt.contextId);
70
+ return;
71
+ }
72
+ this.charactersByContextId.set(donePkt.contextId, text.length);
73
+ await this.synthesize(text, donePkt.contextId);
74
+ }),
75
+ // Listen for TTS interrupts — abort only the interrupted turn's synthesis.
76
+ bus.on("interrupt.tts", (pkt) => {
77
+ const ctxId = pkt.contextId;
78
+ this.charactersByContextId.delete(ctxId);
79
+ const controller = this.abortControllers.get(ctxId);
80
+ if (controller) {
81
+ controller.abort();
82
+ this.abortControllers.delete(ctxId);
83
+ }
84
+ }));
85
+ }
86
+ async synthesize(text, contextId) {
87
+ if (!this.bus)
88
+ return;
89
+ const controller = new AbortController();
90
+ this.abortControllers.set(contextId, controller);
91
+ const signal = controller.signal;
92
+ try {
93
+ await this.synthesizeWithRetry(text, contextId, signal);
94
+ }
95
+ finally {
96
+ // Only clear if still ours — an interrupt may have already removed it.
97
+ if (this.abortControllers.get(contextId) === controller) {
98
+ this.abortControllers.delete(contextId);
99
+ }
100
+ }
101
+ }
102
+ async synthesizeWithRetry(text, contextId, signal) {
103
+ for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
104
+ try {
105
+ const audioChunks = await this.synthesizeOnce(text, contextId, signal);
106
+ if (signal.aborted) {
107
+ this.charactersByContextId.delete(contextId);
108
+ return;
109
+ }
110
+ if (audioChunks > 0) {
111
+ this.emitEnd(contextId);
112
+ return;
113
+ }
114
+ throw new Error("Gemini TTS returned no audio chunks");
115
+ }
116
+ catch (err) {
117
+ if (signal.aborted) {
118
+ this.charactersByContextId.delete(contextId);
119
+ return;
120
+ }
121
+ const category = categorizeTtsError(err);
122
+ const recoverable = isRecoverable(category);
123
+ if (!recoverable || attempt >= this.retryConfig.maxAttempts) {
124
+ this.charactersByContextId.delete(contextId);
125
+ this.bus?.push(Route.Critical, {
126
+ kind: "tts.error",
127
+ contextId,
128
+ timestampMs: Date.now(),
129
+ component: "tts",
130
+ category,
131
+ cause: err instanceof Error ? err : new Error(String(err)),
132
+ isRecoverable: recoverable,
133
+ });
134
+ return;
135
+ }
136
+ this.bus?.push(Route.Background, {
137
+ kind: "metric.conversation",
138
+ contextId,
139
+ timestampMs: Date.now(),
140
+ name: "tts.retry",
141
+ value: String(attempt + 1),
142
+ });
143
+ await waitForRetryDelay(attempt, this.retryConfig, signal);
144
+ }
145
+ }
146
+ }
147
+ emitEnd(contextId) {
148
+ const characters = this.charactersByContextId.get(contextId) ?? 0;
149
+ this.charactersByContextId.delete(contextId);
150
+ if (characters > 0) {
151
+ this.bus?.push(Route.Background, {
152
+ kind: "usage.recorded",
153
+ contextId,
154
+ timestampMs: Date.now(),
155
+ stage: "tts",
156
+ provider: "gemini",
157
+ model: this.model,
158
+ characters,
159
+ });
160
+ }
161
+ this.bus?.push(Route.Main, {
162
+ kind: "tts.end",
163
+ contextId,
164
+ timestampMs: Date.now(),
165
+ });
166
+ }
167
+ async synthesizeOnce(text, contextId, signal) {
168
+ // Dynamic import — @google/genai is heavy, only load when used
169
+ const { GoogleGenAI } = await import("@google/genai");
170
+ const client = new GoogleGenAI({ apiKey: this.apiKey });
171
+ const speechConfig = {
172
+ voiceConfig: {
173
+ prebuiltVoiceConfig: {
174
+ voiceName: this.voiceName,
175
+ },
176
+ },
177
+ };
178
+ if (this.languageCode)
179
+ speechConfig["languageCode"] = this.languageCode;
180
+ const response = await withTimeout(client.models.generateContent({
181
+ model: this.model,
182
+ contents: [{ parts: [{ text: this.instruction ? `${this.instruction}: ${text}` : text }] }],
183
+ config: {
184
+ responseModalities: ["AUDIO"],
185
+ speechConfig,
186
+ ...this.generationConfig,
187
+ abortSignal: signal,
188
+ },
189
+ }), this.timeoutMs, signal);
190
+ let audioChunks = 0;
191
+ const candidate = response.candidates?.[0];
192
+ if (!candidate?.content?.parts)
193
+ return audioChunks;
194
+ for (const part of candidate.content.parts) {
195
+ if (signal.aborted)
196
+ return audioChunks;
197
+ if (part.inlineData?.data && part.inlineData.mimeType?.startsWith("audio/")) {
198
+ const audioBytes = Buffer.from(part.inlineData.data, "base64");
199
+ const audioUint8 = new Uint8Array(audioBytes);
200
+ assertAudioPayload(this.audioFormat, audioUint8);
201
+ this.bus?.push(Route.Main, {
202
+ kind: "tts.audio",
203
+ contextId,
204
+ timestampMs: Date.now(),
205
+ audio: audioUint8,
206
+ sampleRateHz: this.sampleRate,
207
+ });
208
+ audioChunks++;
209
+ }
210
+ }
211
+ return audioChunks;
212
+ }
213
+ /** Flush/cancel all in-flight synthesis (called on interrupt/shutdown). */
214
+ flush() {
215
+ for (const controller of this.abortControllers.values())
216
+ controller.abort();
217
+ this.abortControllers.clear();
218
+ this.charactersByContextId.clear();
219
+ }
220
+ async close() {
221
+ this.flush();
222
+ for (const dispose of this.disposers.splice(0))
223
+ dispose();
224
+ this.textByContextId.clear();
225
+ this.charactersByContextId.clear();
226
+ this.bus = null;
227
+ }
228
+ }
229
+ async function withTimeout(promise, timeoutMs, signal) {
230
+ return await new Promise((resolve, reject) => {
231
+ if (signal.aborted) {
232
+ reject(new Error("Gemini TTS aborted"));
233
+ return;
234
+ }
235
+ const timeout = setTimeout(() => {
236
+ reject(new Error(`Gemini TTS request timeout after ${String(timeoutMs)}ms`));
237
+ }, timeoutMs);
238
+ const onAbort = () => {
239
+ clearTimeout(timeout);
240
+ reject(new Error("Gemini TTS aborted"));
241
+ };
242
+ signal.addEventListener("abort", onAbort, { once: true });
243
+ promise.then((value) => {
244
+ clearTimeout(timeout);
245
+ signal.removeEventListener("abort", onAbort);
246
+ resolve(value);
247
+ }, (err) => {
248
+ clearTimeout(timeout);
249
+ signal.removeEventListener("abort", onAbort);
250
+ reject(err);
251
+ });
252
+ });
253
+ }
254
+ function readPositiveInteger(value, fallback) {
255
+ if (typeof value !== "number" || !Number.isFinite(value))
256
+ return fallback;
257
+ const integer = Math.floor(value);
258
+ return integer > 0 ? integer : fallback;
259
+ }
260
+ function readPlainObject(value) {
261
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
262
+ return value;
263
+ }
264
+ return undefined;
265
+ }
266
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/gemini",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "private": false,
5
5
  "description": "Google Gemini TTS adapter for Syrinx",
6
6
  "keywords": [
@@ -22,18 +22,25 @@
22
22
  "url": "https://github.com/kuralle/syrinx/issues"
23
23
  },
24
24
  "type": "module",
25
- "main": "./src/index.ts",
26
- "types": "./src/index.ts",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
27
  "dependencies": {
28
28
  "@google/genai": "^1.0.0",
29
- "@kuralle-syrinx/core": "4.4.1"
29
+ "@kuralle-syrinx/core": "4.5.0"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/node": "^22.0.0",
32
33
  "typescript": "^5.7.0",
33
34
  "vitest": "^3.2.6"
34
35
  },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "README.md"
40
+ ],
35
41
  "scripts": {
36
42
  "typecheck": "tsc --noEmit",
37
- "test": "vitest run"
43
+ "test": "vitest run",
44
+ "build": "tsc -p tsconfig.build.json"
38
45
  }
39
46
  }
@@ -0,0 +1,319 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ PipelineBusImpl,
6
+ Route,
7
+ type TextToSpeechAudioPacket,
8
+ type TextToSpeechEndPacket,
9
+ type TtsErrorPacket,
10
+ type UsageRecordedPacket,
11
+ } from "@kuralle-syrinx/core";
12
+ import { GeminiTTSPlugin } from "./index.js";
13
+
14
+ const generateContent = vi.fn();
15
+
16
+ vi.mock("@google/genai", () => ({
17
+ GoogleGenAI: vi.fn().mockImplementation(() => ({
18
+ models: { generateContent },
19
+ })),
20
+ }));
21
+
22
+ afterEach(() => {
23
+ generateContent.mockReset();
24
+ });
25
+
26
+ describe("GeminiTTSPlugin", () => {
27
+ it("emits tts.error for odd-length PCM16 without throwing into the bus pump", async () => {
28
+ generateContent.mockResolvedValue({
29
+ candidates: [{
30
+ content: {
31
+ parts: [{
32
+ inlineData: {
33
+ mimeType: "audio/pcm",
34
+ data: Buffer.from(new Uint8Array([1, 2, 3])).toString("base64"),
35
+ },
36
+ }],
37
+ },
38
+ }],
39
+ });
40
+
41
+ const bus = new PipelineBusImpl();
42
+ const started = bus.start();
43
+ const plugin = new GeminiTTSPlugin();
44
+ const audio: TextToSpeechAudioPacket[] = [];
45
+ const errors: TtsErrorPacket[] = [];
46
+ bus.on("tts.audio", (pkt) => {
47
+ audio.push(pkt as TextToSpeechAudioPacket);
48
+ });
49
+ bus.on("tts.error", (pkt) => {
50
+ errors.push(pkt as TtsErrorPacket);
51
+ });
52
+
53
+ await plugin.initialize(bus, { api_key: "test-gemini-key" });
54
+ bus.push(Route.Main, {
55
+ kind: "tts.done",
56
+ contextId: "turn-odd-pcm",
57
+ timestampMs: Date.now(),
58
+ text: "Hello.",
59
+ });
60
+ await new Promise((resolve) => setTimeout(resolve, 50));
61
+
62
+ expect(audio).toEqual([]);
63
+ expect(errors).toEqual([
64
+ expect.objectContaining({
65
+ kind: "tts.error",
66
+ contextId: "turn-odd-pcm",
67
+ component: "tts",
68
+ cause: expect.objectContaining({
69
+ message: expect.stringMatching(/even number of bytes/i),
70
+ }),
71
+ }),
72
+ ]);
73
+
74
+ await plugin.close();
75
+ bus.stop();
76
+ await started;
77
+ });
78
+
79
+ it("sends the raw text to Gemini by default (no hardcoded persona lead-in)", async () => {
80
+ generateContent.mockResolvedValue({
81
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "audio/pcm", data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64") } }] } }],
82
+ });
83
+ const bus = new PipelineBusImpl();
84
+ const started = bus.start();
85
+ const plugin = new GeminiTTSPlugin();
86
+ await plugin.initialize(bus, { api_key: "test-gemini-key" });
87
+ bus.push(Route.Main, { kind: "tts.done", contextId: "t1", timestampMs: Date.now(), text: "Add Biology 101." });
88
+ await new Promise((resolve) => setTimeout(resolve, 50));
89
+
90
+ expect(generateContent).toHaveBeenCalledTimes(1);
91
+ const arg = generateContent.mock.calls[0]![0] as { contents: Array<{ parts: Array<{ text: string }> }> };
92
+ expect(arg.contents[0]!.parts[0]!.text).toBe("Add Biology 101.");
93
+
94
+ await plugin.close();
95
+ bus.stop();
96
+ await started;
97
+ });
98
+
99
+ it("prepends a configured instruction lead-in when provided", async () => {
100
+ generateContent.mockResolvedValue({
101
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "audio/pcm", data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64") } }] } }],
102
+ });
103
+ const bus = new PipelineBusImpl();
104
+ const started = bus.start();
105
+ const plugin = new GeminiTTSPlugin();
106
+ await plugin.initialize(bus, { api_key: "test-gemini-key", instruction: "Read aloud warmly" });
107
+ bus.push(Route.Main, { kind: "tts.done", contextId: "t2", timestampMs: Date.now(), text: "Hi." });
108
+ await new Promise((resolve) => setTimeout(resolve, 50));
109
+
110
+ const arg = generateContent.mock.calls[0]![0] as { contents: Array<{ parts: Array<{ text: string }> }> };
111
+ expect(arg.contents[0]!.parts[0]!.text).toBe("Read aloud warmly: Hi.");
112
+
113
+ await plugin.close();
114
+ bus.stop();
115
+ await started;
116
+ });
117
+
118
+ it("honors voice_name, language_code, sample_rate, and generation_config on generateContent", async () => {
119
+ generateContent.mockResolvedValue({
120
+ candidates: [{
121
+ content: {
122
+ parts: [{
123
+ inlineData: {
124
+ mimeType: "audio/pcm",
125
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
126
+ },
127
+ }],
128
+ },
129
+ }],
130
+ });
131
+
132
+ const bus = new PipelineBusImpl();
133
+ const started = bus.start();
134
+ const plugin = new GeminiTTSPlugin();
135
+ const audio: TextToSpeechAudioPacket[] = [];
136
+ bus.on("tts.audio", (pkt) => {
137
+ audio.push(pkt as TextToSpeechAudioPacket);
138
+ });
139
+
140
+ await plugin.initialize(bus, {
141
+ api_key: "test-gemini-key",
142
+ voice_name: "Puck",
143
+ language_code: "en-IN",
144
+ sample_rate: 24000,
145
+ generation_config: { temperature: 0.4 },
146
+ });
147
+ bus.push(Route.Main, {
148
+ kind: "tts.done",
149
+ contextId: "turn-cfg",
150
+ timestampMs: Date.now(),
151
+ text: "Hello.",
152
+ });
153
+ await new Promise((resolve) => setTimeout(resolve, 50));
154
+
155
+ expect(generateContent).toHaveBeenCalledTimes(1);
156
+ const arg = generateContent.mock.calls[0]![0] as {
157
+ config: {
158
+ responseModalities: string[];
159
+ speechConfig: {
160
+ languageCode?: string;
161
+ voiceConfig: { prebuiltVoiceConfig: { voiceName: string } };
162
+ };
163
+ temperature?: number;
164
+ };
165
+ };
166
+ expect(arg.config.responseModalities).toEqual(["AUDIO"]);
167
+ expect(arg.config.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName).toBe("Puck");
168
+ expect(arg.config.speechConfig.languageCode).toBe("en-IN");
169
+ expect(arg.config.temperature).toBe(0.4);
170
+ expect(audio[0]?.sampleRateHz).toBe(24000);
171
+
172
+ await plugin.close();
173
+ bus.stop();
174
+ await started;
175
+ });
176
+
177
+ it("emits usage.recorded with stage tts and characters equal to synthesized text length", async () => {
178
+ const text = "Hello there.";
179
+ generateContent.mockResolvedValue({
180
+ candidates: [{
181
+ content: {
182
+ parts: [{
183
+ inlineData: {
184
+ mimeType: "audio/pcm",
185
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
186
+ },
187
+ }],
188
+ },
189
+ }],
190
+ });
191
+
192
+ const bus = new PipelineBusImpl();
193
+ const started = bus.start();
194
+ const plugin = new GeminiTTSPlugin();
195
+ const ends: TextToSpeechEndPacket[] = [];
196
+ const usage: UsageRecordedPacket[] = [];
197
+ bus.on("tts.end", (pkt) => {
198
+ ends.push(pkt as TextToSpeechEndPacket);
199
+ });
200
+ bus.on("usage.recorded", (pkt) => {
201
+ usage.push(pkt as UsageRecordedPacket);
202
+ });
203
+
204
+ await plugin.initialize(bus, {
205
+ api_key: "test-gemini-key",
206
+ model: "gemini-2.5-flash-preview-tts",
207
+ });
208
+ bus.push(Route.Main, {
209
+ kind: "tts.done",
210
+ contextId: "turn-usage",
211
+ timestampMs: Date.now(),
212
+ text,
213
+ });
214
+ await new Promise((resolve) => setTimeout(resolve, 50));
215
+
216
+ expect(ends).toEqual([expect.objectContaining({ contextId: "turn-usage" })]);
217
+ expect(usage).toEqual([
218
+ expect.objectContaining({
219
+ kind: "usage.recorded",
220
+ contextId: "turn-usage",
221
+ stage: "tts",
222
+ provider: "gemini",
223
+ model: "gemini-2.5-flash-preview-tts",
224
+ characters: text.length,
225
+ }),
226
+ ]);
227
+
228
+ await plugin.close();
229
+ bus.stop();
230
+ await started;
231
+ });
232
+
233
+ it("does not emit usage.recorded when synthesis is cancelled mid-flight", async () => {
234
+ let resolveGen!: (value: unknown) => void;
235
+ generateContent.mockImplementation(
236
+ () =>
237
+ new Promise((resolve) => {
238
+ resolveGen = resolve;
239
+ }),
240
+ );
241
+
242
+ const bus = new PipelineBusImpl();
243
+ const started = bus.start();
244
+ const plugin = new GeminiTTSPlugin();
245
+ const usage: UsageRecordedPacket[] = [];
246
+ bus.on("usage.recorded", (pkt) => {
247
+ usage.push(pkt as UsageRecordedPacket);
248
+ });
249
+
250
+ await plugin.initialize(bus, { api_key: "test-gemini-key" });
251
+ bus.push(Route.Main, {
252
+ kind: "tts.done",
253
+ contextId: "turn-cancel",
254
+ timestampMs: Date.now(),
255
+ text: "This will be cancelled.",
256
+ });
257
+ // Wait until generateContent is in-flight (controller registered).
258
+ for (let i = 0; i < 50 && !resolveGen; i += 1) {
259
+ await new Promise((resolve) => setTimeout(resolve, 10));
260
+ }
261
+ // Abort synchronously (same path as interrupt.tts handler) so it wins the race
262
+ // against a late generateContent resolution on the microtask queue.
263
+ plugin.flush();
264
+ resolveGen!({
265
+ candidates: [{
266
+ content: {
267
+ parts: [{
268
+ inlineData: {
269
+ mimeType: "audio/pcm",
270
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
271
+ },
272
+ }],
273
+ },
274
+ }],
275
+ });
276
+ await new Promise((resolve) => setTimeout(resolve, 50));
277
+
278
+ expect(usage).toEqual([]);
279
+
280
+ await plugin.close();
281
+ bus.stop();
282
+ await started;
283
+ });
284
+
285
+ it("does not emit usage.recorded when synthesis fails", async () => {
286
+ generateContent.mockRejectedValue(new Error("upstream 500"));
287
+
288
+ const bus = new PipelineBusImpl();
289
+ const started = bus.start();
290
+ const plugin = new GeminiTTSPlugin();
291
+ const usage: UsageRecordedPacket[] = [];
292
+ const errors: TtsErrorPacket[] = [];
293
+ bus.on("usage.recorded", (pkt) => {
294
+ usage.push(pkt as UsageRecordedPacket);
295
+ });
296
+ bus.on("tts.error", (pkt) => {
297
+ errors.push(pkt as TtsErrorPacket);
298
+ });
299
+
300
+ await plugin.initialize(bus, {
301
+ api_key: "test-gemini-key",
302
+ max_attempts: 1,
303
+ });
304
+ bus.push(Route.Main, {
305
+ kind: "tts.done",
306
+ contextId: "turn-fail",
307
+ timestampMs: Date.now(),
308
+ text: "This will fail.",
309
+ });
310
+ await new Promise((resolve) => setTimeout(resolve, 50));
311
+
312
+ expect(errors.length).toBeGreaterThanOrEqual(1);
313
+ expect(usage).toEqual([]);
314
+
315
+ await plugin.close();
316
+ bus.stop();
317
+ await started;
318
+ });
319
+ });