@kuralle-syrinx/gemini 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 +3 -3
- package/src/index.ts +65 -20
- package/src/index.test.ts +0 -115
- package/tsconfig.json +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/gemini",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Google Gemini TTS adapter for Syrinx",
|
|
6
6
|
"keywords": [
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
"types": "./src/index.ts",
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@google/genai": "^1.0.0",
|
|
29
|
-
"@kuralle-syrinx/core": "4.
|
|
29
|
+
"@kuralle-syrinx/core": "4.4.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"typescript": "^5.7.0",
|
|
33
|
-
"vitest": "^2.
|
|
33
|
+
"vitest": "^3.2.6"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
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
|
|
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
|
|
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 (
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
contextId,
|
|
141
|
-
timestampMs: Date.now(),
|
|
142
|
-
});
|
|
154
|
+
if (signal.aborted) {
|
|
155
|
+
this.charactersByContextId.delete(contextId);
|
|
156
|
+
return;
|
|
143
157
|
}
|
|
144
|
-
if (
|
|
145
|
-
|
|
158
|
+
if (audioChunks > 0) {
|
|
159
|
+
this.emitEnd(contextId);
|
|
160
|
+
return;
|
|
146
161
|
}
|
|
147
|
-
|
|
162
|
+
throw new Error("Gemini TTS returned no audio chunks");
|
|
148
163
|
} catch (err) {
|
|
149
|
-
if (signal.aborted)
|
|
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
|
-
|
|
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:
|
|
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
|
-
}
|