@oxvo/ai-live-assist 7.3.4 → 7.4.23

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 (67) hide show
  1. package/cjs/AiLiveAssist.d.ts +588 -6
  2. package/cjs/AiLiveAssist.js +14892 -1240
  3. package/cjs/client.d.ts +71 -10
  4. package/cjs/client.js +316 -58
  5. package/cjs/context.d.ts +15 -5
  6. package/cjs/context.js +568 -73
  7. package/cjs/control.d.ts +72 -7
  8. package/cjs/control.js +594 -71
  9. package/cjs/dialogFocusScope.d.ts +37 -0
  10. package/cjs/dialogFocusScope.js +227 -0
  11. package/cjs/experienceState.js +1 -1
  12. package/cjs/index.d.ts +1 -1
  13. package/cjs/index.js +2 -2
  14. package/cjs/media.d.ts +14 -3
  15. package/cjs/media.js +128 -19
  16. package/cjs/messages.js +26 -3
  17. package/cjs/messengerLoader.d.ts +3 -0
  18. package/cjs/messengerLoader.js +156 -0
  19. package/cjs/pageAssistancePreference.d.ts +78 -0
  20. package/cjs/pageAssistancePreference.js +488 -0
  21. package/cjs/preview.d.ts +33 -0
  22. package/cjs/preview.js +567 -0
  23. package/cjs/tabLock.d.ts +54 -3
  24. package/cjs/tabLock.js +752 -72
  25. package/cjs/types.d.ts +152 -4
  26. package/cjs/ui.d.ts +200 -10
  27. package/cjs/ui.js +6946 -523
  28. package/cjs/version.d.ts +1 -1
  29. package/cjs/version.js +1 -1
  30. package/cjs/voicePresenceUi.d.ts +48 -9
  31. package/cjs/voicePresenceUi.js +863 -125
  32. package/lib/AiLiveAssist.d.ts +588 -6
  33. package/lib/AiLiveAssist.js +14897 -1248
  34. package/lib/client.d.ts +71 -10
  35. package/lib/client.js +313 -57
  36. package/lib/context.d.ts +15 -5
  37. package/lib/context.js +566 -72
  38. package/lib/control.d.ts +72 -7
  39. package/lib/control.js +594 -71
  40. package/lib/dialogFocusScope.d.ts +37 -0
  41. package/lib/dialogFocusScope.js +223 -0
  42. package/lib/experienceState.js +1 -1
  43. package/lib/index.d.ts +1 -1
  44. package/lib/index.js +2 -2
  45. package/lib/media.d.ts +14 -3
  46. package/lib/media.js +128 -19
  47. package/lib/messages.js +26 -3
  48. package/lib/messengerLoader.d.ts +3 -0
  49. package/lib/messengerLoader.js +151 -0
  50. package/lib/pageAssistancePreference.d.ts +78 -0
  51. package/lib/pageAssistancePreference.js +482 -0
  52. package/lib/preview.d.ts +33 -0
  53. package/lib/preview.js +561 -0
  54. package/lib/tabLock.d.ts +54 -3
  55. package/lib/tabLock.js +752 -72
  56. package/lib/types.d.ts +152 -4
  57. package/lib/ui.d.ts +200 -10
  58. package/lib/ui.js +6947 -524
  59. package/lib/version.d.ts +1 -1
  60. package/lib/version.js +1 -1
  61. package/lib/voicePresenceUi.d.ts +48 -9
  62. package/lib/voicePresenceUi.js +863 -125
  63. package/package.json +8 -3
  64. package/cjs/recordingPolicy.d.ts +0 -2
  65. package/cjs/recordingPolicy.js +0 -12
  66. package/lib/recordingPolicy.d.ts +0 -2
  67. package/lib/recordingPolicy.js +0 -8
