@oxvo/ai-live-assist 7.3.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/cjs/AiLiveAssist.d.ts +108 -0
  4. package/cjs/AiLiveAssist.js +1774 -0
  5. package/cjs/client.d.ts +53 -0
  6. package/cjs/client.js +193 -0
  7. package/cjs/context.d.ts +58 -0
  8. package/cjs/context.js +979 -0
  9. package/cjs/control.d.ts +31 -0
  10. package/cjs/control.js +190 -0
  11. package/cjs/experienceState.d.ts +18 -0
  12. package/cjs/experienceState.js +82 -0
  13. package/cjs/index.d.ts +13 -0
  14. package/cjs/index.js +32 -0
  15. package/cjs/media.d.ts +34 -0
  16. package/cjs/media.js +207 -0
  17. package/cjs/messages.d.ts +2 -0
  18. package/cjs/messages.js +95 -0
  19. package/cjs/package.json +1 -0
  20. package/cjs/placement.d.ts +52 -0
  21. package/cjs/placement.js +293 -0
  22. package/cjs/presentation.d.ts +41 -0
  23. package/cjs/presentation.js +483 -0
  24. package/cjs/recordingPolicy.d.ts +2 -0
  25. package/cjs/recordingPolicy.js +12 -0
  26. package/cjs/safeSvg.d.ts +1 -0
  27. package/cjs/safeSvg.js +157 -0
  28. package/cjs/tabLock.d.ts +31 -0
  29. package/cjs/tabLock.js +260 -0
  30. package/cjs/types.d.ts +299 -0
  31. package/cjs/types.js +2 -0
  32. package/cjs/ui.d.ts +184 -0
  33. package/cjs/ui.js +2353 -0
  34. package/cjs/version.d.ts +1 -0
  35. package/cjs/version.js +4 -0
  36. package/cjs/visualContext.d.ts +21 -0
  37. package/cjs/visualContext.js +72 -0
  38. package/cjs/voicePresenceUi.d.ts +148 -0
  39. package/cjs/voicePresenceUi.js +2182 -0
  40. package/lib/AiLiveAssist.d.ts +108 -0
  41. package/lib/AiLiveAssist.js +1769 -0
  42. package/lib/client.d.ts +53 -0
  43. package/lib/client.js +187 -0
  44. package/lib/context.d.ts +58 -0
  45. package/lib/context.js +975 -0
  46. package/lib/control.d.ts +31 -0
  47. package/lib/control.js +186 -0
  48. package/lib/experienceState.d.ts +18 -0
  49. package/lib/experienceState.js +78 -0
  50. package/lib/index.d.ts +13 -0
  51. package/lib/index.js +26 -0
  52. package/lib/media.d.ts +34 -0
  53. package/lib/media.js +203 -0
  54. package/lib/messages.d.ts +2 -0
  55. package/lib/messages.js +92 -0
  56. package/lib/placement.d.ts +52 -0
  57. package/lib/placement.js +286 -0
  58. package/lib/presentation.d.ts +41 -0
  59. package/lib/presentation.js +478 -0
  60. package/lib/recordingPolicy.d.ts +2 -0
  61. package/lib/recordingPolicy.js +8 -0
  62. package/lib/safeSvg.d.ts +1 -0
  63. package/lib/safeSvg.js +153 -0
  64. package/lib/tabLock.d.ts +31 -0
  65. package/lib/tabLock.js +256 -0
  66. package/lib/types.d.ts +299 -0
  67. package/lib/types.js +1 -0
  68. package/lib/ui.d.ts +184 -0
  69. package/lib/ui.js +2349 -0
  70. package/lib/version.d.ts +1 -0
  71. package/lib/version.js +1 -0
  72. package/lib/visualContext.d.ts +21 -0
  73. package/lib/visualContext.js +68 -0
  74. package/lib/voicePresenceUi.d.ts +148 -0
  75. package/lib/voicePresenceUi.js +2178 -0
  76. package/package.json +58 -0
