@kuralle-syrinx/realtime 2.1.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,274 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { Session, LiveServerMessage } from "@google/genai";
4
+
5
+ import { bytesToBase64, base64ToBytes } from "./base64.js";
6
+ import type { RealtimeAdapter, RealtimeEvent, RealtimeToolDef } from "./realtime-adapter.js";
7
+
8
+ const DEFAULT_MODEL = "gemini-3.1-flash-live-preview";
9
+ const INPUT_SAMPLE_RATE_HZ = 16_000;
10
+ const OUTPUT_SAMPLE_RATE_HZ = 24_000;
11
+
12
+ export interface GeminiLiveOptions {
13
+ readonly apiKey: string;
14
+ readonly model?: string;
15
+ readonly systemInstruction?: string;
16
+ readonly tools?: readonly RealtimeToolDef[];
17
+ }
18
+
19
+ class RealtimeEventStream implements AsyncIterable<RealtimeEvent> {
20
+ private readonly queue: RealtimeEvent[] = [];
21
+ private readonly waiters: Array<(result: IteratorResult<RealtimeEvent>) => void> = [];
22
+ private closed = false;
23
+
24
+ push(event: RealtimeEvent): void {
25
+ if (this.closed) return;
26
+ const waiter = this.waiters.shift();
27
+ if (waiter) {
28
+ waiter({ value: event, done: false });
29
+ return;
30
+ }
31
+ this.queue.push(event);
32
+ }
33
+
34
+ close(): void {
35
+ if (this.closed) return;
36
+ this.closed = true;
37
+ while (this.waiters.length > 0) {
38
+ const waiter = this.waiters.shift();
39
+ waiter?.({ value: undefined, done: true });
40
+ }
41
+ }
42
+
43
+ [Symbol.asyncIterator](): AsyncIterator<RealtimeEvent> {
44
+ return {
45
+ next: () =>
46
+ new Promise<IteratorResult<RealtimeEvent>>((resolve) => {
47
+ if (this.queue.length > 0) {
48
+ resolve({ value: this.queue.shift()!, done: false });
49
+ return;
50
+ }
51
+ if (this.closed) {
52
+ resolve({ value: undefined, done: true });
53
+ return;
54
+ }
55
+ this.waiters.push(resolve);
56
+ }),
57
+ };
58
+ }
59
+ }
60
+
61
+ class GeminiLiveAdapter implements RealtimeAdapter {
62
+ readonly caps = {
63
+ inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
64
+ outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
65
+ supportsConcurrentToolAudio: false,
66
+ supportsTruncate: false,
67
+ emitsServerSpeechStarted: true,
68
+ } as const;
69
+
70
+ readonly events: AsyncIterable<RealtimeEvent>;
71
+
72
+ private readonly stream = new RealtimeEventStream();
73
+ private session: Session | null = null;
74
+ private abortHandler: (() => void) | null = null;
75
+ private openResolver: (() => void) | null = null;
76
+ private openRejecter: ((err: Error) => void) | null = null;
77
+ private activeResponse = false;
78
+ private readonly toolNames = new Map<string, string>();
79
+
80
+ constructor(private readonly opts: GeminiLiveOptions) {
81
+ this.events = this.stream;
82
+ }
83
+
84
+ async open(signal: AbortSignal): Promise<void> {
85
+ const { GoogleGenAI, Modality } = await import("@google/genai");
86
+ const model = this.opts.model ?? DEFAULT_MODEL;
87
+
88
+ const tools = (this.opts.tools ?? []).map((t) => ({
89
+ functionDeclarations: [{
90
+ name: t.name,
91
+ description: t.description,
92
+ parametersJsonSchema: t.parameters,
93
+ }],
94
+ }));
95
+
96
+ const config: Record<string, unknown> = {
97
+ responseModalities: [Modality.AUDIO],
98
+ inputAudioTranscription: {},
99
+ outputAudioTranscription: {},
100
+ };
101
+ if (this.opts.systemInstruction) {
102
+ config["systemInstruction"] = this.opts.systemInstruction;
103
+ }
104
+ if (tools.length > 0) {
105
+ config["tools"] = tools;
106
+ }
107
+
108
+ const openPromise = new Promise<void>((resolve, reject) => {
109
+ this.openResolver = resolve;
110
+ this.openRejecter = reject;
111
+ });
112
+
113
+ const ai = new GoogleGenAI({ apiKey: this.opts.apiKey });
114
+
115
+ this.session = await ai.live.connect({
116
+ model,
117
+ config,
118
+ callbacks: {
119
+ onopen: () => this.resolveOpen(),
120
+ onmessage: (msg) => this.handleMessage(msg),
121
+ onerror: (ev) => {
122
+ const cause = ev instanceof Error ? ev : new Error(String(ev));
123
+ this.stream.push({ type: "error", cause, recoverable: true });
124
+ this.rejectOpen(cause);
125
+ },
126
+ onclose: () => this.stream.close(),
127
+ },
128
+ });
129
+
130
+ this.abortHandler = () => {
131
+ void this.close();
132
+ this.rejectOpen(new Error("Gemini Live adapter open aborted"));
133
+ };
134
+ signal.addEventListener("abort", this.abortHandler, { once: true });
135
+
136
+ await openPromise;
137
+ }
138
+
139
+ sendAudio(pcm16: Uint8Array): void {
140
+ this.requireSession().sendRealtimeInput({
141
+ audio: {
142
+ data: bytesToBase64(pcm16),
143
+ mimeType: "audio/pcm;rate=16000",
144
+ },
145
+ });
146
+ }
147
+
148
+ cancelResponse(_audioEndMs: number): void {
149
+ // Gemini handles interruption server-side via `interrupted`; no truncate API.
150
+ }
151
+
152
+ injectToolResult(toolId: string, text: string): void {
153
+ const name = this.toolNames.get(toolId);
154
+ if (!name) {
155
+ this.stream.push({
156
+ type: "error",
157
+ cause: new Error(`unknown tool id "${toolId}" for Gemini tool response`),
158
+ recoverable: false,
159
+ });
160
+ return;
161
+ }
162
+ this.requireSession().sendToolResponse({
163
+ functionResponses: [{
164
+ id: toolId,
165
+ name,
166
+ response: { result: text },
167
+ }],
168
+ });
169
+ }
170
+
171
+ async close(): Promise<void> {
172
+ if (this.abortHandler) {
173
+ // signal may already be gone; best-effort cleanup
174
+ }
175
+ this.session?.close();
176
+ this.session = null;
177
+ this.stream.close();
178
+ }
179
+
180
+ private requireSession(): Session {
181
+ if (!this.session) throw new Error("Gemini Live adapter is not open");
182
+ return this.session;
183
+ }
184
+
185
+ private rejectOpen(err: Error): void {
186
+ this.openRejecter?.(err);
187
+ this.openResolver = null;
188
+ this.openRejecter = null;
189
+ }
190
+
191
+ private resolveOpen(): void {
192
+ this.openResolver?.();
193
+ this.openResolver = null;
194
+ this.openRejecter = null;
195
+ }
196
+
197
+ private handleMessage(msg: LiveServerMessage): void {
198
+ if (msg.setupComplete) {
199
+ if (!this.activeResponse) {
200
+ this.activeResponse = true;
201
+ this.stream.push({ type: "response_started" });
202
+ }
203
+ }
204
+
205
+ const content = msg.serverContent;
206
+ if (content) {
207
+ if (content.interrupted) {
208
+ this.stream.push({ type: "speech_started" });
209
+ }
210
+
211
+ if (content.inputTranscription?.text) {
212
+ this.stream.push({
213
+ type: "transcript",
214
+ role: "user",
215
+ text: content.inputTranscription.text,
216
+ final: content.inputTranscription.finished ?? false,
217
+ });
218
+ }
219
+
220
+ if (content.outputTranscription?.text) {
221
+ this.stream.push({
222
+ type: "transcript",
223
+ role: "assistant",
224
+ text: content.outputTranscription.text,
225
+ final: content.outputTranscription.finished ?? false,
226
+ });
227
+ }
228
+
229
+ const parts = content.modelTurn?.parts;
230
+ if (parts) {
231
+ if (!this.activeResponse) {
232
+ this.activeResponse = true;
233
+ this.stream.push({ type: "response_started" });
234
+ }
235
+ for (const part of parts) {
236
+ const inline = part.inlineData;
237
+ if (inline?.data && inline.mimeType?.startsWith("audio/")) {
238
+ const rateMatch = /rate=(\d+)/.exec(inline.mimeType);
239
+ const sampleRateHz = rateMatch ? Number(rateMatch[1]) : OUTPUT_SAMPLE_RATE_HZ;
240
+ this.stream.push({
241
+ type: "audio",
242
+ pcm16: base64ToBytes(inline.data),
243
+ sampleRateHz,
244
+ });
245
+ }
246
+ }
247
+ }
248
+
249
+ if (content.turnComplete) {
250
+ this.activeResponse = false;
251
+ this.stream.push({ type: "response_done" });
252
+ }
253
+ }
254
+
255
+ const calls = msg.toolCall?.functionCalls;
256
+ if (calls) {
257
+ for (const call of calls) {
258
+ const toolId = call.id ?? crypto.randomUUID();
259
+ const toolName = call.name ?? "unknown";
260
+ this.toolNames.set(toolId, toolName);
261
+ this.stream.push({
262
+ type: "tool_call",
263
+ toolId,
264
+ toolName,
265
+ args: (call.args ?? {}) as Record<string, unknown>,
266
+ });
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ export function fromGeminiLive(opts: GeminiLiveOptions): RealtimeAdapter {
273
+ return new GeminiLiveAdapter(opts);
274
+ }