@@ -0,0 +1,37 @@
1
+ type DialogPresentation = {
2
+ surface: HTMLElement;
3
+ initialFocus: () => HTMLElement | null;
4
+ fallbackFocus: () => HTMLElement | null;
5
+ onEscape: () => void;
6
+ };
7
+ /**
8
+ * Owns keyboard focus for one safety presentation at a time. Presentation
9
+ * replacement is generation-guarded so stale microtasks cannot focus or
10
+ * restore an earlier prompt.
11
+ */
12
+ export declare class DialogFocusScope {
13
+ private readonly root;
14
+ private readonly host;
15
+ private current;
16
+ private restoreTarget;
17
+ private generation;
18
+ private redirectGeneration;
19
+ private containmentBound;
20
+ private destroyed;
21
+ constructor(root: ShadowRoot, host: HTMLElement);
22
+ present(value: DialogPresentation): void;
23
+ dismiss(surface: HTMLElement, options?: {
24
+ restore?: boolean;
25
+ }): void;
26
+ clear(options?: {
27
+ restore?: boolean;
28
+ }): void;
29
+ destroy(): void;
30
+ private bindContainment;
31
+ private unbindContainment;
32
+ private focusPresentation;
33
+ private onFocusIn;
34
+ private activeFocusTarget;
35
+ private onKeyDown;
36
+ }
37
+ export {};
@@ -0,0 +1,223 @@
1
+ const focusableSelector = [
2
+ "button:not([disabled])",
3
+ "input:not([disabled])",
4
+ "textarea:not([disabled])",
5
+ "select:not([disabled])",
6
+ "summary",
7
+ "a[href]",
8
+ '[tabindex]:not([tabindex="-1"])',
9
+ ].join(",");
10
+ const isVisibleFocusTarget = (element) => {
11
+ if (!element?.isConnected ||
12
+ element.hidden ||
13
+ element.getAttribute("aria-hidden") === "true" ||
14
+ element.closest("[hidden],[inert]") ||
15
+ ("disabled" in element && element.disabled === true)) {
16
+ return false;
17
+ }
18
+ try {
19
+ const computed = getComputedStyle(element);
20
+ return computed.display !== "none" && computed.visibility !== "hidden";
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ };
26
+ /**
27
+ * Owns keyboard focus for one safety presentation at a time. Presentation
28
+ * replacement is generation-guarded so stale microtasks cannot focus or
29
+ * restore an earlier prompt.
30
+ */
31
+ export class DialogFocusScope {
32
+ constructor(root, host) {
33
+ this.root = root;
34
+ this.host = host;
35
+ this.current = null;
36
+ this.restoreTarget = null;
37
+ this.generation = 0;
38
+ this.redirectGeneration = null;
39
+ this.containmentBound = false;
40
+ this.destroyed = false;
41
+ this.onFocusIn = (event) => {
42
+ const presentation = this.current;
43
+ if (!presentation || this.destroyed)
44
+ return;
45
+ const eventPath = event.composedPath?.() ?? [];
46
+ const eventTarget = event.target;
47
+ if (eventPath.includes(presentation.surface) ||
48
+ (eventTarget instanceof Node &&
49
+ presentation.surface.contains(eventTarget))) {
50
+ return;
51
+ }
52
+ const generation = this.generation;
53
+ if (this.redirectGeneration === generation)
54
+ return;
55
+ this.redirectGeneration = generation;
56
+ queueMicrotask(() => {
57
+ if (this.redirectGeneration === generation) {
58
+ this.redirectGeneration = null;
59
+ }
60
+ if (this.destroyed ||
61
+ generation !== this.generation ||
62
+ this.current !== presentation ||
63
+ presentation.surface.contains(this.root.activeElement)) {
64
+ return;
65
+ }
66
+ this.focusPresentation(presentation);
67
+ });
68
+ };
69
+ this.onKeyDown = (event) => {
70
+ const presentation = this.current;
71
+ if (!presentation || event.currentTarget !== presentation.surface)
72
+ return;
73
+ if (event.key === "Escape") {
74
+ event.preventDefault();
75
+ event.stopPropagation();
76
+ presentation.onEscape();
77
+ return;
78
+ }
79
+ if (event.key !== "Tab")
80
+ return;
81
+ const focusable = [
82
+ ...presentation.surface.querySelectorAll(focusableSelector),
83
+ ].filter(isVisibleFocusTarget);
84
+ const first = focusable[0];
85
+ const last = focusable.at(-1);
86
+ if (!first || !last)
87
+ return;
88
+ const active = this.root.activeElement;
89
+ if (!presentation.surface.contains(active)) {
90
+ event.preventDefault();
91
+ (event.shiftKey ? last : first).focus({ preventScroll: true });
92
+ return;
93
+ }
94
+ if (event.shiftKey && active === first) {
95
+ event.preventDefault();
96
+ last.focus({ preventScroll: true });
97
+ }
98
+ else if (!event.shiftKey && active === last) {
99
+ event.preventDefault();
100
+ first.focus({ preventScroll: true });
101
+ }
102
+ };
103
+ }
104
+ present(value) {
105
+ if (this.destroyed)
106
+ return;
107
+ if (!this.current && !isVisibleFocusTarget(this.restoreTarget)) {
108
+ this.restoreTarget = this.activeFocusTarget(value);
109
+ }
110
+ this.current?.surface.removeEventListener("keydown", this.onKeyDown);
111
+ this.current = value;
112
+ this.generation += 1;
113
+ this.redirectGeneration = null;
114
+ const generation = this.generation;
115
+ value.surface.addEventListener("keydown", this.onKeyDown);
116
+ this.bindContainment();
117
+ const immediateTarget = value.initialFocus();
118
+ if (isVisibleFocusTarget(immediateTarget)) {
119
+ immediateTarget.focus({ preventScroll: true });
120
+ }
121
+ queueMicrotask(() => {
122
+ if (this.destroyed ||
123
+ generation !== this.generation ||
124
+ this.current !== value) {
125
+ return;
126
+ }
127
+ if (value.surface.contains(this.root.activeElement))
128
+ return;
129
+ const target = value.initialFocus();
130
+ if (isVisibleFocusTarget(target))
131
+ target.focus({ preventScroll: true });
132
+ });
133
+ }
134
+ dismiss(surface, options = {}) {
135
+ if (this.current?.surface !== surface)
136
+ return;
137
+ const presentation = this.current;
138
+ presentation.surface.removeEventListener("keydown", this.onKeyDown);
139
+ this.current = null;
140
+ this.generation += 1;
141
+ this.redirectGeneration = null;
142
+ this.unbindContainment();
143
+ const generation = this.generation;
144
+ if (options.restore === false) {
145
+ this.restoreTarget = null;
146
+ return;
147
+ }
148
+ const restoreTarget = this.restoreTarget;
149
+ queueMicrotask(() => {
150
+ if (this.destroyed ||
151
+ generation !== this.generation ||
152
+ this.current) {
153
+ return;
154
+ }
155
+ const fallback = presentation.fallbackFocus();
156
+ const target = isVisibleFocusTarget(restoreTarget)
157
+ ? restoreTarget
158
+ : isVisibleFocusTarget(fallback)
159
+ ? fallback
160
+ : null;
161
+ this.restoreTarget = null;
162
+ target?.focus({ preventScroll: true });
163
+ });
164
+ }
165
+ clear(options = {}) {
166
+ const surface = this.current?.surface;
167
+ if (surface) {
168
+ this.dismiss(surface, options);
169
+ return;
170
+ }
171
+ this.generation += 1;
172
+ this.restoreTarget = null;
173
+ }
174
+ destroy() {
175
+ if (this.destroyed)
176
+ return;
177
+ this.destroyed = true;
178
+ this.current?.surface.removeEventListener("keydown", this.onKeyDown);
179
+ this.unbindContainment();
180
+ this.current = null;
181
+ this.restoreTarget = null;
182
+ this.redirectGeneration = null;
183
+ this.generation += 1;
184
+ }
185
+ bindContainment() {
186
+ if (this.containmentBound)
187
+ return;
188
+ this.containmentBound = true;
189
+ this.root.addEventListener("focusin", this.onFocusIn, true);
190
+ document.addEventListener("focusin", this.onFocusIn, true);
191
+ }
192
+ unbindContainment() {
193
+ if (!this.containmentBound)
194
+ return;
195
+ this.containmentBound = false;
196
+ this.root.removeEventListener("focusin", this.onFocusIn, true);
197
+ document.removeEventListener("focusin", this.onFocusIn, true);
198
+ }
199
+ focusPresentation(presentation) {
200
+ const preferred = presentation.initialFocus();
201
+ if (isVisibleFocusTarget(preferred)) {
202
+ preferred.focus({ preventScroll: true });
203
+ return;
204
+ }
205
+ const first = [
206
+ ...presentation.surface.querySelectorAll(focusableSelector),
207
+ ].find(isVisibleFocusTarget);
208
+ first?.focus({ preventScroll: true });
209
+ }
210
+ activeFocusTarget(value) {
211
+ const shadowActive = this.root.activeElement;
212
+ if (shadowActive instanceof HTMLElement)
213
+ return shadowActive;
214
+ const documentActive = document.activeElement;
215
+ if (documentActive instanceof HTMLElement &&
216
+ documentActive !== document.body &&
217
+ documentActive !== document.documentElement &&
218
+ documentActive !== this.host) {
219
+ return documentActive;
220
+ }
221
+ return value.fallbackFocus();
222
+ }
223
+ }
@@ -16,7 +16,7 @@ const transitions = new Map([
16
16
  ["connecting", new Set(["active.ready", "active.reconnecting", "recoverable_error", "terminal_error", "ending"])],
17
17
  ["active.reconnecting", new Set(["connecting", "active.ready", "recoverable_error", "terminal_error", "ending"])],
18
18
  ["recoverable_error", new Set(["launcher", "preflight", "requesting_microphone", "connecting", "active.reconnecting", "ending", "terminal_error"])],
19
- ["terminal_error", new Set(["launcher", "ended"])],
19
+ ["terminal_error", new Set(["launcher", "ended", "ending"])],
20
20
  ["ending", new Set(["ended", "terminal_error"])],
21
21
  ["ended", new Set(["launcher"])],
22
22
  ]);
package/lib/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { App, Options as BrowserOptions } from '@oxvo/browser';
2
2
  import AiLiveAssist from './AiLiveAssist.js';
3
3
  import type { PluginOptions } from './types.js';
4
4
  import { VERSION } from './version.js';
5
- export type { PluginOptions } from './types.js';
5
+ export type { AiGuideControllerState, AiGuideOpenInput, MessengerCompanionConfig, PluginOptions, } from './types.js';
6
6
  export { AiLiveAssist, VERSION };
7
7
  export default function aiLiveAssist(options?: PluginOptions): (app: App | null) => AiLiveAssist | undefined;
8
8
  export declare function installAiLiveAssist(input: {
package/lib/index.js CHANGED
@@ -12,8 +12,8 @@ export default function aiLiveAssist(options = {}) {
12
12
  if (document.querySelector('[data-oxvo-ai-live-assist-root]')) {
13
13
  return undefined;
14
14
  }
15
- if (!app.checkRequiredVersion?.('7.3.0')) {
16
- console.warn('OXVO AI Guide requires @oxvo/browser version 7.3.0 or newer.');
15
+ if (!app.checkRequiredVersion?.('7.4.23')) {
16
+ console.warn('OXVO AI Guide requires @oxvo/browser version 7.4.23 or newer.');
17
17
  return undefined;
18
18
  }
19
19
  const instance = new AiLiveAssist(app, options);
package/lib/media.d.ts CHANGED
@@ -6,9 +6,11 @@ type MediaCallbacks = {
6
6
  onConnectionState: (state: RTCPeerConnectionState) => void;
7
7
  onMicrophoneUnavailable: () => void;
8
8
  };
9
+ type MediaLifecycleCheck = () => boolean;
9
10
  export declare class RealtimeMedia {
10
11
  private readonly client;
11
12
  private readonly callbacks;
13
+ private readonly isCurrent;
12
14
  private connection;
13
15
  private localStream;
14
16
  private dataChannel;
@@ -16,10 +18,16 @@ export declare class RealtimeMedia {
16
18
  private pushActive;
17
19
  private muted;
18
20
  private replacingDevice;
19
- constructor(client: RuntimeClient, callbacks: MediaCallbacks);
20
- prepare(mode: AssistMode): Promise<void>;
21
- connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal): Promise<number>;
21
+ private closed;
22
+ private generation;
23
+ private deviceGeneration;
24
+ private signal;
25
+ private readonly lifetimeAbort;
26
+ constructor(client: RuntimeClient, callbacks: MediaCallbacks, isCurrent?: MediaLifecycleCheck);
27
+ prepare(mode: AssistMode, signal: AbortSignal): Promise<void>;
28
+ connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal, offerIdempotencyKey?: string, expectedSessionState?: 'active_text' | 'active_voice'): Promise<number>;
22
29
  setMuted(muted: boolean): void;
30
+ get isMuted(): boolean;
23
31
  setManualTurn(manual: boolean): void;
24
32
  beginPushToTalk(): void;
25
33
  endPushToTalk(): void;
@@ -30,5 +38,8 @@ export declare class RealtimeMedia {
30
38
  private onDeviceChange;
31
39
  private replaceUnavailableDevice;
32
40
  private waitForIce;
41
+ private active;
42
+ private assertActive;
43
+ private assertReplacementActive;
33
44
  }
34
45
  export {};
package/lib/media.js CHANGED
@@ -1,7 +1,13 @@
1
+ const abortError = () => new DOMException('The operation was aborted.', 'AbortError');
2
+ const stopStream = (stream) => {
3
+ for (const track of stream?.getTracks() ?? [])
4
+ track.stop();
5
+ };
1
6
  export class RealtimeMedia {
2
- constructor(client, callbacks) {
7
+ constructor(client, callbacks, isCurrent = () => true) {
3
8
  this.client = client;
4
9
  this.callbacks = callbacks;
10
+ this.isCurrent = isCurrent;
5
11
  this.connection = null;
6
12
  this.localStream = null;
7
13
  this.dataChannel = null;
@@ -9,17 +15,32 @@ export class RealtimeMedia {
9
15
  this.pushActive = false;
10
16
  this.muted = false;
11
17
  this.replacingDevice = false;
18
+ this.closed = false;
19
+ this.generation = 0;
20
+ this.deviceGeneration = 0;
21
+ this.signal = null;
22
+ this.lifetimeAbort = new AbortController();
12
23
  this.onDeviceChange = () => {
24
+ if (!this.connection && this.localStream) {
25
+ const track = this.localStream.getAudioTracks()[0];
26
+ if (!track || track.readyState !== 'live') {
27
+ this.callbacks.onMicrophoneUnavailable();
28
+ }
29
+ return;
30
+ }
13
31
  void this.replaceUnavailableDevice();
14
32
  };
15
33
  }
16
- async prepare(mode) {
34
+ async prepare(mode, signal) {
35
+ this.signal = signal;
36
+ const generation = this.generation;
37
+ this.assertActive(signal, generation);
17
38
  if (mode !== 'voice')
18
39
  return;
19
40
  if (!navigator.mediaDevices?.getUserMedia) {
20
41
  throw new Error('Microphone access is not available in this browser.');
21
42
  }
22
- this.localStream = await navigator.mediaDevices.getUserMedia({
43
+ const stream = await navigator.mediaDevices.getUserMedia({
23
44
  audio: {
24
45
  autoGainControl: true,
25
46
  echoCancellation: true,
@@ -27,12 +48,23 @@ export class RealtimeMedia {
27
48
  },
28
49
  video: false,
29
50
  });
51
+ try {
52
+ this.assertActive(signal, generation);
53
+ }
54
+ catch (error) {
55
+ stopStream(stream);
56
+ throw error;
57
+ }
58
+ this.localStream = stream;
30
59
  navigator.mediaDevices.addEventListener?.('devicechange', this.onDeviceChange);
31
60
  this.bindTrackEnd();
32
61
  this.applyTrackState();
33
62
  this.callbacks.onLocalStream(this.localStream);
34
63
  }
35
- async connect(result, mode, signal) {
64
+ async connect(result, mode, signal, offerIdempotencyKey, expectedSessionState) {
65
+ this.signal = signal;
66
+ const generation = this.generation;
67
+ this.assertActive(signal, generation);
36
68
  const connection = new RTCPeerConnection({ bundlePolicy: 'max-bundle' });
37
69
  this.connection = connection;
38
70
  this.dataChannel = connection.createDataChannel('oai-events', {
@@ -51,27 +83,49 @@ export class RealtimeMedia {
51
83
  connection.addTransceiver('audio', { direction: 'recvonly' });
52
84
  }
53
85
  connection.addEventListener('track', (event) => {
86
+ if (!this.active(signal, generation) ||
87
+ this.connection !== connection) {
88
+ return;
89
+ }
54
90
  const stream = event.streams[0];
55
91
  if (stream)
56
92
  this.callbacks.onRemoteStream(stream);
57
93
  });
58
94
  connection.addEventListener('connectionstatechange', () => {
95
+ if (!this.active(signal, generation) ||
96
+ this.connection !== connection) {
97
+ return;
98
+ }
59
99
  this.callbacks.onConnectionState(connection.connectionState);
60
100
  });
61
101
  const offer = await connection.createOffer();
102
+ this.assertActive(signal, generation);
62
103
  await connection.setLocalDescription(offer);
104
+ this.assertActive(signal, generation);
63
105
  await this.waitForIce(connection, signal);
106
+ this.assertActive(signal, generation);
64
107
  const localSdp = connection.localDescription?.sdp;
65
108
  if (!localSdp)
66
109
  throw new Error('The browser could not create a realtime offer.');
67
- const response = await this.client.offer(result, localSdp, signal);
110
+ const response = offerIdempotencyKey
111
+ ? await this.client.offer(result, localSdp, signal, offerIdempotencyKey)
112
+ : await this.client.offer(result, localSdp, signal);
113
+ this.assertActive(signal, generation);
114
+ if (expectedSessionState &&
115
+ response.sessionState !== expectedSessionState) {
116
+ throw new Error('The realtime session activated in an unexpected mode.');
117
+ }
68
118
  await connection.setRemoteDescription({ type: 'answer', sdp: response.answerSdp });
119
+ this.assertActive(signal, generation);
69
120
  return response.fencingToken;
70
121
  }
71
122
  setMuted(muted) {
72
123
  this.muted = muted;
73
124
  this.applyTrackState();
74
125
  }
126
+ get isMuted() {
127
+ return this.muted;
128
+ }
75
129
  setManualTurn(manual) {
76
130
  this.manualTurn = manual;
77
131
  this.pushActive = false;
@@ -88,23 +142,28 @@ export class RealtimeMedia {
88
142
  this.applyTrackState();
89
143
  }
90
144
  switchToTextOnly() {
145
+ this.deviceGeneration += 1;
91
146
  for (const sender of this.connection?.getSenders() ?? []) {
92
147
  if (sender.track?.kind === 'audio') {
93
148
  sender.track.stop();
94
149
  void sender.replaceTrack(null);
95
150
  }
96
151
  }
97
- for (const track of this.localStream?.getTracks() ?? [])
98
- track.stop();
152
+ stopStream(this.localStream);
99
153
  this.localStream = null;
100
154
  this.callbacks.onLocalStream(null);
101
155
  navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
102
156
  }
103
157
  close() {
158
+ if (!this.closed) {
159
+ this.closed = true;
160
+ this.generation += 1;
161
+ this.deviceGeneration += 1;
162
+ this.lifetimeAbort.abort();
163
+ }
104
164
  this.dataChannel?.close();
105
165
  this.dataChannel = null;
106
- for (const track of this.localStream?.getTracks() ?? [])
107
- track.stop();
166
+ stopStream(this.localStream);
108
167
  this.localStream = null;
109
168
  this.callbacks.onLocalStream(null);
110
169
  navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
@@ -123,8 +182,16 @@ export class RealtimeMedia {
123
182
  }
124
183
  }
125
184
  async replaceUnavailableDevice() {
126
- if (this.replacingDevice || !this.connection || !this.localStream)
185
+ const signal = this.signal;
186
+ const generation = this.generation;
187
+ const deviceGeneration = this.deviceGeneration;
188
+ if (!signal ||
189
+ !this.active(signal, generation) ||
190
+ this.replacingDevice ||
191
+ !this.connection ||
192
+ !this.localStream) {
127
193
  return;
194
+ }
128
195
  const current = this.localStream.getAudioTracks()[0];
129
196
  if (!current) {
130
197
  this.callbacks.onMicrophoneUnavailable();
@@ -133,6 +200,7 @@ export class RealtimeMedia {
133
200
  if (current.readyState === 'live') {
134
201
  try {
135
202
  const devices = await navigator.mediaDevices.enumerateDevices();
203
+ this.assertReplacementActive(signal, generation, deviceGeneration);
136
204
  const currentId = current.getSettings().deviceId;
137
205
  if (!currentId ||
138
206
  devices.some((device) => device.kind === 'audioinput' && device.deviceId === currentId)) {
@@ -144,8 +212,11 @@ export class RealtimeMedia {
144
212
  }
145
213
  }
146
214
  this.replacingDevice = true;
215
+ let replacement = null;
147
216
  try {
148
- const replacement = await navigator.mediaDevices.getUserMedia({
217
+ const connection = this.connection;
218
+ const priorStream = this.localStream;
219
+ replacement = await navigator.mediaDevices.getUserMedia({
149
220
  audio: {
150
221
  autoGainControl: true,
151
222
  echoCancellation: true,
@@ -153,28 +224,41 @@ export class RealtimeMedia {
153
224
  },
154
225
  video: false,
155
226
  });
227
+ this.assertReplacementActive(signal, generation, deviceGeneration);
156
228
  const track = replacement.getAudioTracks()[0];
157
- const sender = this.connection
229
+ const sender = connection
158
230
  .getSenders()
159
231
  .find((candidate) => candidate.track?.kind === 'audio');
160
232
  if (!track || !sender)
161
233
  throw new Error('No replacement microphone track.');
162
234
  await sender.replaceTrack(track);
163
- for (const prior of this.localStream.getTracks())
164
- prior.stop();
165
- this.localStream = replacement;
235
+ this.assertReplacementActive(signal, generation, deviceGeneration);
236
+ if (this.connection !== connection || this.localStream !== priorStream) {
237
+ throw abortError();
238
+ }
239
+ stopStream(priorStream);
240
+ const activeReplacement = replacement;
241
+ this.localStream = activeReplacement;
242
+ replacement = null;
166
243
  this.bindTrackEnd();
167
244
  this.applyTrackState();
168
- this.callbacks.onLocalStream(replacement);
245
+ this.callbacks.onLocalStream(activeReplacement);
169
246
  }
170
- catch {
171
- this.callbacks.onMicrophoneUnavailable();
247
+ catch (error) {
248
+ stopStream(replacement);
249
+ if (!(error instanceof DOMException && error.name === 'AbortError') &&
250
+ this.active(signal, generation)) {
251
+ this.callbacks.onMicrophoneUnavailable();
252
+ }
172
253
  }
173
254
  finally {
174
255
  this.replacingDevice = false;
175
256
  }
176
257
  }
177
258
  waitForIce(connection, signal) {
259
+ if (signal.aborted || this.lifetimeAbort.signal.aborted) {
260
+ return Promise.reject(abortError());
261
+ }
178
262
  if (connection.iceGatheringState === 'complete')
179
263
  return Promise.resolve();
180
264
  return new Promise((resolve, reject) => {
@@ -185,12 +269,13 @@ export class RealtimeMedia {
185
269
  };
186
270
  const onAbort = () => {
187
271
  cleanup();
188
- reject(new DOMException('The operation was aborted.', 'AbortError'));
272
+ reject(abortError());
189
273
  };
190
274
  const cleanup = () => {
191
275
  clearTimeout(timeout);
192
276
  connection.removeEventListener('icegatheringstatechange', onState);
193
277
  signal.removeEventListener('abort', onAbort);
278
+ this.lifetimeAbort.signal.removeEventListener('abort', onAbort);
194
279
  };
195
280
  function finish() {
196
281
  cleanup();
@@ -198,6 +283,30 @@ export class RealtimeMedia {
198
283
  }
199
284
  connection.addEventListener('icegatheringstatechange', onState);
200
285
  signal.addEventListener('abort', onAbort, { once: true });
286
+ this.lifetimeAbort.signal.addEventListener('abort', onAbort, { once: true });
201
287
  });
202
288
  }
289
+ active(signal, generation) {
290
+ if (signal.aborted ||
291
+ this.lifetimeAbort.signal.aborted ||
292
+ this.closed ||
293
+ generation !== this.generation) {
294
+ return false;
295
+ }
296
+ try {
297
+ return this.isCurrent();
298
+ }
299
+ catch {
300
+ return false;
301
+ }
302
+ }
303
+ assertActive(signal, generation) {
304
+ if (!this.active(signal, generation))
305
+ throw abortError();
306
+ }
307
+ assertReplacementActive(signal, generation, deviceGeneration) {
308
+ this.assertActive(signal, generation);
309
+ if (deviceGeneration !== this.deviceGeneration)
310
+ throw abortError();
311
+ }
203
312
  }