@@ -0,0 +1,31 @@
1
+ import type { ControlServerEnvelope, LaunchResult } from './types.js';
2
+ type Handler = (message: ControlServerEnvelope) => void | Promise<void>;
3
+ export declare class ControlChannel {
4
+ private readonly result;
5
+ private readonly tabId;
6
+ private readonly clientVersion;
7
+ private readonly onDisconnect;
8
+ private readonly onClientSequence;
9
+ private socket;
10
+ private clientSequence;
11
+ private serverSequence;
12
+ private fencingToken;
13
+ private heartbeat;
14
+ private handlers;
15
+ private intentionallyClosed;
16
+ private pendingAcks;
17
+ private recentAcks;
18
+ constructor(result: LaunchResult, tabId: string, clientVersion: string, onDisconnect: () => void, initialClientSequence?: number, onClientSequence?: (sequence: number) => void);
19
+ get leaseToken(): number;
20
+ get isOpen(): boolean;
21
+ setFencingToken(value: number): void;
22
+ subscribe(handler: Handler): () => void;
23
+ connect(signal: AbortSignal, resume: boolean): Promise<void>;
24
+ send(type: string, payload: Record<string, unknown>): string;
25
+ waitForAck(messageId: string, timeoutMs?: number): Promise<boolean>;
26
+ startHeartbeat(seconds: number): void;
27
+ close(code?: number, reason?: string): void;
28
+ private handleMessage;
29
+ private stopHeartbeat;
30
+ }
31
+ export {};
package/cjs/control.js ADDED
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ControlChannel = void 0;
4
+ const INVALID_MESSAGE_CLOSE_CODE = 4003;
5
+ const PROTOCOL_VIOLATION_CLOSE_CODE = 4008;
6
+ class ControlChannel {
7
+ constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined) {
8
+ this.result = result;
9
+ this.tabId = tabId;
10
+ this.clientVersion = clientVersion;
11
+ this.onDisconnect = onDisconnect;
12
+ this.onClientSequence = onClientSequence;
13
+ this.socket = null;
14
+ this.serverSequence = 0;
15
+ this.fencingToken = 0;
16
+ this.heartbeat = null;
17
+ this.handlers = new Set();
18
+ this.intentionallyClosed = false;
19
+ this.pendingAcks = new Map();
20
+ this.recentAcks = new Set();
21
+ this.clientSequence =
22
+ Number.isSafeInteger(initialClientSequence) && initialClientSequence >= 0
23
+ ? initialClientSequence
24
+ : 0;
25
+ }
26
+ get leaseToken() {
27
+ return this.fencingToken;
28
+ }
29
+ get isOpen() {
30
+ return this.socket?.readyState === WebSocket.OPEN;
31
+ }
32
+ setFencingToken(value) {
33
+ if (!Number.isSafeInteger(value) || value < 1) {
34
+ throw new Error('Invalid AI Live Assist fencing token.');
35
+ }
36
+ this.fencingToken = value;
37
+ }
38
+ subscribe(handler) {
39
+ this.handlers.add(handler);
40
+ return () => this.handlers.delete(handler);
41
+ }
42
+ connect(signal, resume) {
43
+ return new Promise((resolve, reject) => {
44
+ const url = new URL(this.result.controlUrl);
45
+ url.searchParams.set('session', this.result.sessionId);
46
+ const socket = new WebSocket(url, [
47
+ `oxvo-ai-live-assist-v${this.result.protocolVersion}`,
48
+ `oxvo-auth.${this.result.controlCredential}`,
49
+ ]);
50
+ this.socket = socket;
51
+ this.intentionallyClosed = false;
52
+ const abort = () => {
53
+ socket.close(1000, 'cancelled');
54
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
55
+ };
56
+ signal.addEventListener('abort', abort, { once: true });
57
+ socket.addEventListener('open', () => {
58
+ signal.removeEventListener('abort', abort);
59
+ this.send('hello', {
60
+ protocolMin: 1,
61
+ protocolMax: this.result.protocolVersion,
62
+ clientVersion: this.clientVersion,
63
+ resume,
64
+ });
65
+ resolve();
66
+ }, { once: true });
67
+ socket.addEventListener('message', (event) => {
68
+ void this.handleMessage(event.data);
69
+ });
70
+ socket.addEventListener('error', () => {
71
+ signal.removeEventListener('abort', abort);
72
+ if (socket.readyState !== WebSocket.OPEN) {
73
+ reject(new Error('The AI Live Assist control channel could not connect.'));
74
+ }
75
+ }, { once: true });
76
+ socket.addEventListener('close', () => {
77
+ this.stopHeartbeat();
78
+ if (!this.intentionallyClosed && !signal.aborted)
79
+ this.onDisconnect();
80
+ });
81
+ });
82
+ }
83
+ send(type, payload) {
84
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
85
+ throw new Error('The AI Live Assist control channel is not connected.');
86
+ }
87
+ const id = `msg_${crypto.randomUUID()}`;
88
+ this.clientSequence += 1;
89
+ this.socket.send(JSON.stringify({
90
+ v: this.result.protocolVersion,
91
+ id,
92
+ type,
93
+ sessionId: this.result.sessionId,
94
+ tabId: this.tabId,
95
+ sequence: this.clientSequence,
96
+ fencingToken: this.fencingToken,
97
+ sentAt: new Date().toISOString(),
98
+ payload,
99
+ }));
100
+ this.onClientSequence(this.clientSequence);
101
+ return id;
102
+ }
103
+ waitForAck(messageId, timeoutMs = 2500) {
104
+ if (this.recentAcks.delete(messageId))
105
+ return Promise.resolve(true);
106
+ return new Promise((resolve) => {
107
+ const timeout = setTimeout(() => {
108
+ this.pendingAcks.delete(messageId);
109
+ resolve(false);
110
+ }, Math.max(250, Math.min(10000, timeoutMs)));
111
+ this.pendingAcks.set(messageId, (acknowledged) => {
112
+ clearTimeout(timeout);
113
+ this.pendingAcks.delete(messageId);
114
+ resolve(acknowledged);
115
+ });
116
+ });
117
+ }
118
+ startHeartbeat(seconds) {
119
+ this.stopHeartbeat();
120
+ const interval = Math.max(5, Math.min(30, seconds)) * 1000;
121
+ this.heartbeat = setInterval(() => {
122
+ if (!this.isOpen || this.fencingToken < 1)
123
+ return;
124
+ try {
125
+ this.send('heartbeat', { lastServerSequence: this.serverSequence });
126
+ }
127
+ catch {
128
+ this.onDisconnect();
129
+ }
130
+ }, interval);
131
+ }
132
+ close(code = 1000, reason = 'closed') {
133
+ this.intentionallyClosed = true;
134
+ this.stopHeartbeat();
135
+ this.socket?.close(code, reason.slice(0, 120));
136
+ this.socket = null;
137
+ this.handlers.clear();
138
+ for (const resolve of this.pendingAcks.values())
139
+ resolve(false);
140
+ this.pendingAcks.clear();
141
+ this.recentAcks.clear();
142
+ }
143
+ async handleMessage(raw) {
144
+ if (typeof raw !== 'string' || raw.length > 131072)
145
+ return;
146
+ let message;
147
+ try {
148
+ message = JSON.parse(raw);
149
+ }
150
+ catch {
151
+ this.close(INVALID_MESSAGE_CLOSE_CODE, 'invalid message');
152
+ return;
153
+ }
154
+ const directProtocolError = message.type === 'error' && message.sequence === 0;
155
+ if (message.v !== this.result.protocolVersion ||
156
+ message.sessionId !== this.result.sessionId ||
157
+ !Number.isSafeInteger(message.sequence) ||
158
+ (!directProtocolError && message.sequence <= this.serverSequence) ||
159
+ typeof message.type !== 'string' ||
160
+ !message.payload ||
161
+ typeof message.payload !== 'object') {
162
+ this.close(PROTOCOL_VIOLATION_CLOSE_CODE, 'protocol violation');
163
+ return;
164
+ }
165
+ if (!directProtocolError)
166
+ this.serverSequence = message.sequence;
167
+ if (message.type === 'ack' && typeof message.payload.messageId === 'string') {
168
+ const messageId = message.payload.messageId;
169
+ const pending = this.pendingAcks.get(messageId);
170
+ if (pending)
171
+ pending(true);
172
+ else {
173
+ this.recentAcks.add(messageId);
174
+ if (this.recentAcks.size > 100) {
175
+ const oldest = this.recentAcks.values().next().value;
176
+ if (typeof oldest === 'string')
177
+ this.recentAcks.delete(oldest);
178
+ }
179
+ }
180
+ }
181
+ for (const handler of this.handlers)
182
+ await handler(message);
183
+ }
184
+ stopHeartbeat() {
185
+ if (this.heartbeat)
186
+ clearInterval(this.heartbeat);
187
+ this.heartbeat = null;
188
+ }
189
+ }
190
+ exports.ControlChannel = ControlChannel;
@@ -0,0 +1,18 @@
1
+ export type VoicePresenceVisualState = "unavailable" | "launcher" | "activation_modal" | "preflight" | "requesting_microphone" | "connecting" | "active.ready" | "active.listening" | "active.thinking" | "active.speaking" | "active.acting" | "active.awaiting_confirmation" | "active.reconnecting" | "ending" | "ended" | "recoverable_error" | "terminal_error";
2
+ export type VoicePresenceStateContext = {
3
+ sessionId: string | null;
4
+ tabId: string;
5
+ pageId: string | null;
6
+ serverSequence: number;
7
+ fencingToken: number;
8
+ };
9
+ export declare class VoicePresenceStateMachine {
10
+ private readonly onChange;
11
+ private current;
12
+ private context;
13
+ constructor(tabId: string, onChange?: (state: VoicePresenceVisualState, context: VoicePresenceStateContext) => void);
14
+ get state(): VoicePresenceVisualState;
15
+ get snapshot(): VoicePresenceStateContext;
16
+ transition(next: VoicePresenceVisualState, update?: Partial<VoicePresenceStateContext>): boolean;
17
+ reset(): void;
18
+ }
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VoicePresenceStateMachine = void 0;
4
+ const ACTIVE_STATES = [
5
+ "active.ready",
6
+ "active.listening",
7
+ "active.thinking",
8
+ "active.speaking",
9
+ "active.acting",
10
+ "active.awaiting_confirmation",
11
+ "active.reconnecting",
12
+ ];
13
+ const transitions = new Map([
14
+ ["unavailable", new Set(["launcher"])],
15
+ ["launcher", new Set(["activation_modal", "active.reconnecting", "unavailable"])],
16
+ ["activation_modal", new Set(["launcher", "preflight", "recoverable_error", "terminal_error"])],
17
+ ["preflight", new Set(["requesting_microphone", "recoverable_error", "terminal_error", "launcher"])],
18
+ ["requesting_microphone", new Set(["connecting", "recoverable_error", "terminal_error", "launcher"])],
19
+ ["connecting", new Set(["active.ready", "active.reconnecting", "recoverable_error", "terminal_error", "ending"])],
20
+ ["active.reconnecting", new Set(["connecting", "active.ready", "recoverable_error", "terminal_error", "ending"])],
21
+ ["recoverable_error", new Set(["launcher", "preflight", "requesting_microphone", "connecting", "active.reconnecting", "ending", "terminal_error"])],
22
+ ["terminal_error", new Set(["launcher", "ended"])],
23
+ ["ending", new Set(["ended", "terminal_error"])],
24
+ ["ended", new Set(["launcher"])],
25
+ ]);
26
+ for (const state of ACTIVE_STATES) {
27
+ const allowed = transitions.get(state) ?? new Set();
28
+ for (const next of ACTIVE_STATES)
29
+ allowed.add(next);
30
+ allowed.add("ending");
31
+ allowed.add("recoverable_error");
32
+ allowed.add("terminal_error");
33
+ transitions.set(state, allowed);
34
+ }
35
+ class VoicePresenceStateMachine {
36
+ constructor(tabId, onChange = () => undefined) {
37
+ this.onChange = onChange;
38
+ this.current = "unavailable";
39
+ this.context = {
40
+ sessionId: null,
41
+ tabId,
42
+ pageId: null,
43
+ serverSequence: 0,
44
+ fencingToken: 0,
45
+ };
46
+ }
47
+ get state() {
48
+ return this.current;
49
+ }
50
+ get snapshot() {
51
+ return { ...this.context };
52
+ }
53
+ transition(next, update = {}) {
54
+ if (update.serverSequence !== undefined &&
55
+ update.serverSequence <= this.context.serverSequence) {
56
+ return false;
57
+ }
58
+ if (update.sessionId &&
59
+ this.context.sessionId &&
60
+ update.sessionId !== this.context.sessionId) {
61
+ return false;
62
+ }
63
+ if (next !== this.current && !transitions.get(this.current)?.has(next)) {
64
+ return false;
65
+ }
66
+ this.context = { ...this.context, ...update };
67
+ this.current = next;
68
+ this.onChange(next, this.snapshot);
69
+ return true;
70
+ }
71
+ reset() {
72
+ this.current = "unavailable";
73
+ this.context = {
74
+ ...this.context,
75
+ sessionId: null,
76
+ pageId: null,
77
+ serverSequence: 0,
78
+ fencingToken: 0,
79
+ };
80
+ }
81
+ }
82
+ exports.VoicePresenceStateMachine = VoicePresenceStateMachine;
package/cjs/index.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { App, Options as BrowserOptions } from '@oxvo/browser';
2
+ import AiLiveAssist from './AiLiveAssist.js';
3
+ import type { PluginOptions } from './types.js';
4
+ import { VERSION } from './version.js';
5
+ export type { PluginOptions } from './types.js';
6
+ export { AiLiveAssist, VERSION };
7
+ export default function aiLiveAssist(options?: PluginOptions): (app: App | null) => AiLiveAssist | undefined;
8
+ export declare function installAiLiveAssist(input: {
9
+ tracker: {
10
+ use<T>(plugin: (app: App | null, options?: Partial<BrowserOptions>) => T): T;
11
+ };
12
+ options?: PluginOptions;
13
+ }): AiLiveAssist | undefined;
package/cjs/index.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VERSION = exports.AiLiveAssist = void 0;
4
+ exports.default = aiLiveAssist;
5
+ exports.installAiLiveAssist = installAiLiveAssist;
6
+ const AiLiveAssist_js_1 = require("./AiLiveAssist.js");
7
+ exports.AiLiveAssist = AiLiveAssist_js_1.default;
8
+ const version_js_1 = require("./version.js");
9
+ Object.defineProperty(exports, "VERSION", { enumerable: true, get: function () { return version_js_1.VERSION; } });
10
+ const installed = new WeakMap();
11
+ function aiLiveAssist(options = {}) {
12
+ return function install(app) {
13
+ if (!app || options.enabled === false || app.insideIframe)
14
+ return undefined;
15
+ const existing = installed.get(app);
16
+ if (existing)
17
+ return existing;
18
+ if (document.querySelector('[data-oxvo-ai-live-assist-root]')) {
19
+ return undefined;
20
+ }
21
+ if (!app.checkRequiredVersion?.('7.3.0')) {
22
+ console.warn('OXVO AI Live Assist requires @oxvo/browser version 7.3.0 or newer.');
23
+ return undefined;
24
+ }
25
+ const instance = new AiLiveAssist_js_1.default(app, options);
26
+ installed.set(app, instance);
27
+ return instance;
28
+ };
29
+ }
30
+ function installAiLiveAssist(input) {
31
+ return input.tracker.use(aiLiveAssist(input.options));
32
+ }
package/cjs/media.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { AssistMode, LaunchResult } from './types.js';
2
+ import type { RuntimeClient } from './client.js';
3
+ type MediaCallbacks = {
4
+ onLocalStream: (stream: MediaStream | null) => void;
5
+ onRemoteStream: (stream: MediaStream) => void;
6
+ onConnectionState: (state: RTCPeerConnectionState) => void;
7
+ onMicrophoneUnavailable: () => void;
8
+ };
9
+ export declare class RealtimeMedia {
10
+ private readonly client;
11
+ private readonly callbacks;
12
+ private connection;
13
+ private localStream;
14
+ private dataChannel;
15
+ private manualTurn;
16
+ private pushActive;
17
+ private muted;
18
+ private replacingDevice;
19
+ constructor(client: RuntimeClient, callbacks: MediaCallbacks);
20
+ prepare(mode: AssistMode): Promise<void>;
21
+ connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal): Promise<number>;
22
+ setMuted(muted: boolean): void;
23
+ setManualTurn(manual: boolean): void;
24
+ beginPushToTalk(): void;
25
+ endPushToTalk(): void;
26
+ switchToTextOnly(): void;
27
+ close(): void;
28
+ private applyTrackState;
29
+ private bindTrackEnd;
30
+ private onDeviceChange;
31
+ private replaceUnavailableDevice;
32
+ private waitForIce;
33
+ }
34
+ export {};
package/cjs/media.js ADDED
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RealtimeMedia = void 0;
4
+ class RealtimeMedia {
5
+ constructor(client, callbacks) {
6
+ this.client = client;
7
+ this.callbacks = callbacks;
8
+ this.connection = null;
9
+ this.localStream = null;
10
+ this.dataChannel = null;
11
+ this.manualTurn = false;
12
+ this.pushActive = false;
13
+ this.muted = false;
14
+ this.replacingDevice = false;
15
+ this.onDeviceChange = () => {
16
+ void this.replaceUnavailableDevice();
17
+ };
18
+ }
19
+ async prepare(mode) {
20
+ if (mode !== 'voice')
21
+ return;
22
+ if (!navigator.mediaDevices?.getUserMedia) {
23
+ throw new Error('Microphone access is not available in this browser.');
24
+ }
25
+ this.localStream = await navigator.mediaDevices.getUserMedia({
26
+ audio: {
27
+ autoGainControl: true,
28
+ echoCancellation: true,
29
+ noiseSuppression: true,
30
+ },
31
+ video: false,
32
+ });
33
+ navigator.mediaDevices.addEventListener?.('devicechange', this.onDeviceChange);
34
+ this.bindTrackEnd();
35
+ this.applyTrackState();
36
+ this.callbacks.onLocalStream(this.localStream);
37
+ }
38
+ async connect(result, mode, signal) {
39
+ const connection = new RTCPeerConnection({ bundlePolicy: 'max-bundle' });
40
+ this.connection = connection;
41
+ this.dataChannel = connection.createDataChannel('oai-events', {
42
+ ordered: true,
43
+ });
44
+ if (mode === 'voice') {
45
+ const stream = this.localStream;
46
+ if (!stream)
47
+ throw new Error('Microphone access was not prepared.');
48
+ for (const track of stream.getAudioTracks())
49
+ connection.addTrack(track, stream);
50
+ }
51
+ else {
52
+ // OpenAI's WebRTC endpoint requires an audio media section even when the
53
+ // configured response modality is text-only.
54
+ connection.addTransceiver('audio', { direction: 'recvonly' });
55
+ }
56
+ connection.addEventListener('track', (event) => {
57
+ const stream = event.streams[0];
58
+ if (stream)
59
+ this.callbacks.onRemoteStream(stream);
60
+ });
61
+ connection.addEventListener('connectionstatechange', () => {
62
+ this.callbacks.onConnectionState(connection.connectionState);
63
+ });
64
+ const offer = await connection.createOffer();
65
+ await connection.setLocalDescription(offer);
66
+ await this.waitForIce(connection, signal);
67
+ const localSdp = connection.localDescription?.sdp;
68
+ if (!localSdp)
69
+ throw new Error('The browser could not create a realtime offer.');
70
+ const response = await this.client.offer(result, localSdp, signal);
71
+ await connection.setRemoteDescription({ type: 'answer', sdp: response.answerSdp });
72
+ return response.fencingToken;
73
+ }
74
+ setMuted(muted) {
75
+ this.muted = muted;
76
+ this.applyTrackState();
77
+ }
78
+ setManualTurn(manual) {
79
+ this.manualTurn = manual;
80
+ this.pushActive = false;
81
+ this.applyTrackState();
82
+ }
83
+ beginPushToTalk() {
84
+ if (!this.manualTurn || this.muted)
85
+ return;
86
+ this.pushActive = true;
87
+ this.applyTrackState();
88
+ }
89
+ endPushToTalk() {
90
+ this.pushActive = false;
91
+ this.applyTrackState();
92
+ }
93
+ switchToTextOnly() {
94
+ for (const sender of this.connection?.getSenders() ?? []) {
95
+ if (sender.track?.kind === 'audio') {
96
+ sender.track.stop();
97
+ void sender.replaceTrack(null);
98
+ }
99
+ }
100
+ for (const track of this.localStream?.getTracks() ?? [])
101
+ track.stop();
102
+ this.localStream = null;
103
+ this.callbacks.onLocalStream(null);
104
+ navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
105
+ }
106
+ close() {
107
+ this.dataChannel?.close();
108
+ this.dataChannel = null;
109
+ for (const track of this.localStream?.getTracks() ?? [])
110
+ track.stop();
111
+ this.localStream = null;
112
+ this.callbacks.onLocalStream(null);
113
+ navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
114
+ this.connection?.close();
115
+ this.connection = null;
116
+ }
117
+ applyTrackState() {
118
+ const enabled = !this.muted && (!this.manualTurn || this.pushActive);
119
+ for (const track of this.localStream?.getAudioTracks() ?? []) {
120
+ track.enabled = enabled;
121
+ }
122
+ }
123
+ bindTrackEnd() {
124
+ for (const track of this.localStream?.getAudioTracks() ?? []) {
125
+ track.addEventListener('ended', this.onDeviceChange, { once: true });
126
+ }
127
+ }
128
+ async replaceUnavailableDevice() {
129
+ if (this.replacingDevice || !this.connection || !this.localStream)
130
+ return;
131
+ const current = this.localStream.getAudioTracks()[0];
132
+ if (!current) {
133
+ this.callbacks.onMicrophoneUnavailable();
134
+ return;
135
+ }
136
+ if (current.readyState === 'live') {
137
+ try {
138
+ const devices = await navigator.mediaDevices.enumerateDevices();
139
+ const currentId = current.getSettings().deviceId;
140
+ if (!currentId ||
141
+ devices.some((device) => device.kind === 'audioinput' && device.deviceId === currentId)) {
142
+ return;
143
+ }
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ }
149
+ this.replacingDevice = true;
150
+ try {
151
+ const replacement = await navigator.mediaDevices.getUserMedia({
152
+ audio: {
153
+ autoGainControl: true,
154
+ echoCancellation: true,
155
+ noiseSuppression: true,
156
+ },
157
+ video: false,
158
+ });
159
+ const track = replacement.getAudioTracks()[0];
160
+ const sender = this.connection
161
+ .getSenders()
162
+ .find((candidate) => candidate.track?.kind === 'audio');
163
+ if (!track || !sender)
164
+ throw new Error('No replacement microphone track.');
165
+ await sender.replaceTrack(track);
166
+ for (const prior of this.localStream.getTracks())
167
+ prior.stop();
168
+ this.localStream = replacement;
169
+ this.bindTrackEnd();
170
+ this.applyTrackState();
171
+ this.callbacks.onLocalStream(replacement);
172
+ }
173
+ catch {
174
+ this.callbacks.onMicrophoneUnavailable();
175
+ }
176
+ finally {
177
+ this.replacingDevice = false;
178
+ }
179
+ }
180
+ waitForIce(connection, signal) {
181
+ if (connection.iceGatheringState === 'complete')
182
+ return Promise.resolve();
183
+ return new Promise((resolve, reject) => {
184
+ const timeout = setTimeout(finish, 2500);
185
+ const onState = () => {
186
+ if (connection.iceGatheringState === 'complete')
187
+ finish();
188
+ };
189
+ const onAbort = () => {
190
+ cleanup();
191
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
192
+ };
193
+ const cleanup = () => {
194
+ clearTimeout(timeout);
195
+ connection.removeEventListener('icegatheringstatechange', onState);
196
+ signal.removeEventListener('abort', onAbort);
197
+ };
198
+ function finish() {
199
+ cleanup();
200
+ resolve();
201
+ }
202
+ connection.addEventListener('icegatheringstatechange', onState);
203
+ signal.addEventListener('abort', onAbort, { once: true });
204
+ });
205
+ }
206
+ }
207
+ exports.RealtimeMedia = RealtimeMedia;
@@ -0,0 +1,2 @@
1
+ import type { WidgetMessageKey } from "./types.js";
2
+ export declare const defaultMessages: Record<WidgetMessageKey, string>;