@kuralle-syrinx/gemini 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,18 +1,36 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/gemini",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Google Gemini TTS adapter for Syrinx",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "gemini",
12
+ "text-to-speech"
13
+ ],
6
14
  "license": "MIT",
15
+ "homepage": "https://github.com/kuralle/syrinx#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kuralle/syrinx.git",
19
+ "directory": "packages/gemini"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/kuralle/syrinx/issues"
23
+ },
24
+ "type": "module",
7
25
  "main": "./src/index.ts",
8
26
  "types": "./src/index.ts",
9
27
  "dependencies": {
10
28
  "@google/genai": "^1.0.0",
11
- "@kuralle-syrinx/core": "4.1.0"
29
+ "@kuralle-syrinx/core": "4.3.0"
12
30
  },
13
31
  "devDependencies": {
14
32
  "typescript": "^5.7.0",
15
- "vitest": "^2.1.0"
33
+ "vitest": "^3.2.6"
16
34
  },
17
35
  "scripts": {
18
36
  "typecheck": "tsc --noEmit",
package/src/index.ts CHANGED
@@ -48,7 +48,7 @@ export type GeminiTTSModel =
48
48
 
49
49
  const DEFAULT_MODEL: GeminiTTSModel = "gemini-3.1-flash-tts-preview";
50
50
  const DEFAULT_VOICE: GeminiVoice = "Kore";
51
- const SAMPLE_RATE = 24000;
51
+ const DEFAULT_SAMPLE_RATE = 24000;
52
52
 
53
53
  // =============================================================================
54
54
  // Plugin
@@ -60,15 +60,24 @@ export class GeminiTTSPlugin implements VoicePlugin {
60
60
  private model: string = DEFAULT_MODEL;
61
61
  private voiceName: string = DEFAULT_VOICE;
62
62
  private instruction = "";
63
+ private languageCode: string | undefined;
63
64
  private timeoutMs = 45_000;
65
+ private sampleRate = DEFAULT_SAMPLE_RATE;
66
+ private generationConfig: Record<string, unknown> | undefined;
64
67
  // One controller per in-flight turn. A single shared field let a second turn's
65
68
  // synthesize() overwrite the first's controller, so a barge-in on turn N aborted
66
69
  // only turn N+1 and turn N's stale audio could still stream after the interrupt.
67
70
  private readonly abortControllers = new Map<string, AbortController>();
68
71
  private textByContextId = new Map<string, string>();
72
+ /** Synthesized character counts per contextId — billed on successful tts.end. */
73
+ private charactersByContextId = new Map<string, number>();
69
74
  private retryConfig: RetryConfig = readRetryConfig({});
70
75
  private disposers: Array<() => void> = [];
71
- private readonly audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: SAMPLE_RATE, channels: 1 };
76
+ private audioFormat: AudioFormat = {
77
+ encoding: "pcm_s16le",
78
+ sampleRateHz: DEFAULT_SAMPLE_RATE,
79
+ channels: 1,
80
+ };
72
81
 
73
82
  async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
74
83
  this.bus = bus;
@@ -76,8 +85,13 @@ export class GeminiTTSPlugin implements VoicePlugin {
76
85
  this.model = optionalStringConfig(config, "model") ?? DEFAULT_MODEL;
77
86
  this.voiceName = optionalStringConfig(config, "voice_name") ?? DEFAULT_VOICE;
78
87
  this.instruction = optionalStringConfig(config, "instruction") ?? "";
88
+ this.languageCode =
89
+ optionalStringConfig(config, "language_code") ?? optionalStringConfig(config, "language");
79
90
  this.timeoutMs = readPositiveInteger(config["timeout_ms"], 45_000);
91
+ this.sampleRate = readPositiveInteger(config["sample_rate"], DEFAULT_SAMPLE_RATE);
92
+ this.generationConfig = readPlainObject(config["generation_config"]);
80
93
  this.retryConfig = readRetryConfig(config);
94
+ this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
81
95
  assertAudioFormat(this.audioFormat);
82
96
 
83
97
  // Accumulate streaming text deltas; Gemini TTS returns chunked audio for
@@ -95,15 +109,18 @@ export class GeminiTTSPlugin implements VoicePlugin {
95
109
  this.textByContextId.delete(donePkt.contextId);
96
110
  const text = donePkt.text || buffered;
97
111
  if (!text.trim()) {
112
+ this.charactersByContextId.delete(donePkt.contextId);
98
113
  this.emitEnd(donePkt.contextId);
99
114
  return;
100
115
  }
116
+ this.charactersByContextId.set(donePkt.contextId, text.length);
101
117
  await this.synthesize(text, donePkt.contextId);
102
118
  }),
103
119
 
104
120
  // Listen for TTS interrupts — abort only the interrupted turn's synthesis.
105
121
  bus.on("interrupt.tts", (pkt) => {
106
122
  const ctxId = (pkt as { contextId: string }).contextId;
123
+ this.charactersByContextId.delete(ctxId);
107
124
  const controller = this.abortControllers.get(ctxId);
108
125
  if (controller) {
109
126
  controller.abort();
@@ -134,23 +151,25 @@ export class GeminiTTSPlugin implements VoicePlugin {
134
151
  for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
135
152
  try {
136
153
  const audioChunks = await this.synthesizeOnce(text, contextId, signal);
137
- if (!signal.aborted && audioChunks > 0) {
138
- this.bus?.push(Route.Main, {
139
- kind: "tts.end",
140
- contextId,
141
- timestampMs: Date.now(),
142
- });
154
+ if (signal.aborted) {
155
+ this.charactersByContextId.delete(contextId);
156
+ return;
143
157
  }
144
- if (!signal.aborted && audioChunks === 0) {
145
- throw new Error("Gemini TTS returned no audio chunks");
158
+ if (audioChunks > 0) {
159
+ this.emitEnd(contextId);
160
+ return;
146
161
  }
147
- return;
162
+ throw new Error("Gemini TTS returned no audio chunks");
148
163
  } catch (err) {
149
- if (signal.aborted) return;
164
+ if (signal.aborted) {
165
+ this.charactersByContextId.delete(contextId);
166
+ return;
167
+ }
150
168
 
151
169
  const category = categorizeTtsError(err);
152
170
  const recoverable = isRecoverable(category);
153
171
  if (!recoverable || attempt >= this.retryConfig.maxAttempts) {
172
+ this.charactersByContextId.delete(contextId);
154
173
  this.bus?.push(Route.Critical, {
155
174
  kind: "tts.error",
156
175
  contextId,
@@ -176,6 +195,19 @@ export class GeminiTTSPlugin implements VoicePlugin {
176
195
  }
177
196
 
178
197
  private emitEnd(contextId: string): void {
198
+ const characters = this.charactersByContextId.get(contextId) ?? 0;
199
+ this.charactersByContextId.delete(contextId);
200
+ if (characters > 0) {
201
+ this.bus?.push(Route.Background, {
202
+ kind: "usage.recorded",
203
+ contextId,
204
+ timestampMs: Date.now(),
205
+ stage: "tts",
206
+ provider: "gemini",
207
+ model: this.model,
208
+ characters,
209
+ });
210
+ }
179
211
  this.bus?.push(Route.Main, {
180
212
  kind: "tts.end",
181
213
  contextId,
@@ -189,19 +221,23 @@ export class GeminiTTSPlugin implements VoicePlugin {
189
221
 
190
222
  const client = new GoogleGenAI({ apiKey: this.apiKey });
191
223
 
224
+ const speechConfig: Record<string, unknown> = {
225
+ voiceConfig: {
226
+ prebuiltVoiceConfig: {
227
+ voiceName: this.voiceName,
228
+ },
229
+ },
230
+ };
231
+ if (this.languageCode) speechConfig["languageCode"] = this.languageCode;
232
+
192
233
  const response = await withTimeout(
193
234
  client.models.generateContent({
194
235
  model: this.model,
195
236
  contents: [{ parts: [{ text: this.instruction ? `${this.instruction}: ${text}` : text }] }],
196
237
  config: {
197
238
  responseModalities: ["AUDIO"],
198
- speechConfig: {
199
- voiceConfig: {
200
- prebuiltVoiceConfig: {
201
- voiceName: this.voiceName,
202
- },
203
- },
204
- },
239
+ speechConfig,
240
+ ...this.generationConfig,
205
241
  abortSignal: signal,
206
242
  },
207
243
  }),
@@ -226,7 +262,7 @@ export class GeminiTTSPlugin implements VoicePlugin {
226
262
  contextId,
227
263
  timestampMs: Date.now(),
228
264
  audio: audioUint8,
229
- sampleRateHz: SAMPLE_RATE,
265
+ sampleRateHz: this.sampleRate,
230
266
  });
231
267
  audioChunks++;
232
268
  }
@@ -239,12 +275,14 @@ export class GeminiTTSPlugin implements VoicePlugin {
239
275
  flush(): void {
240
276
  for (const controller of this.abortControllers.values()) controller.abort();
241
277
  this.abortControllers.clear();
278
+ this.charactersByContextId.clear();
242
279
  }
243
280
 
244
281
  async close(): Promise<void> {
245
282
  this.flush();
246
283
  for (const dispose of this.disposers.splice(0)) dispose();
247
284
  this.textByContextId.clear();
285
+ this.charactersByContextId.clear();
248
286
  this.bus = null;
249
287
  }
250
288
  }
@@ -285,3 +323,10 @@ function readPositiveInteger(value: unknown, fallback: number): number {
285
323
  const integer = Math.floor(value);
286
324
  return integer > 0 ? integer : fallback;
287
325
  }
326
+
327
+ function readPlainObject(value: unknown): Record<string, unknown> | undefined {
328
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
329
+ return value as Record<string, unknown>;
330
+ }
331
+ return undefined;
332
+ }
package/src/index.test.ts DELETED
@@ -1,115 +0,0 @@
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 TtsErrorPacket,
9
- } from "@kuralle-syrinx/core";
10
- import { GeminiTTSPlugin } from "./index.js";
11
-
12
- const generateContent = vi.fn();
13
-
14
- vi.mock("@google/genai", () => ({
15
- GoogleGenAI: vi.fn().mockImplementation(() => ({
16
- models: { generateContent },
17
- })),
18
- }));
19
-
20
- afterEach(() => {
21
- generateContent.mockReset();
22
- });
23
-
24
- describe("GeminiTTSPlugin", () => {
25
- it("emits tts.error for odd-length PCM16 without throwing into the bus pump", async () => {
26
- generateContent.mockResolvedValue({
27
- candidates: [{
28
- content: {
29
- parts: [{
30
- inlineData: {
31
- mimeType: "audio/pcm",
32
- data: Buffer.from(new Uint8Array([1, 2, 3])).toString("base64"),
33
- },
34
- }],
35
- },
36
- }],
37
- });
38
-
39
- const bus = new PipelineBusImpl();
40
- const started = bus.start();
41
- const plugin = new GeminiTTSPlugin();
42
- const audio: TextToSpeechAudioPacket[] = [];
43
- const errors: TtsErrorPacket[] = [];
44
- bus.on("tts.audio", (pkt) => {
45
- audio.push(pkt as TextToSpeechAudioPacket);
46
- });
47
- bus.on("tts.error", (pkt) => {
48
- errors.push(pkt as TtsErrorPacket);
49
- });
50
-
51
- await plugin.initialize(bus, { api_key: "test-gemini-key" });
52
- bus.push(Route.Main, {
53
- kind: "tts.done",
54
- contextId: "turn-odd-pcm",
55
- timestampMs: Date.now(),
56
- text: "Hello.",
57
- });
58
- await new Promise((resolve) => setTimeout(resolve, 50));
59
-
60
- expect(audio).toEqual([]);
61
- expect(errors).toEqual([
62
- expect.objectContaining({
63
- kind: "tts.error",
64
- contextId: "turn-odd-pcm",
65
- component: "tts",
66
- cause: expect.objectContaining({
67
- message: expect.stringMatching(/even number of bytes/i),
68
- }),
69
- }),
70
- ]);
71
-
72
- await plugin.close();
73
- bus.stop();
74
- await started;
75
- });
76
-
77
- it("sends the raw text to Gemini by default (no hardcoded persona lead-in)", async () => {
78
- generateContent.mockResolvedValue({
79
- candidates: [{ content: { parts: [{ inlineData: { mimeType: "audio/pcm", data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64") } }] } }],
80
- });
81
- const bus = new PipelineBusImpl();
82
- const started = bus.start();
83
- const plugin = new GeminiTTSPlugin();
84
- await plugin.initialize(bus, { api_key: "test-gemini-key" });
85
- bus.push(Route.Main, { kind: "tts.done", contextId: "t1", timestampMs: Date.now(), text: "Add Biology 101." });
86
- await new Promise((resolve) => setTimeout(resolve, 50));
87
-
88
- expect(generateContent).toHaveBeenCalledTimes(1);
89
- const arg = generateContent.mock.calls[0]![0] as { contents: Array<{ parts: Array<{ text: string }> }> };
90
- expect(arg.contents[0]!.parts[0]!.text).toBe("Add Biology 101.");
91
-
92
- await plugin.close();
93
- bus.stop();
94
- await started;
95
- });
96
-
97
- it("prepends a configured instruction lead-in when provided", async () => {
98
- generateContent.mockResolvedValue({
99
- candidates: [{ content: { parts: [{ inlineData: { mimeType: "audio/pcm", data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64") } }] } }],
100
- });
101
- const bus = new PipelineBusImpl();
102
- const started = bus.start();
103
- const plugin = new GeminiTTSPlugin();
104
- await plugin.initialize(bus, { api_key: "test-gemini-key", instruction: "Read aloud warmly" });
105
- bus.push(Route.Main, { kind: "tts.done", contextId: "t2", timestampMs: Date.now(), text: "Hi." });
106
- await new Promise((resolve) => setTimeout(resolve, 50));
107
-
108
- const arg = generateContent.mock.calls[0]![0] as { contents: Array<{ parts: Array<{ text: string }> }> };
109
- expect(arg.contents[0]!.parts[0]!.text).toBe("Read aloud warmly: Hi.");
110
-
111
- await plugin.close();
112
- bus.stop();
113
- await started;
114
- });
115
- });
package/tsconfig.json DELETED
@@ -1,21 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "lib": ["ES2022"],
7
- "strict": true,
8
- "noUncheckedIndexedAccess": true,
9
- "noImplicitReturns": true,
10
- "declaration": true,
11
- "declarationMap": true,
12
- "sourceMap": true,
13
- "outDir": "./dist",
14
- "rootDir": "./src",
15
- "esModuleInterop": true,
16
- "skipLibCheck": true,
17
- "forceConsistentCasingInFileNames": true
18
- },
19
- "include": ["src/**/*.ts"],
20
- "exclude": ["node_modules", "dist"]
21
- }