@live-assistant/gemini 0.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.
- package/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/dtos/live-server-message-dto.d.ts +57 -0
- package/dist/dtos/live-server-message-dto.d.ts.map +1 -0
- package/dist/dtos/live-server-message-dto.js +3 -0
- package/dist/dtos/live-server-message-dto.js.map +1 -0
- package/dist/dtos/live-setup-request-dto.d.ts +16 -0
- package/dist/dtos/live-setup-request-dto.d.ts.map +1 -0
- package/dist/dtos/live-setup-request-dto.js +3 -0
- package/dist/dtos/live-setup-request-dto.js.map +1 -0
- package/dist/gemini-live-credentials.d.ts +22 -0
- package/dist/gemini-live-credentials.d.ts.map +1 -0
- package/dist/gemini-live-credentials.js +3 -0
- package/dist/gemini-live-credentials.js.map +1 -0
- package/dist/gemini-live-session.d.ts +57 -0
- package/dist/gemini-live-session.d.ts.map +1 -0
- package/dist/gemini-live-session.js +190 -0
- package/dist/gemini-live-session.js.map +1 -0
- package/dist/guards.d.ts +4 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +9 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/live-message-mapper.d.ts +4 -0
- package/dist/live-message-mapper.d.ts.map +1 -0
- package/dist/live-message-mapper.js +84 -0
- package/dist/live-message-mapper.js.map +1 -0
- package/dist/live-protocol.d.ts +20 -0
- package/dist/live-protocol.d.ts.map +1 -0
- package/dist/live-protocol.js +24 -0
- package/dist/live-protocol.js.map +1 -0
- package/dist/live-setup-request.d.ts +10 -0
- package/dist/live-setup-request.d.ts.map +1 -0
- package/dist/live-setup-request.js +13 -0
- package/dist/live-setup-request.js.map +1 -0
- package/package.json +45 -0
- package/src/dtos/live-server-message-dto.ts +29 -0
- package/src/dtos/live-setup-request-dto.ts +13 -0
- package/src/gemini-live-credentials.ts +21 -0
- package/src/gemini-live-session.ts +235 -0
- package/src/guards.ts +4 -0
- package/src/index.ts +2 -0
- package/src/live-message-mapper.ts +92 -0
- package/src/live-protocol.ts +21 -0
- package/src/live-setup-request.ts +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Recep Tayyip Ekşi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @live-assistant/gemini
|
|
2
|
+
|
|
3
|
+
The Gemini Live implementation of `AssistantSession`: one WebSocket, its setup handshake, and the frames in both directions (binary JSON frames, 16 kHz in and 24 kHz out, generic tool calls and cancellations).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const session = new GeminiLiveSession();
|
|
7
|
+
await session.connect({ token, model }); // wsUrl defaults to the constrained endpoint
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Tokens come from `@live-assistant/token-server`. Gemini fixes the session's configuration when the token is minted.
|
|
11
|
+
|
|
12
|
+
See the [overview](../README.md).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One frame as the Live API sends it, before anything is trusted about it.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* - **Every field is optional because the wire says so.** A frame carries
|
|
6
|
+
* exactly one of these envelopes, and which one is not discriminated by any
|
|
7
|
+
* tag — the presence of the key IS the tag. Modelling it as a union here
|
|
8
|
+
* would be asserting a shape rather than describing one.
|
|
9
|
+
* - **`unknown` for anything nested that gets narrowed later.** The parts of a
|
|
10
|
+
* model turn are the only place a wrong assumption is invisible: an
|
|
11
|
+
* `inlineData` that is not base64 audio would decode to noise instead of
|
|
12
|
+
* throwing, so the mapper checks rather than casts.
|
|
13
|
+
*/
|
|
14
|
+
export interface LiveServerMessageDto {
|
|
15
|
+
setupComplete?: Record<string, unknown>;
|
|
16
|
+
serverContent?: {
|
|
17
|
+
modelTurn?: {
|
|
18
|
+
parts?: {
|
|
19
|
+
inlineData?: {
|
|
20
|
+
mimeType?: string;
|
|
21
|
+
data?: string;
|
|
22
|
+
};
|
|
23
|
+
text?: string;
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
inputTranscription?: {
|
|
27
|
+
text?: string;
|
|
28
|
+
};
|
|
29
|
+
outputTranscription?: {
|
|
30
|
+
text?: string;
|
|
31
|
+
};
|
|
32
|
+
interrupted?: boolean;
|
|
33
|
+
turnComplete?: boolean;
|
|
34
|
+
generationComplete?: boolean;
|
|
35
|
+
};
|
|
36
|
+
toolCall?: {
|
|
37
|
+
functionCalls?: {
|
|
38
|
+
id?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
args?: Record<string, unknown>;
|
|
41
|
+
}[];
|
|
42
|
+
};
|
|
43
|
+
toolCallCancellation?: {
|
|
44
|
+
ids?: unknown[];
|
|
45
|
+
};
|
|
46
|
+
sessionResumptionUpdate?: {
|
|
47
|
+
newHandle?: string;
|
|
48
|
+
resumable?: boolean;
|
|
49
|
+
};
|
|
50
|
+
goAway?: {
|
|
51
|
+
timeLeft?: string;
|
|
52
|
+
};
|
|
53
|
+
usageMetadata?: {
|
|
54
|
+
totalTokenCount?: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=live-server-message-dto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-server-message-dto.d.ts","sourceRoot":"","sources":["../../src/dtos/live-server-message-dto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,aAAa,CAAC,EAAE;QACd,SAAS,CAAC,EAAE;YAAE,KAAK,CAAC,EAAE;gBAAE,UAAU,CAAC,EAAE;oBAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;oBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;iBAAE,CAAC;gBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;aAAE,EAAE,CAAA;SAAE,CAAC;QAC/F,kBAAkB,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACvC,mBAAmB,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACxC,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,kBAAkB,CAAC,EAAE,OAAO,CAAC;KAC9B,CAAC;IACF,QAAQ,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;IAChG,oBAAoB,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,OAAO,EAAE,CAAA;KAAE,CAAC;IAC3C,uBAAuB,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACtE,MAAM,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/B,aAAa,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-server-message-dto.js","sourceRoot":"","sources":["../../src/dtos/live-server-message-dto.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frame that opens a live session.
|
|
3
|
+
*
|
|
4
|
+
* It carries only the model. For a token minted WITH a setup — the way this
|
|
5
|
+
* package's token server mints them — the session's configuration (system
|
|
6
|
+
* instruction, tools, modality, transcription, compression, resumption) is
|
|
7
|
+
* fixed at mint time and anything sent here is discarded. Whether a token
|
|
8
|
+
* minted without one honours a client's setup is not yet measured. The frame
|
|
9
|
+
* is mandatory either way: without it the session never reaches `setupComplete`.
|
|
10
|
+
*/
|
|
11
|
+
export interface LiveSetupRequestDto {
|
|
12
|
+
setup: {
|
|
13
|
+
model: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=live-setup-request-dto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-setup-request-dto.d.ts","sourceRoot":"","sources":["../../src/dtos/live-setup-request-dto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-setup-request-dto.js","sourceRoot":"","sources":["../../src/dtos/live-setup-request-dto.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Gemini Live session needs to connect.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* - **An ephemeral token, never an API key.** Your server holds the key and
|
|
6
|
+
* mints a short-lived `auth_tokens/…` token per session; the device only ever
|
|
7
|
+
* holds that.
|
|
8
|
+
* - **The session's configuration is baked into the token.** System
|
|
9
|
+
* instruction, tools, voice and transcription are set when the token is
|
|
10
|
+
* minted: measured against the live API, a client's own setup is discarded
|
|
11
|
+
* for a token that carries one, and tools the token did not declare simply
|
|
12
|
+
* do not exist — with no error saying so.
|
|
13
|
+
* - **`model` must be the one the token was minted for**, e.g.
|
|
14
|
+
* `models/gemini-live-2.5-flash-preview`. `wsUrl` defaults to the constrained
|
|
15
|
+
* endpoint, the only one an ephemeral token opens.
|
|
16
|
+
*/
|
|
17
|
+
export interface GeminiLiveCredentials {
|
|
18
|
+
readonly token: string;
|
|
19
|
+
readonly model: string;
|
|
20
|
+
readonly wsUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=gemini-live-credentials.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gemini-live-credentials.d.ts","sourceRoot":"","sources":["../src/gemini-live-credentials.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gemini-live-credentials.js","sourceRoot":"","sources":["../src/gemini-live-credentials.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AssistantFailure, AssistantSession, AudioFormat, Result, SessionEvent, ToolCall } from '@live-assistant/core';
|
|
2
|
+
import type { GeminiLiveCredentials } from './gemini-live-credentials';
|
|
3
|
+
/**
|
|
4
|
+
* The Live API transport: one WebSocket, its setup handshake, and the JSON
|
|
5
|
+
* frames in both directions.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* - **The token rides on the URL, and nothing else authenticates.** A browser
|
|
9
|
+
* WebSocket cannot set a header, so the credential is a query parameter or it
|
|
10
|
+
* is nowhere. Opened without it the handshake still completes and the server
|
|
11
|
+
* then closes the socket — which looks, from the client, exactly like a
|
|
12
|
+
* connection that hangs.
|
|
13
|
+
* - **Connected means `setupComplete`, not open.** The socket opens before the
|
|
14
|
+
* server has accepted the model, the tool list or the modality, and audio
|
|
15
|
+
* sent in that window is discarded silently rather than rejected. `connect`
|
|
16
|
+
* resolves on the acknowledgement, so a caller that awaits it can start the
|
|
17
|
+
* microphone knowing the frames will land.
|
|
18
|
+
* - **Listeners are notified in frame order, and one frame yields several
|
|
19
|
+
* events.** The mapper decides that order — notably `interrupted` ahead of
|
|
20
|
+
* any audio beside it — so this class must not reorder or batch them.
|
|
21
|
+
* - **A listener that throws must not kill the socket.** One consumer's bad
|
|
22
|
+
* render would otherwise take down the audio session and every other
|
|
23
|
+
* listener with it.
|
|
24
|
+
* - **The resumption handle is remembered but never acted on here.** It cannot
|
|
25
|
+
* be: a handle put in the setup frame is discarded, so continuing a session
|
|
26
|
+
* means minting a new token with it. Reconnecting also asks whether the user
|
|
27
|
+
* still wants to be talking, which is a policy question either way; this
|
|
28
|
+
* class only keeps the handle and reports the `goAway` that makes it matter.
|
|
29
|
+
* - **Whether a close was expected is tracked per socket**, not in a field. A
|
|
30
|
+
* reconnect closes the old socket and opens a new one in the same tick, and a
|
|
31
|
+
* single flag was read by the old socket's `onclose` after the new
|
|
32
|
+
* connection had already reset it — reporting a deliberate close as a drop.
|
|
33
|
+
*/
|
|
34
|
+
type SocketFactory = (url: string) => WebSocket;
|
|
35
|
+
export declare class GeminiLiveSession implements AssistantSession<GeminiLiveCredentials> {
|
|
36
|
+
private readonly createSocket;
|
|
37
|
+
readonly audioFormat: AudioFormat;
|
|
38
|
+
private socket;
|
|
39
|
+
private readonly listeners;
|
|
40
|
+
private readonly deliberatelyClosed;
|
|
41
|
+
private resumptionHandle;
|
|
42
|
+
constructor(createSocket?: SocketFactory);
|
|
43
|
+
/** The latest handle a new token can be minted with to continue after a `goAway`. */
|
|
44
|
+
get lastResumptionHandle(): string | null;
|
|
45
|
+
connect(credentials: GeminiLiveCredentials): Promise<Result<void, AssistantFailure>>;
|
|
46
|
+
sendAudio(samples: Float32Array<ArrayBuffer>): void;
|
|
47
|
+
sendText(text: string): void;
|
|
48
|
+
respondToTool(call: Pick<ToolCall, 'id' | 'name'>, response: Readonly<Record<string, unknown>>): void;
|
|
49
|
+
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
50
|
+
close(): void;
|
|
51
|
+
private get isOpen();
|
|
52
|
+
private send;
|
|
53
|
+
private parse;
|
|
54
|
+
private emit;
|
|
55
|
+
}
|
|
56
|
+
export {};
|
|
57
|
+
//# sourceMappingURL=gemini-live-session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gemini-live-session.d.ts","sourceRoot":"","sources":["../src/gemini-live-session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAE5H,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAMvE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAGH,KAAK,aAAa,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,SAAS,CAAC;AAmDhD,qBAAa,iBAAkB,YAAW,gBAAgB,CAAC,qBAAqB,CAAC;IAWnE,OAAO,CAAC,QAAQ,CAAC,YAAY;IAVzC,QAAQ,CAAC,WAAW,EAAE,WAAW,CAG/B;IAEF,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4C;IACtE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA4B;IAC/D,OAAO,CAAC,gBAAgB,CAAuB;gBAElB,YAAY,GAAE,aAA2C;IAEtF,qFAAqF;IACrF,IAAI,oBAAoB,IAAI,MAAM,GAAG,IAAI,CAExC;IAED,OAAO,CAAC,WAAW,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAmDpF,SAAS,CAAC,OAAO,EAAE,YAAY,CAAC,WAAW,CAAC,GAAG,IAAI;IAYnD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM5B,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,GAAG,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI;IAIrG,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI;IAO9D,KAAK,IAAI,IAAI;IASb,OAAO,KAAK,MAAM,GAEjB;IAED,OAAO,CAAC,IAAI;IASZ,OAAO,CAAC,KAAK;IAYb,OAAO,CAAC,IAAI;CASb"}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GeminiLiveSession = void 0;
|
|
4
|
+
const core_1 = require("@live-assistant/core");
|
|
5
|
+
const guards_1 = require("./guards");
|
|
6
|
+
const live_protocol_1 = require("./live-protocol");
|
|
7
|
+
const live_message_mapper_1 = require("./live-message-mapper");
|
|
8
|
+
const live_setup_request_1 = require("./live-setup-request");
|
|
9
|
+
const SOCKET_OPEN = 1;
|
|
10
|
+
const ARRAY_BUFFER = 'arraybuffer';
|
|
11
|
+
/**
|
|
12
|
+
* How long a socket may take to reach `setupComplete` before it is abandoned.
|
|
13
|
+
*
|
|
14
|
+
* Without it a socket that opened, took the setup frame and then said nothing
|
|
15
|
+
* left `connect` pending forever — and a caller that opens the microphone
|
|
16
|
+
* first would hold the recording device open with nothing counting against it.
|
|
17
|
+
*/
|
|
18
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
19
|
+
const QUERY_START = '?';
|
|
20
|
+
const QUERY_JOIN = '&';
|
|
21
|
+
/**
|
|
22
|
+
* The socket URL with the session's credential on it.
|
|
23
|
+
*
|
|
24
|
+
* The token is the ONLY thing that authenticates this connection — there is no
|
|
25
|
+
* header to put it in, because a browser WebSocket cannot set one. Opened
|
|
26
|
+
* without it the socket completes its handshake and is then closed by the
|
|
27
|
+
* server, which reads as "connecting, and then nothing".
|
|
28
|
+
*
|
|
29
|
+
* `access_token` is deliberately NOT percent-encoded. The name contains a
|
|
30
|
+
* slash (`auth_tokens/…`), and encoding it produced a socket the server
|
|
31
|
+
* refused — measured against the live API, along with the `key=` spelling,
|
|
32
|
+
* which it also refuses.
|
|
33
|
+
*/
|
|
34
|
+
function authenticatedUrl(credentials) {
|
|
35
|
+
const wsUrl = credentials.wsUrl ?? live_protocol_1.LiveProtocol.constrainedWsUrl;
|
|
36
|
+
const separator = wsUrl.includes(QUERY_START) ? QUERY_JOIN : QUERY_START;
|
|
37
|
+
return `${wsUrl}${separator}access_token=${credentials.token}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reads one incoming frame as text.
|
|
41
|
+
*
|
|
42
|
+
* The Live API sends its JSON as BINARY WebSocket frames, not text ones — every
|
|
43
|
+
* frame, including `setupComplete`. A transport that accepted only strings
|
|
44
|
+
* silently received nothing at all, and its unit tests passed because a fake
|
|
45
|
+
* socket naturally sends strings. `binaryType` is set to `arraybuffer` so this
|
|
46
|
+
* stays synchronous; a Blob would have to be awaited, and the mapper's ordering
|
|
47
|
+
* guarantee does not survive an await between frames.
|
|
48
|
+
*/
|
|
49
|
+
function decodeFrame(data) {
|
|
50
|
+
if ((0, guards_1.isString)(data))
|
|
51
|
+
return data;
|
|
52
|
+
if (data instanceof ArrayBuffer)
|
|
53
|
+
return new TextDecoder().decode(data);
|
|
54
|
+
if (ArrayBuffer.isView(data))
|
|
55
|
+
return new TextDecoder().decode(data.buffer);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
class GeminiLiveSession {
|
|
59
|
+
constructor(createSocket = (url) => new WebSocket(url)) {
|
|
60
|
+
this.createSocket = createSocket;
|
|
61
|
+
this.audioFormat = {
|
|
62
|
+
inputSampleRate: live_protocol_1.LiveProtocol.inputSampleRate,
|
|
63
|
+
outputSampleRate: live_protocol_1.LiveProtocol.outputSampleRate,
|
|
64
|
+
};
|
|
65
|
+
this.socket = null;
|
|
66
|
+
this.listeners = new Set();
|
|
67
|
+
this.deliberatelyClosed = new WeakSet();
|
|
68
|
+
this.resumptionHandle = null;
|
|
69
|
+
}
|
|
70
|
+
/** The latest handle a new token can be minted with to continue after a `goAway`. */
|
|
71
|
+
get lastResumptionHandle() {
|
|
72
|
+
return this.resumptionHandle;
|
|
73
|
+
}
|
|
74
|
+
connect(credentials) {
|
|
75
|
+
this.close();
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
let settled = false;
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
// Settled BEFORE closing: a runtime whose `close()` fires `onclose`
|
|
80
|
+
// synchronously would otherwise answer with "closed before ready" and
|
|
81
|
+
// this reason would never be the one anybody reads.
|
|
82
|
+
settle((0, core_1.fail)({ code: core_1.AssistantFailureCode.ConnectTimedOut }));
|
|
83
|
+
this.close();
|
|
84
|
+
}, CONNECT_TIMEOUT_MS);
|
|
85
|
+
const settle = (result) => {
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
resolve(result);
|
|
91
|
+
};
|
|
92
|
+
const socket = this.createSocket(authenticatedUrl(credentials));
|
|
93
|
+
// The server sends its JSON in BINARY frames. Without this the runtime
|
|
94
|
+
// hands them over as Blob, which cannot be read synchronously.
|
|
95
|
+
socket.binaryType = ARRAY_BUFFER;
|
|
96
|
+
this.socket = socket;
|
|
97
|
+
socket.onopen = () => {
|
|
98
|
+
socket.send(JSON.stringify((0, live_setup_request_1.toLiveSetupRequest)({ model: credentials.model })));
|
|
99
|
+
};
|
|
100
|
+
socket.onmessage = (event) => {
|
|
101
|
+
for (const mapped of this.parse(event.data)) {
|
|
102
|
+
if (mapped.kind === core_1.SessionEventKind.Ready)
|
|
103
|
+
settle((0, core_1.ok)(undefined));
|
|
104
|
+
if (mapped.kind === core_1.SessionEventKind.Resumption)
|
|
105
|
+
this.resumptionHandle = mapped.handle;
|
|
106
|
+
this.emit(mapped);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
socket.onerror = () => {
|
|
110
|
+
settle((0, core_1.fail)({ code: core_1.AssistantFailureCode.SocketFailed }));
|
|
111
|
+
};
|
|
112
|
+
socket.onclose = () => {
|
|
113
|
+
const expected = this.deliberatelyClosed.has(socket);
|
|
114
|
+
if (this.socket === socket)
|
|
115
|
+
this.socket = null;
|
|
116
|
+
settle((0, core_1.fail)({ code: core_1.AssistantFailureCode.ClosedBeforeReady }));
|
|
117
|
+
this.emit({ kind: core_1.SessionEventKind.Closed, expected });
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
sendAudio(samples) {
|
|
122
|
+
if (samples.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
// Checked before encoding, not after: capture now starts before the socket
|
|
125
|
+
// does, so every frame in that window used to be converted to base64 and
|
|
126
|
+
// then dropped by `send`.
|
|
127
|
+
if (!this.isOpen)
|
|
128
|
+
return;
|
|
129
|
+
this.send({
|
|
130
|
+
realtimeInput: { audio: { data: (0, core_1.float32ToPcm16Base64)(samples), mimeType: live_protocol_1.LiveProtocol.inputAudioMime } },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
sendText(text) {
|
|
134
|
+
if (text === '')
|
|
135
|
+
return;
|
|
136
|
+
this.send({ clientContent: { turns: [{ role: live_protocol_1.LiveProtocol.userRole, parts: [{ text }] }], turnComplete: true } });
|
|
137
|
+
}
|
|
138
|
+
respondToTool(call, response) {
|
|
139
|
+
this.send({ toolResponse: { functionResponses: [{ id: call.id, name: call.name, response }] } });
|
|
140
|
+
}
|
|
141
|
+
subscribe(listener) {
|
|
142
|
+
this.listeners.add(listener);
|
|
143
|
+
return () => {
|
|
144
|
+
this.listeners.delete(listener);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
close() {
|
|
148
|
+
const socket = this.socket;
|
|
149
|
+
this.socket = null;
|
|
150
|
+
if (socket === null)
|
|
151
|
+
return;
|
|
152
|
+
this.deliberatelyClosed.add(socket);
|
|
153
|
+
socket.close();
|
|
154
|
+
}
|
|
155
|
+
get isOpen() {
|
|
156
|
+
return this.socket !== null && this.socket.readyState === SOCKET_OPEN;
|
|
157
|
+
}
|
|
158
|
+
send(message) {
|
|
159
|
+
// A frame produced after the socket went away is dropped: it is one frame
|
|
160
|
+
// of audio, and there is nothing a caller could usefully do about it.
|
|
161
|
+
const socket = this.socket;
|
|
162
|
+
if (socket === null || socket.readyState !== SOCKET_OPEN)
|
|
163
|
+
return;
|
|
164
|
+
socket.send(JSON.stringify(message));
|
|
165
|
+
}
|
|
166
|
+
parse(data) {
|
|
167
|
+
const text = decodeFrame(data);
|
|
168
|
+
if (text === null)
|
|
169
|
+
return [];
|
|
170
|
+
try {
|
|
171
|
+
return (0, live_message_mapper_1.mapLiveServerMessage)(JSON.parse(text));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// A frame we cannot read is not a reason to drop a live conversation.
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
emit(event) {
|
|
179
|
+
for (const listener of this.listeners) {
|
|
180
|
+
try {
|
|
181
|
+
listener(event);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// One listener's failure must not take down the audio session.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
exports.GeminiLiveSession = GeminiLiveSession;
|
|
190
|
+
//# sourceMappingURL=gemini-live-session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gemini-live-session.js","sourceRoot":"","sources":["../src/gemini-live-session.ts"],"names":[],"mappings":"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"}
|
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,eAAO,MAAM,QAAQ,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,MAAmC,CAAC;AAEvF,eAAO,MAAM,gBAAgB,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,MAA6C,CAAC"}
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isNonEmptyString = exports.isString = void 0;
|
|
4
|
+
/** Narrowing helpers for frames this package did not produce. */
|
|
5
|
+
const isString = (value) => typeof value === 'string';
|
|
6
|
+
exports.isString = isString;
|
|
7
|
+
const isNonEmptyString = (value) => (0, exports.isString)(value) && value.length > 0;
|
|
8
|
+
exports.isNonEmptyString = isNonEmptyString;
|
|
9
|
+
//# sourceMappingURL=guards.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.ts"],"names":[],"mappings":";;;AAAA,iEAAiE;AAC1D,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAmB,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC;AAA1E,QAAA,QAAQ,YAAkE;AAEhF,MAAM,gBAAgB,GAAG,CAAC,KAAc,EAAmB,EAAE,CAAC,IAAA,gBAAQ,EAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;AAA5F,QAAA,gBAAgB,oBAA4E"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,YAAY,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GeminiLiveSession = void 0;
|
|
4
|
+
var gemini_live_session_1 = require("./gemini-live-session");
|
|
5
|
+
Object.defineProperty(exports, "GeminiLiveSession", { enumerable: true, get: function () { return gemini_live_session_1.GeminiLiveSession; } });
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,6DAA0D;AAAjD,wHAAA,iBAAiB,OAAA"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SessionEvent } from '@live-assistant/core';
|
|
2
|
+
import type { LiveServerMessageDto } from './dtos/live-server-message-dto';
|
|
3
|
+
export declare function mapLiveServerMessage(dto: LiveServerMessageDto): SessionEvent[];
|
|
4
|
+
//# sourceMappingURL=live-message-mapper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-message-mapper.d.ts","sourceRoot":"","sources":["../src/live-message-mapper.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAyC3E,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,oBAAoB,GAAG,YAAY,EAAE,CAgD9E"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mapLiveServerMessage = mapLiveServerMessage;
|
|
4
|
+
const core_1 = require("@live-assistant/core");
|
|
5
|
+
const guards_1 = require("./guards");
|
|
6
|
+
/**
|
|
7
|
+
* Turns one Live API frame into the events a consumer acts on.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* - **A frame is not an event; it is a bag of them.** A single `serverContent`
|
|
11
|
+
* routinely carries a transcript fragment, several audio chunks and the end
|
|
12
|
+
* of the turn at once, so this returns a list. Mapping one frame to one event
|
|
13
|
+
* would have meant picking which of them to lose.
|
|
14
|
+
* - **Order within the frame is the order they must be handled.** `interrupted`
|
|
15
|
+
* is emitted before any audio in the same frame: the server can mark a turn
|
|
16
|
+
* interrupted and still include trailing chunks of it, and handling those
|
|
17
|
+
* first would enqueue exactly the audio the flush was meant to drop.
|
|
18
|
+
* - **`turnComplete`, not `generationComplete`.** The latter fires when the
|
|
19
|
+
* model stops generating, which is before the audio it generated has been
|
|
20
|
+
* sent — treating it as the end of the turn cuts the last words off.
|
|
21
|
+
* - **`timeLeft` is a protobuf duration string** (`"9.5s"`), not a number, and
|
|
22
|
+
* parsing it as one silently yields `NaN` for every value with a unit.
|
|
23
|
+
* - **A tool call keeps its name and raw `args`.** The package does not know
|
|
24
|
+
* which tools a consumer declared, so it neither validates nor narrows them;
|
|
25
|
+
* a call is dropped only when it has no id or name, because nothing could
|
|
26
|
+
* answer it.
|
|
27
|
+
* - **Unknown frames map to nothing rather than to a failure.** The API adds
|
|
28
|
+
* message types over time and a session that fell over on an unrecognised
|
|
29
|
+
* one would be broken by a server-side release.
|
|
30
|
+
*/
|
|
31
|
+
const AUDIO_MIME_PREFIX = 'audio/';
|
|
32
|
+
const SECONDS_SUFFIX = 's';
|
|
33
|
+
const MS_PER_SECOND = 1000;
|
|
34
|
+
function parseTimeLeftMs(timeLeft) {
|
|
35
|
+
if (!(0, guards_1.isNonEmptyString)(timeLeft))
|
|
36
|
+
return 0;
|
|
37
|
+
const seconds = Number.parseFloat(timeLeft.endsWith(SECONDS_SUFFIX) ? timeLeft.slice(0, -SECONDS_SUFFIX.length) : timeLeft);
|
|
38
|
+
return Number.isFinite(seconds) ? seconds * MS_PER_SECOND : 0;
|
|
39
|
+
}
|
|
40
|
+
function mapLiveServerMessage(dto) {
|
|
41
|
+
const events = [];
|
|
42
|
+
if (dto.setupComplete !== undefined)
|
|
43
|
+
events.push({ kind: core_1.SessionEventKind.Ready });
|
|
44
|
+
const content = dto.serverContent;
|
|
45
|
+
if (content !== undefined) {
|
|
46
|
+
if (content.interrupted === true)
|
|
47
|
+
events.push({ kind: core_1.SessionEventKind.Interrupted });
|
|
48
|
+
const heard = content.inputTranscription?.text;
|
|
49
|
+
if ((0, guards_1.isNonEmptyString)(heard)) {
|
|
50
|
+
events.push({ kind: core_1.SessionEventKind.Transcript, speaker: core_1.Speaker.User, text: heard });
|
|
51
|
+
}
|
|
52
|
+
const said = content.outputTranscription?.text;
|
|
53
|
+
if ((0, guards_1.isNonEmptyString)(said)) {
|
|
54
|
+
events.push({ kind: core_1.SessionEventKind.Transcript, speaker: core_1.Speaker.Assistant, text: said });
|
|
55
|
+
}
|
|
56
|
+
for (const part of content.modelTurn?.parts ?? []) {
|
|
57
|
+
const data = part.inlineData?.data;
|
|
58
|
+
if ((0, guards_1.isNonEmptyString)(data) && (part.inlineData?.mimeType ?? '').startsWith(AUDIO_MIME_PREFIX)) {
|
|
59
|
+
events.push({ kind: core_1.SessionEventKind.Audio, samples: (0, core_1.pcm16Base64ToFloat32)(data) });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (content.turnComplete === true)
|
|
63
|
+
events.push({ kind: core_1.SessionEventKind.TurnComplete });
|
|
64
|
+
}
|
|
65
|
+
for (const call of dto.toolCall?.functionCalls ?? []) {
|
|
66
|
+
if (!(0, guards_1.isNonEmptyString)(call.id) || !(0, guards_1.isNonEmptyString)(call.name))
|
|
67
|
+
continue;
|
|
68
|
+
events.push({ kind: core_1.SessionEventKind.ToolCall, call: { id: call.id, name: call.name, args: call.args ?? {} } });
|
|
69
|
+
}
|
|
70
|
+
const cancelled = (dto.toolCallCancellation?.ids ?? []).filter(guards_1.isNonEmptyString);
|
|
71
|
+
if (cancelled.length > 0)
|
|
72
|
+
events.push({ kind: core_1.SessionEventKind.ToolCallCancelled, callIds: cancelled });
|
|
73
|
+
const handle = dto.sessionResumptionUpdate?.newHandle;
|
|
74
|
+
if ((0, guards_1.isNonEmptyString)(handle))
|
|
75
|
+
events.push({ kind: core_1.SessionEventKind.Resumption, handle });
|
|
76
|
+
if (dto.goAway !== undefined) {
|
|
77
|
+
events.push({ kind: core_1.SessionEventKind.GoAway, timeLeftMs: parseTimeLeftMs(dto.goAway.timeLeft) });
|
|
78
|
+
}
|
|
79
|
+
const totalTokens = dto.usageMetadata?.totalTokenCount;
|
|
80
|
+
if (typeof totalTokens === 'number')
|
|
81
|
+
events.push({ kind: core_1.SessionEventKind.Usage, totalTokens });
|
|
82
|
+
return events;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=live-message-mapper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-message-mapper.js","sourceRoot":"","sources":["../src/live-message-mapper.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Live API's fixed wire values, defined once.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* - **The input rate is the client's to state; the output rate is not.** The
|
|
6
|
+
* microphone frame declares 16 kHz because that is what the audio layer
|
|
7
|
+
* resamples to. The model answers at 24 kHz regardless.
|
|
8
|
+
* - **`constrainedWsUrl` is the only socket an ephemeral token opens.** The
|
|
9
|
+
* ordinary `BidiGenerateContent` method refuses one ("Method doesn't allow
|
|
10
|
+
* unregistered callers"), measured against the live API.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LiveProtocol: {
|
|
13
|
+
readonly constrainedWsUrl: string;
|
|
14
|
+
readonly inputAudioMime: "audio/pcm;rate=16000";
|
|
15
|
+
/** The wire `role` of a typed turn. Gemini's word, kept apart from `Speaker` so renaming one cannot break the other. */
|
|
16
|
+
readonly userRole: "user";
|
|
17
|
+
readonly outputSampleRate: 24000;
|
|
18
|
+
readonly inputSampleRate: 16000;
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=live-protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-protocol.d.ts","sourceRoot":"","sources":["../src/live-protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY;;;IAKvB,wHAAwH;;;;CAIhH,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LiveProtocol = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The Live API's fixed wire values, defined once.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* - **The input rate is the client's to state; the output rate is not.** The
|
|
9
|
+
* microphone frame declares 16 kHz because that is what the audio layer
|
|
10
|
+
* resamples to. The model answers at 24 kHz regardless.
|
|
11
|
+
* - **`constrainedWsUrl` is the only socket an ephemeral token opens.** The
|
|
12
|
+
* ordinary `BidiGenerateContent` method refuses one ("Method doesn't allow
|
|
13
|
+
* unregistered callers"), measured against the live API.
|
|
14
|
+
*/
|
|
15
|
+
exports.LiveProtocol = {
|
|
16
|
+
constrainedWsUrl: 'wss://generativelanguage.googleapis.com/ws/' +
|
|
17
|
+
'google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained',
|
|
18
|
+
inputAudioMime: 'audio/pcm;rate=16000',
|
|
19
|
+
/** The wire `role` of a typed turn. Gemini's word, kept apart from `Speaker` so renaming one cannot break the other. */
|
|
20
|
+
userRole: 'user',
|
|
21
|
+
outputSampleRate: 24_000,
|
|
22
|
+
inputSampleRate: 16_000,
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=live-protocol.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-protocol.js","sourceRoot":"","sources":["../src/live-protocol.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;GAUG;AACU,QAAA,YAAY,GAAG;IAC1B,gBAAgB,EACd,6CAA6C;QAC7C,uFAAuF;IACzF,cAAc,EAAE,sBAAsB;IACtC,wHAAwH;IACxH,QAAQ,EAAE,MAAM;IAChB,gBAAgB,EAAE,MAAM;IACxB,eAAe,EAAE,MAAM;CACf,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LiveSetupRequestDto } from './dtos/live-setup-request-dto';
|
|
2
|
+
/**
|
|
3
|
+
* The first frame on the socket. It only names the model: for a token minted
|
|
4
|
+
* with a setup, the instruction, tools and voice are baked in at mint time and
|
|
5
|
+
* the Live API discards whatever a client puts here beyond the model.
|
|
6
|
+
*/
|
|
7
|
+
export declare const toLiveSetupRequest: (input: {
|
|
8
|
+
model: string;
|
|
9
|
+
}) => LiveSetupRequestDto;
|
|
10
|
+
//# sourceMappingURL=live-setup-request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-setup-request.d.ts","sourceRoot":"","sources":["../src/live-setup-request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAEzE;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,KAAG,mBAE5D,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toLiveSetupRequest = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The first frame on the socket. It only names the model: for a token minted
|
|
6
|
+
* with a setup, the instruction, tools and voice are baked in at mint time and
|
|
7
|
+
* the Live API discards whatever a client puts here beyond the model.
|
|
8
|
+
*/
|
|
9
|
+
const toLiveSetupRequest = (input) => ({
|
|
10
|
+
setup: { model: input.model },
|
|
11
|
+
});
|
|
12
|
+
exports.toLiveSetupRequest = toLiveSetupRequest;
|
|
13
|
+
//# sourceMappingURL=live-setup-request.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-setup-request.js","sourceRoot":"","sources":["../src/live-setup-request.ts"],"names":[],"mappings":";;;AAEA;;;;GAIG;AACI,MAAM,kBAAkB,GAAG,CAAC,KAAwB,EAAuB,EAAE,CAAC,CAAC;IACpF,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE;CAC9B,CAAC,CAAC;AAFU,QAAA,kBAAkB,sBAE5B"}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@live-assistant/gemini",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Gemini Live adapter for @live-assistant/core: the socket, its setup handshake and the frames in both directions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Recep Tayyip Ekşi",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Recipely-Team/live-assistant.git",
|
|
10
|
+
"directory": "packages/assistant-gemini"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Recipely-Team/live-assistant/tree/main/packages/assistant-gemini#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Recipely-Team/live-assistant/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"gemini",
|
|
18
|
+
"gemini-live",
|
|
19
|
+
"voice-assistant",
|
|
20
|
+
"realtime",
|
|
21
|
+
"websocket",
|
|
22
|
+
"ai"
|
|
23
|
+
],
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"types": "dist/index.d.ts",
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"!src/**/__tests__",
|
|
32
|
+
"!src/**/__fixtures__"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
37
|
+
"prepack": "npm run build"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@live-assistant/core": "0.1.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One frame as the Live API sends it, before anything is trusted about it.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* - **Every field is optional because the wire says so.** A frame carries
|
|
6
|
+
* exactly one of these envelopes, and which one is not discriminated by any
|
|
7
|
+
* tag — the presence of the key IS the tag. Modelling it as a union here
|
|
8
|
+
* would be asserting a shape rather than describing one.
|
|
9
|
+
* - **`unknown` for anything nested that gets narrowed later.** The parts of a
|
|
10
|
+
* model turn are the only place a wrong assumption is invisible: an
|
|
11
|
+
* `inlineData` that is not base64 audio would decode to noise instead of
|
|
12
|
+
* throwing, so the mapper checks rather than casts.
|
|
13
|
+
*/
|
|
14
|
+
export interface LiveServerMessageDto {
|
|
15
|
+
setupComplete?: Record<string, unknown>;
|
|
16
|
+
serverContent?: {
|
|
17
|
+
modelTurn?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] };
|
|
18
|
+
inputTranscription?: { text?: string };
|
|
19
|
+
outputTranscription?: { text?: string };
|
|
20
|
+
interrupted?: boolean;
|
|
21
|
+
turnComplete?: boolean;
|
|
22
|
+
generationComplete?: boolean;
|
|
23
|
+
};
|
|
24
|
+
toolCall?: { functionCalls?: { id?: string; name?: string; args?: Record<string, unknown> }[] };
|
|
25
|
+
toolCallCancellation?: { ids?: unknown[] };
|
|
26
|
+
sessionResumptionUpdate?: { newHandle?: string; resumable?: boolean };
|
|
27
|
+
goAway?: { timeLeft?: string };
|
|
28
|
+
usageMetadata?: { totalTokenCount?: number };
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frame that opens a live session.
|
|
3
|
+
*
|
|
4
|
+
* It carries only the model. For a token minted WITH a setup — the way this
|
|
5
|
+
* package's token server mints them — the session's configuration (system
|
|
6
|
+
* instruction, tools, modality, transcription, compression, resumption) is
|
|
7
|
+
* fixed at mint time and anything sent here is discarded. Whether a token
|
|
8
|
+
* minted without one honours a client's setup is not yet measured. The frame
|
|
9
|
+
* is mandatory either way: without it the session never reaches `setupComplete`.
|
|
10
|
+
*/
|
|
11
|
+
export interface LiveSetupRequestDto {
|
|
12
|
+
setup: { model: string };
|
|
13
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Gemini Live session needs to connect.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* - **An ephemeral token, never an API key.** Your server holds the key and
|
|
6
|
+
* mints a short-lived `auth_tokens/…` token per session; the device only ever
|
|
7
|
+
* holds that.
|
|
8
|
+
* - **The session's configuration is baked into the token.** System
|
|
9
|
+
* instruction, tools, voice and transcription are set when the token is
|
|
10
|
+
* minted: measured against the live API, a client's own setup is discarded
|
|
11
|
+
* for a token that carries one, and tools the token did not declare simply
|
|
12
|
+
* do not exist — with no error saying so.
|
|
13
|
+
* - **`model` must be the one the token was minted for**, e.g.
|
|
14
|
+
* `models/gemini-live-2.5-flash-preview`. `wsUrl` defaults to the constrained
|
|
15
|
+
* endpoint, the only one an ephemeral token opens.
|
|
16
|
+
*/
|
|
17
|
+
export interface GeminiLiveCredentials {
|
|
18
|
+
readonly token: string;
|
|
19
|
+
readonly model: string;
|
|
20
|
+
readonly wsUrl?: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { AssistantFailureCode, SessionEventKind, fail, float32ToPcm16Base64, ok } from '@live-assistant/core';
|
|
2
|
+
import type { AssistantFailure, AssistantSession, AudioFormat, Result, SessionEvent, ToolCall } from '@live-assistant/core';
|
|
3
|
+
import type { LiveServerMessageDto } from './dtos/live-server-message-dto';
|
|
4
|
+
import type { GeminiLiveCredentials } from './gemini-live-credentials';
|
|
5
|
+
import { isString } from './guards';
|
|
6
|
+
import { LiveProtocol } from './live-protocol';
|
|
7
|
+
import { mapLiveServerMessage } from './live-message-mapper';
|
|
8
|
+
import { toLiveSetupRequest } from './live-setup-request';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The Live API transport: one WebSocket, its setup handshake, and the JSON
|
|
12
|
+
* frames in both directions.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* - **The token rides on the URL, and nothing else authenticates.** A browser
|
|
16
|
+
* WebSocket cannot set a header, so the credential is a query parameter or it
|
|
17
|
+
* is nowhere. Opened without it the handshake still completes and the server
|
|
18
|
+
* then closes the socket — which looks, from the client, exactly like a
|
|
19
|
+
* connection that hangs.
|
|
20
|
+
* - **Connected means `setupComplete`, not open.** The socket opens before the
|
|
21
|
+
* server has accepted the model, the tool list or the modality, and audio
|
|
22
|
+
* sent in that window is discarded silently rather than rejected. `connect`
|
|
23
|
+
* resolves on the acknowledgement, so a caller that awaits it can start the
|
|
24
|
+
* microphone knowing the frames will land.
|
|
25
|
+
* - **Listeners are notified in frame order, and one frame yields several
|
|
26
|
+
* events.** The mapper decides that order — notably `interrupted` ahead of
|
|
27
|
+
* any audio beside it — so this class must not reorder or batch them.
|
|
28
|
+
* - **A listener that throws must not kill the socket.** One consumer's bad
|
|
29
|
+
* render would otherwise take down the audio session and every other
|
|
30
|
+
* listener with it.
|
|
31
|
+
* - **The resumption handle is remembered but never acted on here.** It cannot
|
|
32
|
+
* be: a handle put in the setup frame is discarded, so continuing a session
|
|
33
|
+
* means minting a new token with it. Reconnecting also asks whether the user
|
|
34
|
+
* still wants to be talking, which is a policy question either way; this
|
|
35
|
+
* class only keeps the handle and reports the `goAway` that makes it matter.
|
|
36
|
+
* - **Whether a close was expected is tracked per socket**, not in a field. A
|
|
37
|
+
* reconnect closes the old socket and opens a new one in the same tick, and a
|
|
38
|
+
* single flag was read by the old socket's `onclose` after the new
|
|
39
|
+
* connection had already reset it — reporting a deliberate close as a drop.
|
|
40
|
+
*/
|
|
41
|
+
// Injected so the whole state machine can be driven against a fake, which is
|
|
42
|
+
// the only way any of this is testable without a network and a key.
|
|
43
|
+
type SocketFactory = (url: string) => WebSocket;
|
|
44
|
+
|
|
45
|
+
const SOCKET_OPEN = 1;
|
|
46
|
+
const ARRAY_BUFFER = 'arraybuffer';
|
|
47
|
+
/**
|
|
48
|
+
* How long a socket may take to reach `setupComplete` before it is abandoned.
|
|
49
|
+
*
|
|
50
|
+
* Without it a socket that opened, took the setup frame and then said nothing
|
|
51
|
+
* left `connect` pending forever — and a caller that opens the microphone
|
|
52
|
+
* first would hold the recording device open with nothing counting against it.
|
|
53
|
+
*/
|
|
54
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
55
|
+
const QUERY_START = '?';
|
|
56
|
+
const QUERY_JOIN = '&';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The socket URL with the session's credential on it.
|
|
60
|
+
*
|
|
61
|
+
* The token is the ONLY thing that authenticates this connection — there is no
|
|
62
|
+
* header to put it in, because a browser WebSocket cannot set one. Opened
|
|
63
|
+
* without it the socket completes its handshake and is then closed by the
|
|
64
|
+
* server, which reads as "connecting, and then nothing".
|
|
65
|
+
*
|
|
66
|
+
* `access_token` is deliberately NOT percent-encoded. The name contains a
|
|
67
|
+
* slash (`auth_tokens/…`), and encoding it produced a socket the server
|
|
68
|
+
* refused — measured against the live API, along with the `key=` spelling,
|
|
69
|
+
* which it also refuses.
|
|
70
|
+
*/
|
|
71
|
+
function authenticatedUrl(credentials: GeminiLiveCredentials): string {
|
|
72
|
+
const wsUrl = credentials.wsUrl ?? LiveProtocol.constrainedWsUrl;
|
|
73
|
+
const separator = wsUrl.includes(QUERY_START) ? QUERY_JOIN : QUERY_START;
|
|
74
|
+
return `${wsUrl}${separator}access_token=${credentials.token}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reads one incoming frame as text.
|
|
79
|
+
*
|
|
80
|
+
* The Live API sends its JSON as BINARY WebSocket frames, not text ones — every
|
|
81
|
+
* frame, including `setupComplete`. A transport that accepted only strings
|
|
82
|
+
* silently received nothing at all, and its unit tests passed because a fake
|
|
83
|
+
* socket naturally sends strings. `binaryType` is set to `arraybuffer` so this
|
|
84
|
+
* stays synchronous; a Blob would have to be awaited, and the mapper's ordering
|
|
85
|
+
* guarantee does not survive an await between frames.
|
|
86
|
+
*/
|
|
87
|
+
function decodeFrame(data: unknown): string | null {
|
|
88
|
+
if (isString(data)) return data;
|
|
89
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
90
|
+
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data.buffer as ArrayBuffer);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class GeminiLiveSession implements AssistantSession<GeminiLiveCredentials> {
|
|
95
|
+
readonly audioFormat: AudioFormat = {
|
|
96
|
+
inputSampleRate: LiveProtocol.inputSampleRate,
|
|
97
|
+
outputSampleRate: LiveProtocol.outputSampleRate,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
private socket: WebSocket | null = null;
|
|
101
|
+
private readonly listeners = new Set<(event: SessionEvent) => void>();
|
|
102
|
+
private readonly deliberatelyClosed = new WeakSet<WebSocket>();
|
|
103
|
+
private resumptionHandle: string | null = null;
|
|
104
|
+
|
|
105
|
+
constructor(private readonly createSocket: SocketFactory = (url) => new WebSocket(url)) {}
|
|
106
|
+
|
|
107
|
+
/** The latest handle a new token can be minted with to continue after a `goAway`. */
|
|
108
|
+
get lastResumptionHandle(): string | null {
|
|
109
|
+
return this.resumptionHandle;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
connect(credentials: GeminiLiveCredentials): Promise<Result<void, AssistantFailure>> {
|
|
113
|
+
this.close();
|
|
114
|
+
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
let settled = false;
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
// Settled BEFORE closing: a runtime whose `close()` fires `onclose`
|
|
119
|
+
// synchronously would otherwise answer with "closed before ready" and
|
|
120
|
+
// this reason would never be the one anybody reads.
|
|
121
|
+
settle(fail({ code: AssistantFailureCode.ConnectTimedOut }));
|
|
122
|
+
this.close();
|
|
123
|
+
}, CONNECT_TIMEOUT_MS);
|
|
124
|
+
|
|
125
|
+
const settle = (result: Result<void, AssistantFailure>): void => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
resolve(result);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const socket = this.createSocket(authenticatedUrl(credentials));
|
|
133
|
+
// The server sends its JSON in BINARY frames. Without this the runtime
|
|
134
|
+
// hands them over as Blob, which cannot be read synchronously.
|
|
135
|
+
socket.binaryType = ARRAY_BUFFER;
|
|
136
|
+
this.socket = socket;
|
|
137
|
+
|
|
138
|
+
socket.onopen = () => {
|
|
139
|
+
socket.send(JSON.stringify(toLiveSetupRequest({ model: credentials.model })));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
socket.onmessage = (event) => {
|
|
143
|
+
for (const mapped of this.parse(event.data)) {
|
|
144
|
+
if (mapped.kind === SessionEventKind.Ready) settle(ok(undefined));
|
|
145
|
+
if (mapped.kind === SessionEventKind.Resumption) this.resumptionHandle = mapped.handle;
|
|
146
|
+
this.emit(mapped);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
socket.onerror = () => {
|
|
151
|
+
settle(fail({ code: AssistantFailureCode.SocketFailed }));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
socket.onclose = () => {
|
|
155
|
+
const expected = this.deliberatelyClosed.has(socket);
|
|
156
|
+
if (this.socket === socket) this.socket = null;
|
|
157
|
+
settle(fail({ code: AssistantFailureCode.ClosedBeforeReady }));
|
|
158
|
+
this.emit({ kind: SessionEventKind.Closed, expected });
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
sendAudio(samples: Float32Array<ArrayBuffer>): void {
|
|
164
|
+
if (samples.length === 0) return;
|
|
165
|
+
// Checked before encoding, not after: capture now starts before the socket
|
|
166
|
+
// does, so every frame in that window used to be converted to base64 and
|
|
167
|
+
// then dropped by `send`.
|
|
168
|
+
if (!this.isOpen) return;
|
|
169
|
+
|
|
170
|
+
this.send({
|
|
171
|
+
realtimeInput: { audio: { data: float32ToPcm16Base64(samples), mimeType: LiveProtocol.inputAudioMime } },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
sendText(text: string): void {
|
|
176
|
+
if (text === '') return;
|
|
177
|
+
|
|
178
|
+
this.send({ clientContent: { turns: [{ role: LiveProtocol.userRole, parts: [{ text }] }], turnComplete: true } });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
respondToTool(call: Pick<ToolCall, 'id' | 'name'>, response: Readonly<Record<string, unknown>>): void {
|
|
182
|
+
this.send({ toolResponse: { functionResponses: [{ id: call.id, name: call.name, response }] } });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
subscribe(listener: (event: SessionEvent) => void): () => void {
|
|
186
|
+
this.listeners.add(listener);
|
|
187
|
+
return () => {
|
|
188
|
+
this.listeners.delete(listener);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
close(): void {
|
|
193
|
+
const socket = this.socket;
|
|
194
|
+
this.socket = null;
|
|
195
|
+
if (socket === null) return;
|
|
196
|
+
|
|
197
|
+
this.deliberatelyClosed.add(socket);
|
|
198
|
+
socket.close();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private get isOpen(): boolean {
|
|
202
|
+
return this.socket !== null && this.socket.readyState === SOCKET_OPEN;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private send(message: unknown): void {
|
|
206
|
+
// A frame produced after the socket went away is dropped: it is one frame
|
|
207
|
+
// of audio, and there is nothing a caller could usefully do about it.
|
|
208
|
+
const socket = this.socket;
|
|
209
|
+
if (socket === null || socket.readyState !== SOCKET_OPEN) return;
|
|
210
|
+
|
|
211
|
+
socket.send(JSON.stringify(message));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private parse(data: unknown): SessionEvent[] {
|
|
215
|
+
const text = decodeFrame(data);
|
|
216
|
+
if (text === null) return [];
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
return mapLiveServerMessage(JSON.parse(text) as LiveServerMessageDto);
|
|
220
|
+
} catch {
|
|
221
|
+
// A frame we cannot read is not a reason to drop a live conversation.
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private emit(event: SessionEvent): void {
|
|
227
|
+
for (const listener of this.listeners) {
|
|
228
|
+
try {
|
|
229
|
+
listener(event);
|
|
230
|
+
} catch {
|
|
231
|
+
// One listener's failure must not take down the audio session.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/guards.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SessionEventKind, Speaker, pcm16Base64ToFloat32 } from '@live-assistant/core';
|
|
2
|
+
import type { SessionEvent } from '@live-assistant/core';
|
|
3
|
+
import type { LiveServerMessageDto } from './dtos/live-server-message-dto';
|
|
4
|
+
import { isNonEmptyString } from './guards';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Turns one Live API frame into the events a consumer acts on.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* - **A frame is not an event; it is a bag of them.** A single `serverContent`
|
|
11
|
+
* routinely carries a transcript fragment, several audio chunks and the end
|
|
12
|
+
* of the turn at once, so this returns a list. Mapping one frame to one event
|
|
13
|
+
* would have meant picking which of them to lose.
|
|
14
|
+
* - **Order within the frame is the order they must be handled.** `interrupted`
|
|
15
|
+
* is emitted before any audio in the same frame: the server can mark a turn
|
|
16
|
+
* interrupted and still include trailing chunks of it, and handling those
|
|
17
|
+
* first would enqueue exactly the audio the flush was meant to drop.
|
|
18
|
+
* - **`turnComplete`, not `generationComplete`.** The latter fires when the
|
|
19
|
+
* model stops generating, which is before the audio it generated has been
|
|
20
|
+
* sent — treating it as the end of the turn cuts the last words off.
|
|
21
|
+
* - **`timeLeft` is a protobuf duration string** (`"9.5s"`), not a number, and
|
|
22
|
+
* parsing it as one silently yields `NaN` for every value with a unit.
|
|
23
|
+
* - **A tool call keeps its name and raw `args`.** The package does not know
|
|
24
|
+
* which tools a consumer declared, so it neither validates nor narrows them;
|
|
25
|
+
* a call is dropped only when it has no id or name, because nothing could
|
|
26
|
+
* answer it.
|
|
27
|
+
* - **Unknown frames map to nothing rather than to a failure.** The API adds
|
|
28
|
+
* message types over time and a session that fell over on an unrecognised
|
|
29
|
+
* one would be broken by a server-side release.
|
|
30
|
+
*/
|
|
31
|
+
const AUDIO_MIME_PREFIX = 'audio/';
|
|
32
|
+
const SECONDS_SUFFIX = 's';
|
|
33
|
+
const MS_PER_SECOND = 1000;
|
|
34
|
+
|
|
35
|
+
function parseTimeLeftMs(timeLeft: string | undefined): number {
|
|
36
|
+
if (!isNonEmptyString(timeLeft)) return 0;
|
|
37
|
+
|
|
38
|
+
const seconds = Number.parseFloat(
|
|
39
|
+
timeLeft.endsWith(SECONDS_SUFFIX) ? timeLeft.slice(0, -SECONDS_SUFFIX.length) : timeLeft,
|
|
40
|
+
);
|
|
41
|
+
return Number.isFinite(seconds) ? seconds * MS_PER_SECOND : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function mapLiveServerMessage(dto: LiveServerMessageDto): SessionEvent[] {
|
|
45
|
+
const events: SessionEvent[] = [];
|
|
46
|
+
|
|
47
|
+
if (dto.setupComplete !== undefined) events.push({ kind: SessionEventKind.Ready });
|
|
48
|
+
|
|
49
|
+
const content = dto.serverContent;
|
|
50
|
+
if (content !== undefined) {
|
|
51
|
+
if (content.interrupted === true) events.push({ kind: SessionEventKind.Interrupted });
|
|
52
|
+
|
|
53
|
+
const heard = content.inputTranscription?.text;
|
|
54
|
+
if (isNonEmptyString(heard)) {
|
|
55
|
+
events.push({ kind: SessionEventKind.Transcript, speaker: Speaker.User, text: heard });
|
|
56
|
+
}
|
|
57
|
+
const said = content.outputTranscription?.text;
|
|
58
|
+
if (isNonEmptyString(said)) {
|
|
59
|
+
events.push({ kind: SessionEventKind.Transcript, speaker: Speaker.Assistant, text: said });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const part of content.modelTurn?.parts ?? []) {
|
|
63
|
+
const data = part.inlineData?.data;
|
|
64
|
+
if (isNonEmptyString(data) && (part.inlineData?.mimeType ?? '').startsWith(AUDIO_MIME_PREFIX)) {
|
|
65
|
+
events.push({ kind: SessionEventKind.Audio, samples: pcm16Base64ToFloat32(data) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (content.turnComplete === true) events.push({ kind: SessionEventKind.TurnComplete });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const call of dto.toolCall?.functionCalls ?? []) {
|
|
73
|
+
if (!isNonEmptyString(call.id) || !isNonEmptyString(call.name)) continue;
|
|
74
|
+
|
|
75
|
+
events.push({ kind: SessionEventKind.ToolCall, call: { id: call.id, name: call.name, args: call.args ?? {} } });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cancelled = (dto.toolCallCancellation?.ids ?? []).filter(isNonEmptyString);
|
|
79
|
+
if (cancelled.length > 0) events.push({ kind: SessionEventKind.ToolCallCancelled, callIds: cancelled });
|
|
80
|
+
|
|
81
|
+
const handle = dto.sessionResumptionUpdate?.newHandle;
|
|
82
|
+
if (isNonEmptyString(handle)) events.push({ kind: SessionEventKind.Resumption, handle });
|
|
83
|
+
|
|
84
|
+
if (dto.goAway !== undefined) {
|
|
85
|
+
events.push({ kind: SessionEventKind.GoAway, timeLeftMs: parseTimeLeftMs(dto.goAway.timeLeft) });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const totalTokens = dto.usageMetadata?.totalTokenCount;
|
|
89
|
+
if (typeof totalTokens === 'number') events.push({ kind: SessionEventKind.Usage, totalTokens });
|
|
90
|
+
|
|
91
|
+
return events;
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Live API's fixed wire values, defined once.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* - **The input rate is the client's to state; the output rate is not.** The
|
|
6
|
+
* microphone frame declares 16 kHz because that is what the audio layer
|
|
7
|
+
* resamples to. The model answers at 24 kHz regardless.
|
|
8
|
+
* - **`constrainedWsUrl` is the only socket an ephemeral token opens.** The
|
|
9
|
+
* ordinary `BidiGenerateContent` method refuses one ("Method doesn't allow
|
|
10
|
+
* unregistered callers"), measured against the live API.
|
|
11
|
+
*/
|
|
12
|
+
export const LiveProtocol = {
|
|
13
|
+
constrainedWsUrl:
|
|
14
|
+
'wss://generativelanguage.googleapis.com/ws/' +
|
|
15
|
+
'google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained',
|
|
16
|
+
inputAudioMime: 'audio/pcm;rate=16000',
|
|
17
|
+
/** The wire `role` of a typed turn. Gemini's word, kept apart from `Speaker` so renaming one cannot break the other. */
|
|
18
|
+
userRole: 'user',
|
|
19
|
+
outputSampleRate: 24_000,
|
|
20
|
+
inputSampleRate: 16_000,
|
|
21
|
+
} as const;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LiveSetupRequestDto } from './dtos/live-setup-request-dto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The first frame on the socket. It only names the model: for a token minted
|
|
5
|
+
* with a setup, the instruction, tools and voice are baked in at mint time and
|
|
6
|
+
* the Live API discards whatever a client puts here beyond the model.
|
|
7
|
+
*/
|
|
8
|
+
export const toLiveSetupRequest = (input: { model: string }): LiveSetupRequestDto => ({
|
|
9
|
+
setup: { model: input.model },
|
|
10
|
+
});
|