@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,128 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { Session, LiveServerMessage } from "@google/genai";
4
+
5
+ import { bytesToBase64, base64ToBytes } from "./base64.js";
6
+
7
+ const DEFAULT_MODEL = "gemini-3.5-live-translate-preview";
8
+ const OUTPUT_SAMPLE_RATE_HZ = 24_000;
9
+
10
+ export interface GeminiTranslateSession {
11
+ sendAudio(pcm16: Uint8Array): void;
12
+ signalAudioStreamEnd(): void;
13
+ close(): Promise<void>;
14
+ }
15
+
16
+ export interface GeminiTranslateSessionOptions {
17
+ readonly apiKey: string;
18
+ readonly targetLanguageCode: string;
19
+ readonly echoTargetLanguage?: boolean;
20
+ readonly onAudio: (pcm16: Uint8Array, sampleRateHz: number) => void;
21
+ readonly onText?: (text: string, role: "input" | "output", final: boolean) => void;
22
+ readonly onError?: (cause: Error) => void;
23
+ }
24
+
25
+ export async function createGeminiTranslateSession(
26
+ opts: GeminiTranslateSessionOptions,
27
+ ): Promise<GeminiTranslateSession> {
28
+ const { GoogleGenAI, Modality } = await import("@google/genai");
29
+ const ai = new GoogleGenAI({
30
+ apiKey: opts.apiKey,
31
+ httpOptions: { apiVersion: "v1alpha" },
32
+ });
33
+
34
+ const session = await ai.live.connect({
35
+ model: DEFAULT_MODEL,
36
+ config: {
37
+ responseModalities: [Modality.AUDIO],
38
+ inputAudioTranscription: {},
39
+ outputAudioTranscription: {},
40
+ translationConfig: {
41
+ targetLanguageCode: opts.targetLanguageCode,
42
+ echoTargetLanguage: opts.echoTargetLanguage ?? true,
43
+ },
44
+ },
45
+ callbacks: {
46
+ onmessage: (msg) => handleTranslateMessage(msg, opts),
47
+ onerror: (ev) => {
48
+ const cause = ev instanceof Error ? ev : new Error(String(ev));
49
+ opts.onError?.(cause);
50
+ },
51
+ },
52
+ });
53
+
54
+ // Gemini Live Translate needs ~100ms input chunks. Feeding the engine's native 20ms frames
55
+ // intermittently makes the model echo the SOURCE language instead of translating (~20-33%,
56
+ // verified deterministically via Deepgram detect_language). Coalesce to 100ms here so callers
57
+ // can keep sending 20ms frames. 100ms @ 16kHz PCM16 = 1600 samples = 3200 bytes (sample-aligned).
58
+ const INPUT_CHUNK_BYTES = 3200;
59
+ let buf = new Uint8Array(0);
60
+ const flushChunk = (chunk: Uint8Array): void => {
61
+ session.sendRealtimeInput({ audio: { data: bytesToBase64(chunk), mimeType: "audio/pcm;rate=16000" } });
62
+ };
63
+ const flushRemainder = (): void => {
64
+ if (buf.length > 0) {
65
+ flushChunk(buf);
66
+ buf = new Uint8Array(0);
67
+ }
68
+ };
69
+
70
+ return {
71
+ sendAudio(pcm16: Uint8Array): void {
72
+ const merged = new Uint8Array(buf.length + pcm16.length);
73
+ merged.set(buf, 0);
74
+ merged.set(pcm16, buf.length);
75
+ buf = merged;
76
+ while (buf.length >= INPUT_CHUNK_BYTES) {
77
+ flushChunk(buf.subarray(0, INPUT_CHUNK_BYTES));
78
+ buf = buf.slice(INPUT_CHUNK_BYTES);
79
+ }
80
+ },
81
+ signalAudioStreamEnd(): void {
82
+ flushRemainder();
83
+ session.sendRealtimeInput({ audioStreamEnd: true });
84
+ },
85
+ async close(): Promise<void> {
86
+ flushRemainder();
87
+ session.close();
88
+ },
89
+ };
90
+ }
91
+
92
+ function handleTranslateMessage(
93
+ msg: LiveServerMessage,
94
+ opts: GeminiTranslateSessionOptions,
95
+ ): void {
96
+ const content = msg.serverContent;
97
+ if (!content) return;
98
+
99
+ if (content.inputTranscription?.text && opts.onText) {
100
+ opts.onText(
101
+ content.inputTranscription.text,
102
+ "input",
103
+ content.inputTranscription.finished ?? false,
104
+ );
105
+ }
106
+
107
+ if (content.outputTranscription?.text && opts.onText) {
108
+ opts.onText(
109
+ content.outputTranscription.text,
110
+ "output",
111
+ content.outputTranscription.finished ?? false,
112
+ );
113
+ }
114
+
115
+ const parts = content.modelTurn?.parts;
116
+ if (!parts) return;
117
+
118
+ for (const part of parts) {
119
+ const inline = part.inlineData;
120
+ if (inline?.data && inline.mimeType?.startsWith("audio/")) {
121
+ const rateMatch = /rate=(\d+)/.exec(inline.mimeType);
122
+ const sampleRateHz = rateMatch ? Number(rateMatch[1]) : OUTPUT_SAMPLE_RATE_HZ;
123
+ opts.onAudio(base64ToBytes(inline.data), sampleRateHz);
124
+ }
125
+ }
126
+ }
127
+
128
+ export { DEFAULT_MODEL as GEMINI_TRANSLATE_MODEL };
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export type { RealtimeAdapter, RealtimeEvent, RealtimeToolDef } from "./realtime-adapter.js";
4
+ export { bytesToBase64, base64ToBytes } from "./base64.js";
5
+ export {
6
+ createOpenAiCompatibleRealtimeAdapter,
7
+ type OpenAiCompatibleRealtimeConfig,
8
+ } from "./openai-compatible-realtime.js";
9
+ export { fromOpenAIRealtime, type OpenAIRealtimeOptions } from "./from-openai-realtime.js";
10
+ export { fromGeminiLive, type GeminiLiveOptions } from "./from-gemini-live.js";
11
+ export {
12
+ createGeminiTranslateSession,
13
+ GEMINI_TRANSLATE_MODEL,
14
+ type GeminiTranslateSession,
15
+ type GeminiTranslateSessionOptions,
16
+ } from "./gemini-translate.js";
17
+ export { RealtimeBridge } from "./realtime-bridge.js";
@@ -0,0 +1,373 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { SocketFactory } from "@kuralle-syrinx/ws";
4
+ import { RealtimeSocket } from "@kuralle-syrinx/ws/realtime";
5
+
6
+ import { base64ToBytes, bytesToBase64 } from "./base64.js";
7
+ import type { RealtimeAdapter, RealtimeEvent } from "./realtime-adapter.js";
8
+
9
+ export interface OpenAiCompatibleRealtimeConfig {
10
+ readonly apiKey: string;
11
+ readonly socketFactory: SocketFactory;
12
+ readonly debug?: boolean;
13
+ readonly debugLogPrefix?: string;
14
+ readonly defaultModel: string;
15
+ readonly model?: string;
16
+ readonly url?: () => string;
17
+ readonly buildUrl?: (model: string) => string;
18
+ readonly caps: RealtimeAdapter["caps"];
19
+ readonly buildSessionUpdate: () => Record<string, unknown>;
20
+ readonly supportsTruncate: boolean;
21
+ readonly requiresResponseCreateAfterToolOutput?: boolean;
22
+ readonly defaultErrorMessage: string;
23
+ readonly extendServerMessage?: (
24
+ type: string,
25
+ msg: Record<string, unknown>,
26
+ ctx: {
27
+ push: (event: RealtimeEvent) => void;
28
+ caps: RealtimeAdapter["caps"];
29
+ },
30
+ ) => boolean;
31
+ }
32
+
33
+ class RealtimeEventStream implements AsyncIterable<RealtimeEvent> {
34
+ private readonly queue: RealtimeEvent[] = [];
35
+ private readonly waiters: Array<(result: IteratorResult<RealtimeEvent>) => void> = [];
36
+ private closed = false;
37
+
38
+ push(event: RealtimeEvent): void {
39
+ if (this.closed) return;
40
+ const waiter = this.waiters.shift();
41
+ if (waiter) {
42
+ waiter({ value: event, done: false });
43
+ return;
44
+ }
45
+ this.queue.push(event);
46
+ }
47
+
48
+ close(): void {
49
+ if (this.closed) return;
50
+ this.closed = true;
51
+ while (this.waiters.length > 0) {
52
+ const waiter = this.waiters.shift();
53
+ waiter?.({ value: undefined, done: true });
54
+ }
55
+ }
56
+
57
+ [Symbol.asyncIterator](): AsyncIterator<RealtimeEvent> {
58
+ return {
59
+ next: () =>
60
+ new Promise<IteratorResult<RealtimeEvent>>((resolve) => {
61
+ if (this.queue.length > 0) {
62
+ resolve({ value: this.queue.shift()!, done: false });
63
+ return;
64
+ }
65
+ if (this.closed) {
66
+ resolve({ value: undefined, done: true });
67
+ return;
68
+ }
69
+ this.waiters.push(resolve);
70
+ }),
71
+ };
72
+ }
73
+ }
74
+
75
+ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
76
+ readonly caps: RealtimeAdapter["caps"];
77
+ readonly events: AsyncIterable<RealtimeEvent>;
78
+
79
+ private readonly stream = new RealtimeEventStream();
80
+ private socket: RealtimeSocket | null = null;
81
+ private abortHandler: (() => void) | null = null;
82
+ private openResolver: (() => void) | null = null;
83
+ private openRejecter: ((err: Error) => void) | null = null;
84
+ private currentAssistantItemId: string | null = null;
85
+ private assistantTranscript = "";
86
+ private activeResponse = false;
87
+ private pendingResponseCreate = false;
88
+
89
+ constructor(private readonly config: OpenAiCompatibleRealtimeConfig) {
90
+ this.events = this.stream;
91
+ this.caps = config.caps;
92
+ }
93
+
94
+ async open(signal: AbortSignal): Promise<void> {
95
+ const model = this.config.model ?? this.config.defaultModel;
96
+ const url =
97
+ this.config.url ??
98
+ (this.config.buildUrl
99
+ ? () => this.config.buildUrl!(model)
100
+ : () => `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`);
101
+
102
+ this.socket = new RealtimeSocket({
103
+ url,
104
+ headers: {
105
+ Authorization: `Bearer ${this.config.apiKey}`,
106
+ },
107
+ socketFactory: this.config.socketFactory,
108
+ onMessage: (json) => this.handleServerMessage(json),
109
+ onConnectionLost: (err) => {
110
+ this.stream.push({ type: "error", cause: err, recoverable: true });
111
+ this.rejectOpen(err);
112
+ },
113
+ onUnrecoverable: (err) => {
114
+ this.stream.push({ type: "error", cause: err, recoverable: false });
115
+ this.rejectOpen(err);
116
+ },
117
+ });
118
+
119
+ const openPromise = new Promise<void>((resolve, reject) => {
120
+ this.openResolver = resolve;
121
+ this.openRejecter = reject;
122
+ });
123
+
124
+ this.abortHandler = () => {
125
+ void this.close();
126
+ this.rejectOpen(new Error("Realtime adapter open aborted"));
127
+ };
128
+ signal.addEventListener("abort", this.abortHandler, { once: true });
129
+
130
+ await this.socket.connect();
131
+ this.socket.send({
132
+ type: "session.update",
133
+ session: this.config.buildSessionUpdate(),
134
+ });
135
+
136
+ await openPromise;
137
+ }
138
+
139
+ sendAudio(pcm16: Uint8Array): void {
140
+ this.requireSocket().send({
141
+ type: "input_audio_buffer.append",
142
+ audio: bytesToBase64(pcm16),
143
+ });
144
+ }
145
+
146
+ cancelResponse(audioEndMs: number): void {
147
+ if (!this.activeResponse) return;
148
+ const socket = this.requireSocket();
149
+ socket.send({ type: "response.cancel" });
150
+ if (this.config.supportsTruncate && this.currentAssistantItemId) {
151
+ socket.send({
152
+ type: "conversation.item.truncate",
153
+ item_id: this.currentAssistantItemId,
154
+ content_index: 0,
155
+ audio_end_ms: audioEndMs,
156
+ });
157
+ }
158
+ if (this.config.supportsTruncate) {
159
+ this.currentAssistantItemId = null;
160
+ }
161
+ }
162
+
163
+ injectToolResult(toolId: string, text: string): void {
164
+ const socket = this.requireSocket();
165
+ socket.send({
166
+ type: "conversation.item.create",
167
+ item: {
168
+ type: "function_call_output",
169
+ call_id: toolId,
170
+ output: text,
171
+ },
172
+ });
173
+ if (this.config.requiresResponseCreateAfterToolOutput !== false) {
174
+ this.requestResponseCreate();
175
+ }
176
+ }
177
+
178
+ async close(): Promise<void> {
179
+ await this.socket?.close();
180
+ this.socket = null;
181
+ this.stream.close();
182
+ }
183
+
184
+ private requestResponseCreate(): void {
185
+ if (this.activeResponse) {
186
+ this.pendingResponseCreate = true;
187
+ return;
188
+ }
189
+ this.requireSocket().send({ type: "response.create" });
190
+ this.pendingResponseCreate = false;
191
+ }
192
+
193
+ private completeResponse(): void {
194
+ this.activeResponse = false;
195
+ if (this.config.supportsTruncate) {
196
+ this.currentAssistantItemId = null;
197
+ }
198
+ if (this.pendingResponseCreate) {
199
+ this.pendingResponseCreate = false;
200
+ this.requireSocket().send({ type: "response.create" });
201
+ }
202
+ }
203
+
204
+ private requireSocket(): RealtimeSocket {
205
+ if (!this.socket) throw new Error("Realtime adapter is not open");
206
+ return this.socket;
207
+ }
208
+
209
+ private rejectOpen(err: Error): void {
210
+ this.openRejecter?.(err);
211
+ this.openResolver = null;
212
+ this.openRejecter = null;
213
+ }
214
+
215
+ private resolveOpen(): void {
216
+ this.openResolver?.();
217
+ this.openResolver = null;
218
+ this.openRejecter = null;
219
+ }
220
+
221
+ private handleServerMessage(json: string): void {
222
+ let msg: Record<string, unknown>;
223
+ try {
224
+ msg = JSON.parse(json) as Record<string, unknown>;
225
+ } catch (err) {
226
+ this.stream.push({
227
+ type: "error",
228
+ cause: err instanceof Error ? err : new Error(String(err)),
229
+ recoverable: false,
230
+ });
231
+ return;
232
+ }
233
+
234
+ const type = typeof msg["type"] === "string" ? msg["type"] : "";
235
+ if (this.config.debug) {
236
+ console.error(`${this.config.debugLogPrefix ?? "[raw]"} ${type}`);
237
+ }
238
+
239
+ if (this.config.extendServerMessage?.(type, msg, { push: (e) => this.stream.push(e), caps: this.caps })) {
240
+ return;
241
+ }
242
+
243
+ switch (type) {
244
+ case "session.created":
245
+ case "session.updated":
246
+ this.resolveOpen();
247
+ break;
248
+ case "response.created":
249
+ this.assistantTranscript = "";
250
+ if (this.config.supportsTruncate) {
251
+ this.currentAssistantItemId = null;
252
+ }
253
+ this.activeResponse = true;
254
+ this.stream.push({ type: "response_started" });
255
+ break;
256
+ case "response.output_item.added": {
257
+ if (!this.config.supportsTruncate) break;
258
+ const item = msg["item"];
259
+ if (item && typeof item === "object") {
260
+ const record = item as Record<string, unknown>;
261
+ if (record["type"] === "message" && typeof record["id"] === "string") {
262
+ this.currentAssistantItemId = record["id"];
263
+ }
264
+ }
265
+ break;
266
+ }
267
+ case "response.output_audio.delta": {
268
+ const delta = msg["delta"];
269
+ if (typeof delta === "string" && delta.length > 0) {
270
+ this.stream.push({
271
+ type: "audio",
272
+ pcm16: base64ToBytes(delta),
273
+ sampleRateHz: this.caps.outputSampleRateHz,
274
+ });
275
+ }
276
+ break;
277
+ }
278
+ case "input_audio_buffer.speech_started":
279
+ this.stream.push({ type: "speech_started" });
280
+ break;
281
+ case "response.output_audio_transcript.delta": {
282
+ const delta = msg["delta"];
283
+ if (typeof delta === "string" && delta.length > 0) {
284
+ this.assistantTranscript += delta;
285
+ this.stream.push({
286
+ type: "transcript",
287
+ role: "assistant",
288
+ text: delta,
289
+ final: false,
290
+ });
291
+ }
292
+ break;
293
+ }
294
+ case "response.output_audio_transcript.done": {
295
+ const transcript =
296
+ typeof msg["transcript"] === "string" ? msg["transcript"] : this.assistantTranscript;
297
+ this.stream.push({
298
+ type: "transcript",
299
+ role: "assistant",
300
+ text: transcript,
301
+ final: true,
302
+ });
303
+ this.assistantTranscript = "";
304
+ break;
305
+ }
306
+ case "conversation.item.input_audio_transcription.completed": {
307
+ // User-side transcript (requires input transcription enabled in session config).
308
+ const transcript = typeof msg["transcript"] === "string" ? msg["transcript"] : "";
309
+ if (transcript.trim()) {
310
+ this.stream.push({ type: "transcript", role: "user", text: transcript, final: true });
311
+ }
312
+ break;
313
+ }
314
+ case "response.done": {
315
+ this.completeResponse();
316
+ const toolCall = extractFunctionCall(msg["response"]);
317
+ if (toolCall) {
318
+ this.stream.push(toolCall);
319
+ }
320
+ this.stream.push({ type: "response_done" });
321
+ break;
322
+ }
323
+ case "error": {
324
+ const errObj = msg["error"];
325
+ const message =
326
+ errObj && typeof errObj === "object" && typeof (errObj as Record<string, unknown>)["message"] === "string"
327
+ ? String((errObj as Record<string, unknown>)["message"])
328
+ : this.config.defaultErrorMessage;
329
+ const code =
330
+ errObj && typeof errObj === "object" ? (errObj as Record<string, unknown>)["code"] : undefined;
331
+ const recoverable = code !== "invalid_api_key" && code !== "authentication_failed";
332
+ this.stream.push({
333
+ type: "error",
334
+ cause: new Error(message),
335
+ recoverable,
336
+ });
337
+ break;
338
+ }
339
+ default:
340
+ break;
341
+ }
342
+ }
343
+ }
344
+
345
+ function extractFunctionCall(response: unknown): RealtimeEvent | null {
346
+ if (!response || typeof response !== "object") return null;
347
+ const output = (response as Record<string, unknown>)["output"];
348
+ if (!Array.isArray(output)) return null;
349
+ for (const item of output) {
350
+ if (!item || typeof item !== "object") continue;
351
+ const record = item as Record<string, unknown>;
352
+ if (record["type"] !== "function_call") continue;
353
+ const toolId = typeof record["call_id"] === "string" ? record["call_id"] : "";
354
+ const toolName = typeof record["name"] === "string" ? record["name"] : "";
355
+ const argsRaw = typeof record["arguments"] === "string" ? record["arguments"] : "{}";
356
+ if (!toolId || !toolName) continue;
357
+ let args: Record<string, unknown>;
358
+ try {
359
+ const parsed: unknown = JSON.parse(argsRaw);
360
+ args = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
361
+ } catch {
362
+ args = {};
363
+ }
364
+ return { type: "tool_call", toolId, toolName, args };
365
+ }
366
+ return null;
367
+ }
368
+
369
+ export function createOpenAiCompatibleRealtimeAdapter(
370
+ config: OpenAiCompatibleRealtimeConfig,
371
+ ): RealtimeAdapter {
372
+ return new OpenAiCompatibleRealtimeAdapter(config);
373
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export interface RealtimeAdapter {
4
+ readonly caps: {
5
+ readonly inputSampleRateHz: number;
6
+ readonly outputSampleRateHz: number;
7
+ readonly supportsConcurrentToolAudio: boolean;
8
+ readonly supportsTruncate: boolean;
9
+ readonly emitsServerSpeechStarted: boolean;
10
+ };
11
+
12
+ open(signal: AbortSignal): Promise<void>;
13
+ sendAudio(pcm16: Uint8Array): void;
14
+ cancelResponse(audioEndMs: number): void;
15
+ injectToolResult(toolId: string, text: string): void;
16
+ /** Close the provider socket and end the event stream. */
17
+ close(): Promise<void>;
18
+ readonly events: AsyncIterable<RealtimeEvent>;
19
+ }
20
+
21
+ /**
22
+ * A function tool advertised to the front model so it can decide when to delegate. Domain-neutral:
23
+ * the caller (example/app) supplies these — the provider adapter never hardcodes any tool.
24
+ */
25
+ export interface RealtimeToolDef {
26
+ readonly name: string;
27
+ readonly description: string;
28
+ /** JSON Schema for the tool arguments. */
29
+ readonly parameters: Record<string, unknown>;
30
+ }
31
+
32
+ export type RealtimeEvent =
33
+ | { type: "audio"; pcm16: Uint8Array; sampleRateHz: number }
34
+ | { type: "speech_started" }
35
+ | { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
36
+ | { type: "tool_call"; toolId: string; toolName: string; args: Record<string, unknown> }
37
+ | { type: "response_started" }
38
+ | { type: "response_done" }
39
+ | { type: "error"; cause: Error; recoverable: boolean };