@keyframelabs/sdk 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/README.md +148 -0
- package/package.json +39 -0
- package/src/PersonaSession.ts +324 -0
- package/src/audio-utils.ts +112 -0
- package/src/index.ts +54 -0
- package/src/types.ts +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @keyframelabs/sdk
|
|
2
|
+
|
|
3
|
+
Browser SDK for KeyframeLab's Persona avatar sessions. Connect any voice AI agent to a real-time avatar.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @keyframelabs/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
On your node / python backend, create a session using your KeyframeLabs API key.
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
const response = await fetch('https://api.keyframelabs.com/v1/session'
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
Authorization: `Bearer ${KFL_API_KEY}`,
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
persona_id: "luna", // or cosmo or astro, etc.
|
|
24
|
+
model_id: "persona-1-live"
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Contains your serverUrl and participantToken
|
|
33
|
+
const result = await response.json();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then, using the client on the browser:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { createClient } from '@keyframelabs/sdk';
|
|
40
|
+
|
|
41
|
+
// Create a Persona client
|
|
42
|
+
const persona = createClient({
|
|
43
|
+
serverUrl: "wss://...",
|
|
44
|
+
participantToken: "A6gB...",
|
|
45
|
+
onVideoTrack: (track) => {
|
|
46
|
+
// Some HTML video element
|
|
47
|
+
videoElement.srcObject = new MediaStream([track]);
|
|
48
|
+
},
|
|
49
|
+
onAudioTrack: (track) => {
|
|
50
|
+
// Some HTML audio element
|
|
51
|
+
audioElement.srcObject = new MediaStream([track]);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Connect to the avatar
|
|
56
|
+
await persona.connect();
|
|
57
|
+
|
|
58
|
+
// Send audio from your voice AI agent (24kHz 16-bit PCM)
|
|
59
|
+
persona.sendAudio(pcmAudioBytes);
|
|
60
|
+
|
|
61
|
+
// Signal an interruption (clears pending frames)
|
|
62
|
+
persona.interrupt();
|
|
63
|
+
|
|
64
|
+
// Close when done
|
|
65
|
+
await persona.close();
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
### `createClient(options)`
|
|
71
|
+
|
|
72
|
+
Create a new Persona client.
|
|
73
|
+
|
|
74
|
+
**Options:**
|
|
75
|
+
- `personaId` - Persona ID (e.g., 'luna', 'cosmo')
|
|
76
|
+
- `apiUrl` - Central API URL (optional)
|
|
77
|
+
- `apiKey` - API key for authentication (optional)
|
|
78
|
+
- `onVideoTrack` - Callback when video track is available
|
|
79
|
+
- `onAudioTrack` - Callback when audio track is available
|
|
80
|
+
- `onStateChange` - Callback when session state changes
|
|
81
|
+
- `onError` - Callback on error
|
|
82
|
+
|
|
83
|
+
### `PersonaSession`
|
|
84
|
+
|
|
85
|
+
The client instance returned by `createClient()`.
|
|
86
|
+
|
|
87
|
+
**Methods:**
|
|
88
|
+
- `connect()` - Connect to the avatar session
|
|
89
|
+
- `sendAudio(pcmData)` - Send 24kHz 16-bit PCM audio
|
|
90
|
+
- `interrupt()` - Signal an interruption (clears pending frames)
|
|
91
|
+
- `close()` - Close the session
|
|
92
|
+
|
|
93
|
+
**Properties:**
|
|
94
|
+
- `state` - Current session state ('disconnected', 'connecting', 'connected', 'error')
|
|
95
|
+
|
|
96
|
+
## Integrating Voice AI Agents
|
|
97
|
+
|
|
98
|
+
The SDK is intentionally minimal - it only handles the avatar connection. You bring your own voice AI agent (Gemini, ElevenLabs, OpenAI, etc.).
|
|
99
|
+
|
|
100
|
+
Example integration pattern:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { createClient } from '@keyframelabs/sdk';
|
|
104
|
+
// Your agent implementation (copy from experiments/src/agents/)
|
|
105
|
+
import { GeminiLiveAgent } from './agents/gemini-live';
|
|
106
|
+
|
|
107
|
+
const persona = createClient({
|
|
108
|
+
personaId: 'luna',
|
|
109
|
+
onVideoTrack: (track) => {
|
|
110
|
+
videoElement.srcObject = new MediaStream([track]);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const agent = new GeminiLiveAgent();
|
|
115
|
+
|
|
116
|
+
// Wire agent audio to persona
|
|
117
|
+
agent.on('audio', (pcmData) => persona.sendAudio(pcmData));
|
|
118
|
+
|
|
119
|
+
// Handle interruptions
|
|
120
|
+
agent.on('interrupted', () => persona.interrupt());
|
|
121
|
+
|
|
122
|
+
// Connect both
|
|
123
|
+
await persona.connect();
|
|
124
|
+
await agent.connect({ apiKey: 'your-gemini-key' });
|
|
125
|
+
|
|
126
|
+
// Send microphone audio to agent
|
|
127
|
+
// (capture and send PCM to agent.sendAudio())
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Architecture
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
Browser GPU Node
|
|
134
|
+
┌─────────────────┐ ┌─────────────────┐
|
|
135
|
+
│ Microphone │──PCM 16kHz──▶│ │
|
|
136
|
+
│ ↓ │ │ │
|
|
137
|
+
│ Voice AI Agent │ │ AvatarSession │
|
|
138
|
+
│ ↓ │ │ │
|
|
139
|
+
│ PersonaSession │──DataStream─▶│ ↓ │
|
|
140
|
+
│ ↑ │ │ Inference │
|
|
141
|
+
│ Video Element │◀──WebRTC────│ ↓ │
|
|
142
|
+
└─────────────────┘ │ Video │
|
|
143
|
+
└─────────────────┘
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@keyframelabs/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser SDK for KeyframeLab's Persona avatar sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"livekit-client": "^2.15.14"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@google/genai": "^1.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@google/genai": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"dev": "tsc --watch"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PersonaSession - Core SDK class for connecting to Persona avatars.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Creating sessions via Central API
|
|
6
|
+
* - Connecting to LiveKit room as "user" participant
|
|
7
|
+
* - Sending audio to "persona" via DataStream
|
|
8
|
+
* - Receiving video from "persona"
|
|
9
|
+
* - Interrupt signaling
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Room,
|
|
14
|
+
RoomEvent,
|
|
15
|
+
Track,
|
|
16
|
+
RemoteTrack,
|
|
17
|
+
RemoteParticipant,
|
|
18
|
+
DisconnectReason,
|
|
19
|
+
} from 'livekit-client';
|
|
20
|
+
|
|
21
|
+
// ByteStreamWriter type from livekit-client (not exported directly)
|
|
22
|
+
type ByteStreamWriter = Awaited<ReturnType<Room['localParticipant']['streamBytes']>>;
|
|
23
|
+
import type {
|
|
24
|
+
CloseReason,
|
|
25
|
+
PersonaSessionOptions,
|
|
26
|
+
SessionState
|
|
27
|
+
} from './types.js';
|
|
28
|
+
|
|
29
|
+
/** Map LiveKit DisconnectReason to our CloseReason */
|
|
30
|
+
function mapDisconnectReason(reason?: DisconnectReason): CloseReason {
|
|
31
|
+
switch (reason) {
|
|
32
|
+
case DisconnectReason.CLIENT_INITIATED:
|
|
33
|
+
return 'client_initiated';
|
|
34
|
+
case DisconnectReason.ROOM_DELETED:
|
|
35
|
+
return 'room_deleted';
|
|
36
|
+
case DisconnectReason.SERVER_SHUTDOWN:
|
|
37
|
+
return 'server_shutdown';
|
|
38
|
+
case DisconnectReason.PARTICIPANT_REMOVED:
|
|
39
|
+
return 'participant_removed';
|
|
40
|
+
default:
|
|
41
|
+
return 'unknown';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** DataStream topic for audio (must match GPU node) */
|
|
46
|
+
const AUDIO_STREAM_TOPIC = 'lk.audio_stream';
|
|
47
|
+
|
|
48
|
+
/** DataStream topic for control messages (interrupt, etc.) */
|
|
49
|
+
const CONTROL_STREAM_TOPIC = 'lk.control';
|
|
50
|
+
|
|
51
|
+
/** Auto-flush timeout in ms after last audio chunk */
|
|
52
|
+
const AUTO_FLUSH_TIMEOUT_MS = 300;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* PersonaSession manages the connection between your app and a Persona avatar.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const session = new PersonaSession({
|
|
60
|
+
* // WebRTC URL, access token, and agent identity returned from KeyframeLab API (called from your server code)
|
|
61
|
+
* serverUrl: websocket-url,
|
|
62
|
+
* participantToken: webrtc-participant-token,
|
|
63
|
+
* agentIdentity: agent_identity,
|
|
64
|
+
* onVideoTrack: (track) => {
|
|
65
|
+
* videoElement.srcObject = new MediaStream([track]);
|
|
66
|
+
* },
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* await session.connect();
|
|
70
|
+
* session.sendAudio(audioBytes);
|
|
71
|
+
* await session.close();
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export class PersonaSession {
|
|
75
|
+
private options: Required<Pick<PersonaSessionOptions, 'serverUrl' | 'participantToken' | 'agentIdentity'>> &
|
|
76
|
+
PersonaSessionOptions;
|
|
77
|
+
private room: Room | null = null;
|
|
78
|
+
private _state: SessionState = 'disconnected';
|
|
79
|
+
private byteStreamWriter: ByteStreamWriter | null = null;
|
|
80
|
+
|
|
81
|
+
// Write queue for proper ordering and completion tracking
|
|
82
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
83
|
+
|
|
84
|
+
// Auto-flush timer for turn end detection
|
|
85
|
+
private autoFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
|
|
87
|
+
constructor(options: PersonaSessionOptions) {
|
|
88
|
+
this.options = { ...options };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Current session state */
|
|
92
|
+
get state(): SessionState {
|
|
93
|
+
return this._state;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private setState(state: SessionState): void {
|
|
97
|
+
if (this._state !== state) {
|
|
98
|
+
this._state = state;
|
|
99
|
+
this.options.onStateChange?.(state);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Connect to a Persona avatar session.
|
|
105
|
+
*
|
|
106
|
+
* Creates a session via Central API and connects to the LiveKit room.
|
|
107
|
+
*/
|
|
108
|
+
async connect(): Promise<void> {
|
|
109
|
+
if (this._state !== 'disconnected') {
|
|
110
|
+
throw new Error('Session already connected or connecting');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.setState('connecting');
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// 1. Connect to LiveKit room
|
|
117
|
+
this.room = new Room();
|
|
118
|
+
this.setupRoomListeners();
|
|
119
|
+
|
|
120
|
+
await this.room.connect(this.options.serverUrl, this.options.participantToken);
|
|
121
|
+
|
|
122
|
+
// Open ByteStream for audio
|
|
123
|
+
this.byteStreamWriter = await this.room.localParticipant.streamBytes({
|
|
124
|
+
topic: AUDIO_STREAM_TOPIC,
|
|
125
|
+
destinationIdentities: [this.options.agentIdentity],
|
|
126
|
+
});
|
|
127
|
+
console.log('[PersonaSession] Opened audio stream');
|
|
128
|
+
|
|
129
|
+
this.setState('connected');
|
|
130
|
+
console.log('[PersonaSession] Connected to room');
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.setState('error');
|
|
133
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private setupRoomListeners(): void {
|
|
139
|
+
if (!this.room) return;
|
|
140
|
+
|
|
141
|
+
// Handle tracks from persona
|
|
142
|
+
this.room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub, participant: RemoteParticipant) => {
|
|
143
|
+
if (participant.identity !== this.options.agentIdentity) return;
|
|
144
|
+
|
|
145
|
+
const mediaTrack = track.mediaStreamTrack;
|
|
146
|
+
|
|
147
|
+
if (track.kind === Track.Kind.Video) {
|
|
148
|
+
console.log('[PersonaSession] Got video track from persona');
|
|
149
|
+
this.options.onVideoTrack?.(mediaTrack);
|
|
150
|
+
} else if (track.kind === Track.Kind.Audio) {
|
|
151
|
+
console.log('[PersonaSession] Got audio track from persona');
|
|
152
|
+
this.options.onAudioTrack?.(mediaTrack);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Handle disconnection
|
|
157
|
+
this.room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
|
|
158
|
+
const closeReason = mapDisconnectReason(reason);
|
|
159
|
+
console.log('[PersonaSession] Disconnected from room:', closeReason);
|
|
160
|
+
this.setState('disconnected');
|
|
161
|
+
this.options.onClose?.(closeReason);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Handle errors
|
|
165
|
+
this.room.on(RoomEvent.MediaDevicesError, (error: Error) => {
|
|
166
|
+
console.error('[PersonaSession] Media devices error:', error);
|
|
167
|
+
this.options.onError?.(error);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Send audio data to the persona for video synthesis.
|
|
173
|
+
*
|
|
174
|
+
* @param pcmData - 16-bit PCM audio at 24kHz
|
|
175
|
+
*/
|
|
176
|
+
sendAudio(pcmData: Uint8Array): void {
|
|
177
|
+
if (!this.room || this._state !== 'connected') {
|
|
178
|
+
console.warn('[PersonaSession] sendAudio dropped - not connected');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Chain writes to ensure ordering and track completion
|
|
183
|
+
// Lazily create ByteStreamWriter if needed (e.g., after endAudioTurn closed it)
|
|
184
|
+
console.log(`[PersonaSession] Writing ${pcmData.length} bytes to ByteStream`);
|
|
185
|
+
this.writeQueue = this.writeQueue
|
|
186
|
+
.then(async () => {
|
|
187
|
+
if (!this.byteStreamWriter && this.room) {
|
|
188
|
+
this.byteStreamWriter = await this.room.localParticipant.streamBytes({
|
|
189
|
+
topic: AUDIO_STREAM_TOPIC,
|
|
190
|
+
destinationIdentities: [this.options.agentIdentity],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
await this.byteStreamWriter?.write(pcmData);
|
|
194
|
+
})
|
|
195
|
+
.catch((err) => {
|
|
196
|
+
console.warn('[PersonaSession] Failed to write audio:', err);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Schedule auto-flush after timeout (reset on each audio chunk)
|
|
200
|
+
this.scheduleAutoFlush();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Schedule auto-flush after a timeout.
|
|
205
|
+
* Called on each sendAudio() to reset the timer.
|
|
206
|
+
*/
|
|
207
|
+
private scheduleAutoFlush(): void {
|
|
208
|
+
if (this.autoFlushTimer) {
|
|
209
|
+
clearTimeout(this.autoFlushTimer);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.autoFlushTimer = setTimeout(() => {
|
|
213
|
+
if (this.byteStreamWriter) {
|
|
214
|
+
console.log('[PersonaSession] Auto-flush: no audio for', AUTO_FLUSH_TIMEOUT_MS, 'ms');
|
|
215
|
+
this.endAudioTurn();
|
|
216
|
+
}
|
|
217
|
+
}, AUTO_FLUSH_TIMEOUT_MS);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Flush all pending audio writes.
|
|
222
|
+
* Call before close to ensure all audio is sent.
|
|
223
|
+
*/
|
|
224
|
+
async flush(): Promise<void> {
|
|
225
|
+
await this.writeQueue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* End the current audio turn and flush any buffered data.
|
|
230
|
+
*
|
|
231
|
+
* This is called automatically 300ms after the last sendAudio() call.
|
|
232
|
+
* You typically don't need to call this manually, but it's available
|
|
233
|
+
* if you need immediate flushing.
|
|
234
|
+
*/
|
|
235
|
+
async endAudioTurn(): Promise<void> {
|
|
236
|
+
// Clear auto-flush timer to prevent double-flush
|
|
237
|
+
if (this.autoFlushTimer) {
|
|
238
|
+
clearTimeout(this.autoFlushTimer);
|
|
239
|
+
this.autoFlushTimer = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.debug('[PersonaSession] endAudioTurn() called, awaiting writeQueue...');
|
|
243
|
+
await this.writeQueue;
|
|
244
|
+
console.debug('[PersonaSession] writeQueue complete, closing stream...');
|
|
245
|
+
if (this.byteStreamWriter) {
|
|
246
|
+
try {
|
|
247
|
+
await this.byteStreamWriter.close();
|
|
248
|
+
console.debug('[PersonaSession] stream closed successfully');
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.warn('[PersonaSession] stream close error:', err);
|
|
251
|
+
}
|
|
252
|
+
this.byteStreamWriter = null;
|
|
253
|
+
} else {
|
|
254
|
+
console.debug('[PersonaSession] no stream to close');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Signal an interruption to the avatar.
|
|
260
|
+
*
|
|
261
|
+
* Clears any pending frames in the GPU node's publish queue,
|
|
262
|
+
* allowing the avatar to quickly respond to new audio.
|
|
263
|
+
*
|
|
264
|
+
* Call this when the user interrupts the agent (e.g., starts speaking
|
|
265
|
+
* while the agent is still talking).
|
|
266
|
+
*/
|
|
267
|
+
async interrupt(): Promise<void> {
|
|
268
|
+
// Clear auto-flush timer - we're interrupting, don't need to flush
|
|
269
|
+
if (this.autoFlushTimer) {
|
|
270
|
+
clearTimeout(this.autoFlushTimer);
|
|
271
|
+
this.autoFlushTimer = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!this.room || this._state !== 'connected') {
|
|
275
|
+
console.warn('[PersonaSession] interrupt() called but not connected');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
console.debug('[PersonaSession] Sending interrupt');
|
|
281
|
+
const writer = await this.room.localParticipant.streamBytes({
|
|
282
|
+
topic: CONTROL_STREAM_TOPIC,
|
|
283
|
+
destinationIdentities: [this.options.agentIdentity],
|
|
284
|
+
});
|
|
285
|
+
await writer.write(new TextEncoder().encode('interrupt'));
|
|
286
|
+
await writer.close();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.warn('[PersonaSession] Failed to send interrupt:', error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Close the session and clean up resources.
|
|
294
|
+
*/
|
|
295
|
+
async close(): Promise<void> {
|
|
296
|
+
// Clear auto-flush timer
|
|
297
|
+
if (this.autoFlushTimer) {
|
|
298
|
+
clearTimeout(this.autoFlushTimer);
|
|
299
|
+
this.autoFlushTimer = null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Wait for pending writes to complete before closing
|
|
303
|
+
await this.flush();
|
|
304
|
+
|
|
305
|
+
// Close byte stream
|
|
306
|
+
if (this.byteStreamWriter) {
|
|
307
|
+
try {
|
|
308
|
+
await this.byteStreamWriter.close();
|
|
309
|
+
} catch {
|
|
310
|
+
// Ignore close errors
|
|
311
|
+
}
|
|
312
|
+
this.byteStreamWriter = null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Disconnect from room
|
|
316
|
+
if (this.room) {
|
|
317
|
+
this.room.disconnect();
|
|
318
|
+
this.room = null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.setState('disconnected');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio utilities for PCM processing and silence generation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Sample rate for audio sent to Persona */
|
|
6
|
+
export const SAMPLE_RATE = 24000;
|
|
7
|
+
|
|
8
|
+
/** Bytes per sample (16-bit PCM) */
|
|
9
|
+
export const BYTES_PER_SAMPLE = 2;
|
|
10
|
+
|
|
11
|
+
/** Number of channels (mono) */
|
|
12
|
+
export const CHANNELS = 1;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert base64-encoded audio to Uint8Array.
|
|
16
|
+
*
|
|
17
|
+
* @param base64 - Base64 encoded audio data
|
|
18
|
+
* @returns Uint8Array of audio bytes
|
|
19
|
+
*/
|
|
20
|
+
export function base64ToBytes(base64: string): Uint8Array {
|
|
21
|
+
const binaryString = atob(base64);
|
|
22
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
23
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
24
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
25
|
+
}
|
|
26
|
+
return bytes;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert Uint8Array to base64 string.
|
|
31
|
+
*
|
|
32
|
+
* @param bytes - Audio bytes
|
|
33
|
+
* @returns Base64 encoded string
|
|
34
|
+
*/
|
|
35
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
36
|
+
let binary = '';
|
|
37
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
38
|
+
binary += String.fromCharCode(bytes[i]);
|
|
39
|
+
}
|
|
40
|
+
return btoa(binary);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resample PCM audio from one sample rate to another.
|
|
45
|
+
* Simple linear interpolation - not high quality but sufficient for real-time.
|
|
46
|
+
*
|
|
47
|
+
* @param input - Input PCM bytes (16-bit signed)
|
|
48
|
+
* @param fromRate - Source sample rate
|
|
49
|
+
* @param toRate - Target sample rate
|
|
50
|
+
* @returns Resampled PCM bytes
|
|
51
|
+
*/
|
|
52
|
+
export function resamplePcm(
|
|
53
|
+
input: Uint8Array,
|
|
54
|
+
fromRate: number,
|
|
55
|
+
toRate: number
|
|
56
|
+
): Uint8Array {
|
|
57
|
+
if (fromRate === toRate) {
|
|
58
|
+
return input;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const inputView = new Int16Array(input.buffer, input.byteOffset, input.length / 2);
|
|
62
|
+
const ratio = fromRate / toRate;
|
|
63
|
+
const outputLength = Math.floor(inputView.length / ratio);
|
|
64
|
+
const output = new Int16Array(outputLength);
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < outputLength; i++) {
|
|
67
|
+
const srcIndex = i * ratio;
|
|
68
|
+
const srcIndexFloor = Math.floor(srcIndex);
|
|
69
|
+
const srcIndexCeil = Math.min(srcIndexFloor + 1, inputView.length - 1);
|
|
70
|
+
const fraction = srcIndex - srcIndexFloor;
|
|
71
|
+
|
|
72
|
+
// Linear interpolation
|
|
73
|
+
output[i] = Math.round(
|
|
74
|
+
inputView[srcIndexFloor] * (1 - fraction) + inputView[srcIndexCeil] * fraction
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Uint8Array(output.buffer);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a simple typed event emitter.
|
|
83
|
+
*/
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
export function createEventEmitter<T extends Record<string, any>>() {
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
const listeners = new Map<keyof T, Set<(data: any) => void>>();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
|
|
91
|
+
if (!listeners.has(event)) {
|
|
92
|
+
listeners.set(event, new Set());
|
|
93
|
+
}
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
listeners.get(event)!.add(handler as (data: any) => void);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
100
|
+
listeners.get(event)?.delete(handler as (data: any) => void);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
emit<K extends keyof T>(event: K, data: T[K]): void {
|
|
104
|
+
listeners.get(event)?.forEach((handler) => handler(data));
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
removeAllListeners(): void {
|
|
108
|
+
listeners.clear();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @keyframelabs/sdk - Browser SDK for KeyframeLab's Persona avatar sessions.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* import { createClient } from '@keyframelabs/sdk';
|
|
7
|
+
*
|
|
8
|
+
* const client = createClient({
|
|
9
|
+
* personaId: 'luna',
|
|
10
|
+
* // WebRTC URL and access token returned from KeyframeLab API (called from your server code)
|
|
11
|
+
* serverUrl: 'wss://webrtc-server-url.com',
|
|
12
|
+
* participantToken: 'webrtc-participant-token',
|
|
13
|
+
* onVideoTrack: (track) => {
|
|
14
|
+
* videoElement.srcObject = new MediaStream([track]);
|
|
15
|
+
* },
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* await client.connect();
|
|
19
|
+
* client.sendAudio(audioBytes);
|
|
20
|
+
* await client.close();
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { PersonaSession } from './PersonaSession.js';
|
|
25
|
+
import type { PersonaSessionOptions } from './types.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a new Persona client.
|
|
29
|
+
*
|
|
30
|
+
* @param options - Session configuration options
|
|
31
|
+
* @returns A new PersonaSession instance
|
|
32
|
+
*/
|
|
33
|
+
const createClient = (options: PersonaSessionOptions): PersonaSession => {
|
|
34
|
+
return new PersonaSession(options);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Core
|
|
38
|
+
export { createClient, PersonaSession };
|
|
39
|
+
|
|
40
|
+
// Types
|
|
41
|
+
export type {
|
|
42
|
+
CloseReason,
|
|
43
|
+
PersonaSessionOptions,
|
|
44
|
+
SessionState,
|
|
45
|
+
} from './types.js';
|
|
46
|
+
|
|
47
|
+
// Utilities
|
|
48
|
+
export {
|
|
49
|
+
SAMPLE_RATE,
|
|
50
|
+
base64ToBytes,
|
|
51
|
+
bytesToBase64,
|
|
52
|
+
createEventEmitter,
|
|
53
|
+
resamplePcm,
|
|
54
|
+
} from './audio-utils.js';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Persona SDK.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Session state machine */
|
|
6
|
+
export type SessionState =
|
|
7
|
+
| 'disconnected'
|
|
8
|
+
| 'connecting'
|
|
9
|
+
| 'connected'
|
|
10
|
+
| 'error';
|
|
11
|
+
|
|
12
|
+
/** Reason for session close */
|
|
13
|
+
export type CloseReason =
|
|
14
|
+
| 'client_initiated' // User called close()
|
|
15
|
+
| 'room_deleted' // Backend deleted the room (max duration, etc.)
|
|
16
|
+
| 'server_shutdown' // Server is shutting down
|
|
17
|
+
| 'participant_removed' // Participant was kicked
|
|
18
|
+
| 'unknown'; // Unknown reason
|
|
19
|
+
|
|
20
|
+
/** Options for creating a PersonaSession */
|
|
21
|
+
export interface PersonaSessionOptions {
|
|
22
|
+
/** WebRTC URL (returned from KeyframeLab API) */
|
|
23
|
+
serverUrl: string;
|
|
24
|
+
/** WebRTC access token (returned from KeyframeLab API) */
|
|
25
|
+
participantToken: string;
|
|
26
|
+
/** Identity of the agent participant */
|
|
27
|
+
agentIdentity: string;
|
|
28
|
+
|
|
29
|
+
/** Called when video track is available */
|
|
30
|
+
onVideoTrack?: (track: MediaStreamTrack) => void;
|
|
31
|
+
|
|
32
|
+
/** Called when audio track is available */
|
|
33
|
+
onAudioTrack?: (track: MediaStreamTrack) => void;
|
|
34
|
+
|
|
35
|
+
/** Called when session state changes */
|
|
36
|
+
onStateChange?: (state: SessionState) => void;
|
|
37
|
+
|
|
38
|
+
/** Called on error */
|
|
39
|
+
onError?: (error: Error) => void;
|
|
40
|
+
|
|
41
|
+
/** Called when session is closed (by server or client) */
|
|
42
|
+
onClose?: (reason: CloseReason) => void;
|
|
43
|
+
}
|