@llmrtc/llmrtc-web-client 1.0.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/dist/index.js ADDED
@@ -0,0 +1,708 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ import { z } from 'zod';
3
+ import { NativePeer } from './native-peer.js';
4
+ import { ConnectionStateMachine, ConnectionState } from './connection-state.js';
5
+ import { PROTOCOL_VERSION } from '@llmrtc/llmrtc-core';
6
+ // Re-export for convenience
7
+ export { ConnectionState } from './connection-state.js';
8
+ export { NativePeer } from './native-peer.js';
9
+ export { PROTOCOL_VERSION } from '@llmrtc/llmrtc-core';
10
+ const MessageSchema = z.object({ type: z.string() }).passthrough();
11
+ const HEARTBEAT_INTERVAL_MS = 15000;
12
+ const HEARTBEAT_TIMEOUT_MS = 10000;
13
+ const MAX_MISSED_HEARTBEATS = 2;
14
+ /**
15
+ * Default ICE servers - Metered STUN server
16
+ * Used when no custom ICE servers configured and server doesn't provide any
17
+ */
18
+ const DEFAULT_ICE_SERVERS = [
19
+ { urls: 'stun:stun.metered.ca:80' }
20
+ ];
21
+ export class LLMRTCWebClient extends EventEmitter {
22
+ constructor(config) {
23
+ super();
24
+ Object.defineProperty(this, "config", {
25
+ enumerable: true,
26
+ configurable: true,
27
+ writable: true,
28
+ value: config
29
+ });
30
+ Object.defineProperty(this, "ws", {
31
+ enumerable: true,
32
+ configurable: true,
33
+ writable: true,
34
+ value: null
35
+ });
36
+ Object.defineProperty(this, "peer", {
37
+ enumerable: true,
38
+ configurable: true,
39
+ writable: true,
40
+ value: null
41
+ });
42
+ Object.defineProperty(this, "stateMachine", {
43
+ enumerable: true,
44
+ configurable: true,
45
+ writable: true,
46
+ value: void 0
47
+ });
48
+ Object.defineProperty(this, "sessionId", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: null
53
+ });
54
+ Object.defineProperty(this, "heartbeatInterval", {
55
+ enumerable: true,
56
+ configurable: true,
57
+ writable: true,
58
+ value: void 0
59
+ });
60
+ Object.defineProperty(this, "heartbeatTimeout", {
61
+ enumerable: true,
62
+ configurable: true,
63
+ writable: true,
64
+ value: void 0
65
+ });
66
+ Object.defineProperty(this, "missedHeartbeats", {
67
+ enumerable: true,
68
+ configurable: true,
69
+ writable: true,
70
+ value: 0
71
+ });
72
+ Object.defineProperty(this, "reconnectTimeout", {
73
+ enumerable: true,
74
+ configurable: true,
75
+ writable: true,
76
+ value: void 0
77
+ });
78
+ /** ICE servers received from server in ready message */
79
+ Object.defineProperty(this, "serverIceServers", {
80
+ enumerable: true,
81
+ configurable: true,
82
+ writable: true,
83
+ value: null
84
+ });
85
+ // Media state
86
+ Object.defineProperty(this, "audioTrack", {
87
+ enumerable: true,
88
+ configurable: true,
89
+ writable: true,
90
+ value: void 0
91
+ });
92
+ Object.defineProperty(this, "audioStream", {
93
+ enumerable: true,
94
+ configurable: true,
95
+ writable: true,
96
+ value: void 0
97
+ });
98
+ Object.defineProperty(this, "audioSender", {
99
+ enumerable: true,
100
+ configurable: true,
101
+ writable: true,
102
+ value: void 0
103
+ });
104
+ Object.defineProperty(this, "videoCapture", {
105
+ enumerable: true,
106
+ configurable: true,
107
+ writable: true,
108
+ value: void 0
109
+ });
110
+ Object.defineProperty(this, "screenCapture", {
111
+ enumerable: true,
112
+ configurable: true,
113
+ writable: true,
114
+ value: void 0
115
+ });
116
+ // Default reconnection to enabled
117
+ const reconnectionConfig = {
118
+ enabled: true,
119
+ ...config.reconnection
120
+ };
121
+ this.stateMachine = new ConnectionStateMachine(reconnectionConfig);
122
+ this.stateMachine.on('stateChange', ({ to }) => {
123
+ this.emit('stateChange', to);
124
+ });
125
+ }
126
+ /**
127
+ * Get the current connection state.
128
+ */
129
+ get state() {
130
+ return this.stateMachine.state;
131
+ }
132
+ /**
133
+ * Get the session ID assigned by the server.
134
+ */
135
+ get currentSessionId() {
136
+ return this.sessionId;
137
+ }
138
+ /**
139
+ * Start the connection to the server.
140
+ */
141
+ async start() {
142
+ if (this.stateMachine.state !== ConnectionState.DISCONNECTED) {
143
+ throw new Error('Client already started or connecting');
144
+ }
145
+ this.stateMachine.transition(ConnectionState.CONNECTING);
146
+ try {
147
+ await this.connect();
148
+ this.stateMachine.transition(ConnectionState.CONNECTED);
149
+ }
150
+ catch (error) {
151
+ this.handleConnectionError(error);
152
+ throw error;
153
+ }
154
+ }
155
+ async connect() {
156
+ // 1. Establish WebSocket
157
+ await this.connectWebSocket();
158
+ // 2. Wait for ready message with session ID and ICE servers
159
+ await this.waitForReady();
160
+ // 3. Resolve ICE servers
161
+ // Priority: client config > server-provided > default STUN
162
+ const iceServers = this.config.iceServers?.length
163
+ ? this.config.iceServers
164
+ : this.serverIceServers?.length
165
+ ? this.serverIceServers
166
+ : DEFAULT_ICE_SERVERS;
167
+ console.log('[web-client] Using', iceServers.length, 'ICE servers');
168
+ // 4. Create peer connection
169
+ this.peer = new NativePeer({ iceServers, trickle: false }, true);
170
+ this.setupPeerEventHandlers();
171
+ // 5. Create offer (triggers signal event which sends to server)
172
+ await this.peer.createOffer();
173
+ // 6. Wait for peer connection to be established
174
+ await this.waitForPeerConnection();
175
+ // 7. Start heartbeat
176
+ this.startHeartbeat();
177
+ }
178
+ connectWebSocket() {
179
+ return new Promise((resolve, reject) => {
180
+ console.log('[web-client] Connecting to', this.config.signallingUrl);
181
+ this.ws = new WebSocket(this.config.signallingUrl);
182
+ const onOpen = () => {
183
+ console.log('[web-client] WebSocket connected');
184
+ cleanup();
185
+ resolve();
186
+ };
187
+ const onError = (e) => {
188
+ console.error('[web-client] WebSocket error:', e);
189
+ cleanup();
190
+ reject(new Error('WebSocket connection failed'));
191
+ };
192
+ const cleanup = () => {
193
+ this.ws?.removeEventListener('open', onOpen);
194
+ this.ws?.removeEventListener('error', onError);
195
+ };
196
+ this.ws.addEventListener('open', onOpen);
197
+ this.ws.addEventListener('error', onError);
198
+ this.ws.onmessage = (ev) => this.handleSignalingMessage(ev.data);
199
+ this.ws.onclose = () => this.handleWebSocketClose();
200
+ });
201
+ }
202
+ waitForReady() {
203
+ return new Promise((resolve, reject) => {
204
+ const timeout = setTimeout(() => {
205
+ reject(new Error('Timeout waiting for ready message'));
206
+ }, 10000);
207
+ const handler = (ev) => {
208
+ try {
209
+ const msg = JSON.parse(ev.data);
210
+ if (msg.type === 'ready') {
211
+ clearTimeout(timeout);
212
+ this.sessionId = msg.id;
213
+ // Store server-provided ICE servers
214
+ if (msg.iceServers?.length) {
215
+ this.serverIceServers = msg.iceServers;
216
+ console.log('[web-client] Received', msg.iceServers.length, 'ICE servers from server');
217
+ }
218
+ // Check protocol version
219
+ const serverVersion = msg.protocolVersion ?? 0;
220
+ if (serverVersion !== PROTOCOL_VERSION) {
221
+ console.warn(`[web-client] Protocol version mismatch: client=${PROTOCOL_VERSION}, server=${serverVersion}`);
222
+ }
223
+ console.log('[web-client] Session ID:', this.sessionId, 'Protocol version:', serverVersion);
224
+ this.ws?.removeEventListener('message', handler);
225
+ resolve();
226
+ }
227
+ }
228
+ catch {
229
+ // Ignore parse errors
230
+ }
231
+ };
232
+ this.ws?.addEventListener('message', handler);
233
+ });
234
+ }
235
+ setupPeerEventHandlers() {
236
+ if (!this.peer)
237
+ return;
238
+ this.peer.on('signal', (signal) => {
239
+ console.log('[web-client] Sending offer signal');
240
+ this.ws?.send(JSON.stringify({ type: 'offer', signal }));
241
+ });
242
+ this.peer.on('data', (data) => {
243
+ const str = typeof data === 'string' ? data : new TextDecoder().decode(data);
244
+ this.handlePayload(str);
245
+ });
246
+ this.peer.on('connect', () => {
247
+ console.log('[web-client] Peer connected');
248
+ });
249
+ this.peer.on('close', () => {
250
+ console.log('[web-client] Peer closed');
251
+ if (this.stateMachine.state === ConnectionState.CONNECTED) {
252
+ this.scheduleReconnect();
253
+ }
254
+ });
255
+ this.peer.on('error', (err) => {
256
+ console.error('[web-client] Peer error:', err.message);
257
+ this.emit('error', {
258
+ code: 'WEBRTC_ERROR',
259
+ message: err.message,
260
+ recoverable: true
261
+ });
262
+ });
263
+ this.peer.on('track', (track, stream) => {
264
+ if (track.kind === 'audio') {
265
+ console.log('[web-client] Received TTS audio track from server');
266
+ this.emit('ttsTrack', stream);
267
+ }
268
+ });
269
+ this.peer.on('connectionStateChange', (state) => {
270
+ console.log('[web-client] Connection state changed:', state);
271
+ if (state === 'failed' || state === 'disconnected') {
272
+ if (this.stateMachine.state === ConnectionState.CONNECTED) {
273
+ this.scheduleReconnect();
274
+ }
275
+ }
276
+ });
277
+ }
278
+ waitForPeerConnection() {
279
+ return new Promise((resolve, reject) => {
280
+ if (this.peer?.connected) {
281
+ resolve();
282
+ return;
283
+ }
284
+ const timeout = setTimeout(() => {
285
+ cleanup();
286
+ reject(new Error('Timeout waiting for peer connection'));
287
+ }, 30000);
288
+ const onConnect = () => {
289
+ clearTimeout(timeout);
290
+ cleanup();
291
+ resolve();
292
+ };
293
+ const onError = (err) => {
294
+ clearTimeout(timeout);
295
+ cleanup();
296
+ reject(err);
297
+ };
298
+ const cleanup = () => {
299
+ this.peer?.off('connect', onConnect);
300
+ this.peer?.off('error', onError);
301
+ };
302
+ this.peer?.on('connect', onConnect);
303
+ this.peer?.on('error', onError);
304
+ });
305
+ }
306
+ handleSignalingMessage(raw) {
307
+ try {
308
+ const parsed = MessageSchema.safeParse(JSON.parse(raw));
309
+ if (!parsed.success)
310
+ return;
311
+ const msg = parsed.data;
312
+ switch (msg.type) {
313
+ case 'signal':
314
+ if (this.peer && !this.peer.destroyed) {
315
+ console.log('[web-client] Received answer signal');
316
+ this.peer.signal(msg.signal);
317
+ }
318
+ break;
319
+ case 'pong':
320
+ // Reset missed heartbeats on pong
321
+ this.missedHeartbeats = 0;
322
+ if (this.heartbeatTimeout) {
323
+ clearTimeout(this.heartbeatTimeout);
324
+ this.heartbeatTimeout = undefined;
325
+ }
326
+ break;
327
+ case 'reconnect-ack':
328
+ console.log('[web-client] Reconnect acknowledged:', msg.historyRecovered ? 'history recovered' : 'new session', 'sessionId:', msg.sessionId);
329
+ // Update session ID to the one confirmed by the server
330
+ // This ensures sessionId is preserved across reconnections when history is recovered
331
+ if (msg.sessionId) {
332
+ this.sessionId = msg.sessionId;
333
+ }
334
+ break;
335
+ default:
336
+ // Only process payload messages from WebSocket if DataChannel is NOT connected
337
+ // This prevents duplicate message handling since backend sends to both channels
338
+ if (!this.peer?.connected) {
339
+ this.handlePayload(raw);
340
+ }
341
+ // When DataChannel is connected, ignore payload messages from WebSocket
342
+ // They will be handled by the DataChannel's 'data' event
343
+ }
344
+ }
345
+ catch (err) {
346
+ console.error('[web-client] Error handling signal:', err);
347
+ }
348
+ }
349
+ handlePayload(raw) {
350
+ try {
351
+ const msg = JSON.parse(raw);
352
+ switch (msg.type) {
353
+ case 'transcript':
354
+ this.emit('transcript', msg.text);
355
+ break;
356
+ case 'llm-chunk':
357
+ if (msg.content)
358
+ this.emit('llmChunk', msg.content);
359
+ break;
360
+ case 'llm':
361
+ this.emit('llm', msg.text);
362
+ break;
363
+ case 'tts':
364
+ if (msg.data)
365
+ this.emit('tts', base64ToArrayBuffer(msg.data), msg.format ?? 'mp3');
366
+ break;
367
+ case 'tts-start':
368
+ this.emit('ttsStart');
369
+ break;
370
+ case 'tts-complete':
371
+ this.emit('ttsComplete');
372
+ break;
373
+ case 'tts-cancelled':
374
+ this.emit('ttsCancelled');
375
+ break;
376
+ case 'speech-start':
377
+ this.emit('speechStart');
378
+ break;
379
+ case 'speech-end':
380
+ this.sendAttachments();
381
+ this.emit('speechEnd');
382
+ break;
383
+ case 'tool-call-start':
384
+ this.emit('toolCallStart', {
385
+ name: msg.name,
386
+ callId: msg.callId,
387
+ arguments: msg.arguments ?? {}
388
+ });
389
+ break;
390
+ case 'tool-call-end':
391
+ this.emit('toolCallEnd', {
392
+ callId: msg.callId,
393
+ result: msg.result,
394
+ error: msg.error,
395
+ durationMs: msg.durationMs ?? 0
396
+ });
397
+ break;
398
+ case 'stage-change':
399
+ this.emit('stageChange', {
400
+ from: msg.from,
401
+ to: msg.to,
402
+ reason: msg.reason ?? ''
403
+ });
404
+ break;
405
+ case 'error':
406
+ this.emit('error', {
407
+ code: msg.code ?? 'SERVER_ERROR',
408
+ message: msg.message ?? 'Unknown error',
409
+ recoverable: false
410
+ });
411
+ break;
412
+ }
413
+ }
414
+ catch (err) {
415
+ console.error('[web-client] Error handling payload:', err);
416
+ }
417
+ }
418
+ handleWebSocketClose() {
419
+ console.log('[web-client] WebSocket closed');
420
+ if (this.stateMachine.state === ConnectionState.CONNECTED) {
421
+ this.scheduleReconnect();
422
+ }
423
+ }
424
+ handleConnectionError(error) {
425
+ console.error('[web-client] Connection error:', error.message);
426
+ if (this.stateMachine.reconnectionEnabled) {
427
+ this.scheduleReconnect();
428
+ }
429
+ else {
430
+ this.stateMachine.transition(ConnectionState.FAILED);
431
+ this.emit('error', {
432
+ code: 'CONNECTION_ERROR',
433
+ message: error.message,
434
+ recoverable: false
435
+ });
436
+ }
437
+ }
438
+ scheduleReconnect() {
439
+ if (!this.stateMachine.reconnectionEnabled) {
440
+ this.stateMachine.transition(ConnectionState.FAILED);
441
+ return;
442
+ }
443
+ // Clear any existing reconnect timeout
444
+ if (this.reconnectTimeout) {
445
+ clearTimeout(this.reconnectTimeout);
446
+ }
447
+ this.stateMachine.transition(ConnectionState.RECONNECTING);
448
+ const delay = this.stateMachine.getNextRetryDelay();
449
+ if (delay === null) {
450
+ // Max retries exceeded
451
+ this.stateMachine.transition(ConnectionState.FAILED);
452
+ this.emit('error', {
453
+ code: 'RECONNECTION_FAILED',
454
+ message: 'Maximum reconnection attempts exceeded',
455
+ recoverable: false
456
+ });
457
+ return;
458
+ }
459
+ console.log(`[web-client] Reconnecting in ${delay}ms (attempt ${this.stateMachine.retryCount}/${this.stateMachine.maxRetries})`);
460
+ this.emit('reconnecting', this.stateMachine.retryCount, this.stateMachine.maxRetries);
461
+ this.reconnectTimeout = setTimeout(() => this.attemptReconnect(), delay);
462
+ }
463
+ async attemptReconnect() {
464
+ // Save old session ID BEFORE cleanup/connect (connect() will set a new sessionId)
465
+ const oldSessionId = this.sessionId;
466
+ // Clean up existing connections but keep session ID
467
+ this.cleanup(false);
468
+ this.stateMachine.transition(ConnectionState.CONNECTING);
469
+ try {
470
+ await this.connect();
471
+ // Send reconnect message with OLD session ID to recover the previous session
472
+ // Note: connect() sets this.sessionId to a new value from the server's ready message,
473
+ // so we must use oldSessionId here to request session recovery
474
+ if (oldSessionId) {
475
+ console.log('[web-client] Sending reconnect request for session:', oldSessionId);
476
+ this.ws?.send(JSON.stringify({
477
+ type: 'reconnect',
478
+ sessionId: oldSessionId
479
+ }));
480
+ }
481
+ // Re-add audio track if we had one
482
+ if (this.audioStream && this.audioTrack) {
483
+ console.log('[web-client] Re-adding audio track after reconnect');
484
+ this.audioSender = this.peer?.addTrack(this.audioTrack, this.audioStream);
485
+ }
486
+ this.stateMachine.transition(ConnectionState.CONNECTED);
487
+ }
488
+ catch (error) {
489
+ console.error('[web-client] Reconnect attempt failed:', error);
490
+ this.scheduleReconnect();
491
+ }
492
+ }
493
+ startHeartbeat() {
494
+ this.stopHeartbeat();
495
+ this.heartbeatInterval = setInterval(() => {
496
+ if (this.ws?.readyState === WebSocket.OPEN) {
497
+ this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
498
+ this.heartbeatTimeout = setTimeout(() => {
499
+ this.missedHeartbeats++;
500
+ console.warn(`[web-client] Missed heartbeat (${this.missedHeartbeats}/${MAX_MISSED_HEARTBEATS})`);
501
+ if (this.missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
502
+ console.warn('[web-client] Too many missed heartbeats, reconnecting');
503
+ this.scheduleReconnect();
504
+ }
505
+ }, HEARTBEAT_TIMEOUT_MS);
506
+ }
507
+ }, HEARTBEAT_INTERVAL_MS);
508
+ }
509
+ stopHeartbeat() {
510
+ if (this.heartbeatInterval) {
511
+ clearInterval(this.heartbeatInterval);
512
+ this.heartbeatInterval = undefined;
513
+ }
514
+ if (this.heartbeatTimeout) {
515
+ clearTimeout(this.heartbeatTimeout);
516
+ this.heartbeatTimeout = undefined;
517
+ }
518
+ this.missedHeartbeats = 0;
519
+ }
520
+ /**
521
+ * Share audio with the server.
522
+ * Speech detection is handled server-side using Silero VAD.
523
+ */
524
+ async shareAudio(stream) {
525
+ if (!this.peer?.connected) {
526
+ throw new Error('Peer not connected');
527
+ }
528
+ this.audioStream = stream;
529
+ this.audioTrack = stream.getAudioTracks()[0];
530
+ if (!this.audioTrack) {
531
+ throw new Error('No audio track in stream');
532
+ }
533
+ console.log('[web-client] Adding audio track to peer connection');
534
+ this.audioSender = this.peer.addTrack(this.audioTrack, stream);
535
+ // Wait for signaling to stabilize
536
+ await this.waitForStableSignaling();
537
+ console.log('[web-client] Audio track added - VAD handled server-side');
538
+ return {
539
+ stop: async () => {
540
+ console.log('[web-client] Stopping audio sharing');
541
+ if (this.audioSender && this.peer) {
542
+ this.peer.removeTrack(this.audioSender);
543
+ this.audioSender = undefined;
544
+ }
545
+ this.audioTrack?.stop();
546
+ stream.getTracks().forEach((t) => t.stop());
547
+ this.audioTrack = undefined;
548
+ this.audioStream = undefined;
549
+ }
550
+ };
551
+ }
552
+ waitForStableSignaling() {
553
+ return new Promise((resolve) => {
554
+ const checkState = () => {
555
+ if (this.peer?.signalingState === 'stable') {
556
+ resolve();
557
+ }
558
+ else {
559
+ setTimeout(checkState, 100);
560
+ }
561
+ };
562
+ setTimeout(checkState, 100);
563
+ });
564
+ }
565
+ /**
566
+ * Send vision attachments to include with next speech segment.
567
+ */
568
+ sendAttachments() {
569
+ const attachments = this.gatherAttachments();
570
+ if (attachments.length > 0 && this.peer?.connected) {
571
+ this.peer.send(JSON.stringify({ type: 'attachments', attachments }));
572
+ }
573
+ }
574
+ gatherAttachments() {
575
+ const attachments = [];
576
+ const cam = this.videoCapture?.getLastFrame();
577
+ const screen = this.screenCapture?.getLastFrame();
578
+ if (cam)
579
+ attachments.push({ data: cam, mimeType: 'image/jpeg', alt: 'camera frame' });
580
+ if (screen)
581
+ attachments.push({ data: screen, mimeType: 'image/jpeg', alt: 'screen frame' });
582
+ return attachments;
583
+ }
584
+ shareVideo(stream, intervalMs = 1000) {
585
+ this.videoCapture?.stop();
586
+ const ctrl = startFrameCapture(stream, intervalMs);
587
+ this.videoCapture = ctrl;
588
+ return ctrl;
589
+ }
590
+ shareScreen(stream, intervalMs = 1200) {
591
+ this.screenCapture?.stop();
592
+ const ctrl = startFrameCapture(stream, intervalMs);
593
+ this.screenCapture = ctrl;
594
+ return ctrl;
595
+ }
596
+ /**
597
+ * Close the connection.
598
+ */
599
+ close() {
600
+ this.stateMachine.transition(ConnectionState.CLOSED);
601
+ this.cleanup(true);
602
+ }
603
+ cleanup(fullCleanup) {
604
+ this.stopHeartbeat();
605
+ if (this.reconnectTimeout) {
606
+ clearTimeout(this.reconnectTimeout);
607
+ this.reconnectTimeout = undefined;
608
+ }
609
+ this.videoCapture?.stop();
610
+ this.screenCapture?.stop();
611
+ this.peer?.destroy();
612
+ this.peer = null;
613
+ if (this.ws) {
614
+ this.ws.onclose = null; // Prevent triggering reconnect
615
+ this.ws.close();
616
+ this.ws = null;
617
+ }
618
+ if (fullCleanup) {
619
+ this.sessionId = null;
620
+ this.serverIceServers = null;
621
+ this.audioTrack = undefined;
622
+ this.audioStream = undefined;
623
+ this.audioSender = undefined;
624
+ this.stateMachine.reset();
625
+ }
626
+ }
627
+ }
628
+ // Helper functions
629
+ function base64ToArrayBuffer(base64) {
630
+ const binary = atob(base64);
631
+ const bytes = new Uint8Array(binary.length);
632
+ for (let i = 0; i < binary.length; i++) {
633
+ bytes[i] = binary.charCodeAt(i);
634
+ }
635
+ return bytes.buffer;
636
+ }
637
+ export async function recordOnce(durationMs = 4000) {
638
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
639
+ const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
640
+ const chunks = [];
641
+ return new Promise((resolve) => {
642
+ recorder.ondataavailable = (e) => {
643
+ if (e.data.size > 0)
644
+ chunks.push(e.data);
645
+ };
646
+ recorder.onstop = async () => {
647
+ const blob = new Blob(chunks, { type: 'audio/webm' });
648
+ const buf = await blob.arrayBuffer();
649
+ stream.getTracks().forEach((t) => t.stop());
650
+ resolve(buf);
651
+ };
652
+ recorder.start();
653
+ setTimeout(() => recorder.stop(), durationMs);
654
+ });
655
+ }
656
+ export async function captureScreenFrame() {
657
+ const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
658
+ const track = screen.getVideoTracks()[0];
659
+ const image = await grabFrame(track);
660
+ track.stop();
661
+ return image;
662
+ }
663
+ async function grabFrame(track) {
664
+ const capture = new window.ImageCapture(track);
665
+ const bitmap = await capture.grabFrame();
666
+ const canvas = document.createElement('canvas');
667
+ canvas.width = bitmap.width;
668
+ canvas.height = bitmap.height;
669
+ const ctx = canvas.getContext('2d');
670
+ if (!ctx)
671
+ throw new Error('canvas context missing');
672
+ ctx.drawImage(bitmap, 0, 0);
673
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
674
+ return dataUrl;
675
+ }
676
+ function startFrameCapture(stream, intervalMs) {
677
+ const track = stream.getVideoTracks()[0];
678
+ let stopped = false;
679
+ const canvas = document.createElement('canvas');
680
+ const ctx = canvas.getContext('2d');
681
+ let lastFrame = null;
682
+ const tick = async () => {
683
+ if (stopped)
684
+ return;
685
+ try {
686
+ const capture = new window.ImageCapture(track);
687
+ const bitmap = await capture.grabFrame();
688
+ canvas.width = bitmap.width;
689
+ canvas.height = bitmap.height;
690
+ ctx?.drawImage(bitmap, 0, 0);
691
+ lastFrame = canvas.toDataURL('image/jpeg', 0.6);
692
+ }
693
+ catch (err) {
694
+ // ignore frame errors
695
+ }
696
+ timer = window.setTimeout(tick, intervalMs);
697
+ };
698
+ let timer = window.setTimeout(tick, intervalMs);
699
+ return {
700
+ stop: () => {
701
+ stopped = true;
702
+ window.clearTimeout(timer);
703
+ stream.getTracks().forEach((t) => t.stop());
704
+ },
705
+ getLastFrame: () => lastFrame
706
+ };
707
+ }
708
+ //# sourceMappingURL=index.js.map