@opengeni/sdk 0.36.1 → 0.37.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 +6 -1
- package/dist/chunk-DXDL7EEW.js +573 -0
- package/dist/chunk-DXDL7EEW.js.map +1 -0
- package/dist/chunk-OB5PGV6N.js +1988 -0
- package/dist/chunk-OB5PGV6N.js.map +1 -0
- package/dist/chunk-OINSI33U.js +97 -0
- package/dist/chunk-OINSI33U.js.map +1 -0
- package/dist/{chunk-B7E4FPNZ.js → chunk-VOQ235WN.js} +136 -106
- package/dist/chunk-VOQ235WN.js.map +1 -0
- package/dist/client.d.ts +35 -2
- package/dist/codex-realtime-controller.d.ts +109 -0
- package/dist/codex-realtime-controller.js +12 -0
- package/dist/codex-realtime-controller.js.map +1 -0
- package/dist/codex-realtime-lifecycle.d.ts +18 -0
- package/dist/codex-realtime-v3-wire.d.ts +86 -0
- package/dist/codex-realtime-v3.d.ts +52 -0
- package/dist/codex-realtime.d.ts +68 -0
- package/dist/core.js +6 -4
- package/dist/gateway-realtime-transport.d.ts +2 -0
- package/dist/gateway-realtime-transport.js +7 -0
- package/dist/gateway-realtime-transport.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +38 -6
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +193 -3
- package/package.json +10 -1
- package/src/client.ts +211 -17
- package/src/codex-realtime-controller.ts +1427 -0
- package/src/codex-realtime-lifecycle.ts +97 -0
- package/src/codex-realtime-v3-wire.ts +349 -0
- package/src/codex-realtime-v3.ts +575 -0
- package/src/codex-realtime.ts +411 -0
- package/src/gateway-realtime-transport.ts +650 -0
- package/src/index.ts +71 -0
- package/src/types.ts +238 -3
- package/dist/chunk-B7E4FPNZ.js.map +0 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CodexRealtimeVoice,
|
|
3
|
+
CodexRealtimeWebrtcRequest,
|
|
4
|
+
CodexRealtimeWebrtcResponse,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
export type CodexRealtimeNegotiator = (
|
|
8
|
+
request: CodexRealtimeWebrtcRequest,
|
|
9
|
+
options: { signal?: AbortSignal | undefined },
|
|
10
|
+
) => Promise<CodexRealtimeWebrtcResponse>;
|
|
11
|
+
|
|
12
|
+
export type CodexRealtimeMicrophoneErrorCode =
|
|
13
|
+
| "permission_denied"
|
|
14
|
+
| "device_not_found"
|
|
15
|
+
| "device_unavailable"
|
|
16
|
+
| "track_ended"
|
|
17
|
+
| "acquisition_failed";
|
|
18
|
+
|
|
19
|
+
export class CodexRealtimeMicrophoneError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
readonly code: CodexRealtimeMicrophoneErrorCode,
|
|
22
|
+
message: string,
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "CodexRealtimeMicrophoneError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CodexRealtimeAudibleOutputState = "inactive" | "pending" | "audible" | "blocked";
|
|
30
|
+
|
|
31
|
+
export type CodexRealtimeConnectionHealth = "connected" | "disconnected" | "failed" | "closed";
|
|
32
|
+
|
|
33
|
+
export type AcquireCodexRealtimeMicrophoneOptions = {
|
|
34
|
+
signal?: AbortSignal | undefined;
|
|
35
|
+
getUserMedia?: ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type StartCodexRealtimeWebrtcOptions = {
|
|
39
|
+
negotiate: CodexRealtimeNegotiator;
|
|
40
|
+
realtimeId: string;
|
|
41
|
+
operationId: string;
|
|
42
|
+
browserInstanceId: string;
|
|
43
|
+
ownerKey: string;
|
|
44
|
+
expectedVersion: number;
|
|
45
|
+
expectedConnectionEpoch: number;
|
|
46
|
+
rotate: boolean;
|
|
47
|
+
signal?: AbortSignal | undefined;
|
|
48
|
+
instructions?: string | undefined;
|
|
49
|
+
voice?: CodexRealtimeVoice | undefined;
|
|
50
|
+
/** Injectable browser seams make complete SDP negotiation deterministic in tests. */
|
|
51
|
+
createPeerConnection?: (() => RTCPeerConnection) | undefined;
|
|
52
|
+
getUserMedia?: ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | undefined;
|
|
53
|
+
/** Called synchronously immediately after `oai-events` is created. */
|
|
54
|
+
onEventsCreated?: ((events: RTCDataChannel) => void) | undefined;
|
|
55
|
+
/** Reuse a caller-owned microphone across transparent connection rotations. */
|
|
56
|
+
media?: MediaStream | undefined;
|
|
57
|
+
/** Caller-owned audio element for the provider's remote WebRTC track. */
|
|
58
|
+
remoteAudio?: HTMLAudioElement | undefined;
|
|
59
|
+
/** Replacement transports defer taking over the shared audio element until promotion. */
|
|
60
|
+
activateRemoteAudio?: boolean | undefined;
|
|
61
|
+
onAudibleOutputState?: ((state: CodexRealtimeAudibleOutputState) => void) | undefined;
|
|
62
|
+
onMicrophoneEnded?: (() => void) | undefined;
|
|
63
|
+
onConnectionHealth?: ((health: CodexRealtimeConnectionHealth) => void) | undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type CodexRealtimeWebrtcSession = {
|
|
67
|
+
readonly peerConnection: RTCPeerConnection;
|
|
68
|
+
readonly events: RTCDataChannel;
|
|
69
|
+
readonly media: MediaStream;
|
|
70
|
+
readonly operationId: string;
|
|
71
|
+
readonly connectionId: string;
|
|
72
|
+
readonly connectionEpoch: number;
|
|
73
|
+
readonly startupFenceSequence: number;
|
|
74
|
+
readonly modeVersion: number;
|
|
75
|
+
microphoneHealthy(): boolean;
|
|
76
|
+
audibleOutputState(): CodexRealtimeAudibleOutputState;
|
|
77
|
+
setOutputMuted(muted: boolean): void;
|
|
78
|
+
activateRemoteAudio(): void;
|
|
79
|
+
retryAudibleOutput(): Promise<boolean>;
|
|
80
|
+
/** Idempotently close media, data channel, and peer transport. */
|
|
81
|
+
stop(): void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Complete the browser half of native connected-Codex GPT-Live V3 negotiation.
|
|
86
|
+
* Provider credentials never enter this boundary: `negotiate` sends SDP,
|
|
87
|
+
* public session configuration, and the active browser-owner proof only to the
|
|
88
|
+
* OpenGeni API.
|
|
89
|
+
*/
|
|
90
|
+
export async function startCodexRealtimeWebrtc(
|
|
91
|
+
options: StartCodexRealtimeWebrtcOptions,
|
|
92
|
+
): Promise<CodexRealtimeWebrtcSession> {
|
|
93
|
+
throwIfAborted(options.signal);
|
|
94
|
+
const peerConnection = (options.createPeerConnection ?? defaultPeerConnection)();
|
|
95
|
+
const events = peerConnection.createDataChannel("oai-events");
|
|
96
|
+
try {
|
|
97
|
+
options.onEventsCreated?.(events);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
try {
|
|
100
|
+
events.close();
|
|
101
|
+
} finally {
|
|
102
|
+
peerConnection.close();
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
let media: MediaStream | null = null;
|
|
107
|
+
let ownsMedia = false;
|
|
108
|
+
let stopped = false;
|
|
109
|
+
let remoteStream: MediaStream | null = null;
|
|
110
|
+
let remoteAudioActive = options.activateRemoteAudio ?? true;
|
|
111
|
+
let outputMuted = false;
|
|
112
|
+
let audibleOutput: CodexRealtimeAudibleOutputState = "inactive";
|
|
113
|
+
let audioAttempt = 0;
|
|
114
|
+
|
|
115
|
+
const publishAudibleOutput = (next: CodexRealtimeAudibleOutputState): void => {
|
|
116
|
+
if (stopped && next !== "inactive") return;
|
|
117
|
+
audibleOutput = next;
|
|
118
|
+
options.onAudibleOutputState?.(next);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const playRemoteAudio = async (): Promise<boolean> => {
|
|
122
|
+
const remoteAudio = options.remoteAudio;
|
|
123
|
+
const stream = remoteStream;
|
|
124
|
+
if (stopped || !remoteAudioActive || !remoteAudio || !stream) return false;
|
|
125
|
+
remoteAudio.autoplay = true;
|
|
126
|
+
remoteAudio.muted = outputMuted;
|
|
127
|
+
remoteAudio.srcObject = stream;
|
|
128
|
+
const attempt = ++audioAttempt;
|
|
129
|
+
publishAudibleOutput("pending");
|
|
130
|
+
try {
|
|
131
|
+
await remoteAudio.play();
|
|
132
|
+
if (stopped || attempt !== audioAttempt || remoteAudio.srcObject !== stream) return false;
|
|
133
|
+
publishAudibleOutput("audible");
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
if (!stopped && attempt === audioAttempt && remoteAudio.srcObject === stream) {
|
|
137
|
+
publishAudibleOutput("blocked");
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const onRemoteTrack = (event: RTCTrackEvent): void => {
|
|
144
|
+
if (stopped || event.track.kind !== "audio") return;
|
|
145
|
+
const stream = event.streams[0];
|
|
146
|
+
if (!stream) return;
|
|
147
|
+
remoteStream = stream;
|
|
148
|
+
if (remoteAudioActive) void playRemoteAudio();
|
|
149
|
+
};
|
|
150
|
+
if (options.remoteAudio) peerConnection.addEventListener("track", onRemoteTrack);
|
|
151
|
+
const onConnectionState = (): void => {
|
|
152
|
+
if (stopped) return;
|
|
153
|
+
const peerState = peerConnection.connectionState;
|
|
154
|
+
const iceState = peerConnection.iceConnectionState;
|
|
155
|
+
if (peerState === "failed" || iceState === "failed") {
|
|
156
|
+
options.onConnectionHealth?.("failed");
|
|
157
|
+
} else if (peerState === "disconnected" || iceState === "disconnected") {
|
|
158
|
+
options.onConnectionHealth?.("disconnected");
|
|
159
|
+
} else if (peerState === "closed" || iceState === "closed") {
|
|
160
|
+
options.onConnectionHealth?.("closed");
|
|
161
|
+
} else if (peerState === "connected" || iceState === "connected" || iceState === "completed") {
|
|
162
|
+
options.onConnectionHealth?.("connected");
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const onChannelClose = (): void => {
|
|
166
|
+
if (!stopped) options.onConnectionHealth?.("closed");
|
|
167
|
+
};
|
|
168
|
+
const onChannelError = (): void => {
|
|
169
|
+
if (!stopped) options.onConnectionHealth?.("failed");
|
|
170
|
+
};
|
|
171
|
+
peerConnection.addEventListener("connectionstatechange", onConnectionState);
|
|
172
|
+
peerConnection.addEventListener("iceconnectionstatechange", onConnectionState);
|
|
173
|
+
events.addEventListener("close", onChannelClose);
|
|
174
|
+
events.addEventListener("error", onChannelError);
|
|
175
|
+
let audioTracks: MediaStreamTrack[] = [];
|
|
176
|
+
const onMicrophoneEnded = (): void => {
|
|
177
|
+
if (!stopped) options.onMicrophoneEnded?.();
|
|
178
|
+
};
|
|
179
|
+
const stop = (): void => {
|
|
180
|
+
if (stopped) return;
|
|
181
|
+
stopped = true;
|
|
182
|
+
audioAttempt += 1;
|
|
183
|
+
options.signal?.removeEventListener("abort", stop);
|
|
184
|
+
if (options.remoteAudio) peerConnection.removeEventListener("track", onRemoteTrack);
|
|
185
|
+
peerConnection.removeEventListener("connectionstatechange", onConnectionState);
|
|
186
|
+
peerConnection.removeEventListener("iceconnectionstatechange", onConnectionState);
|
|
187
|
+
events.removeEventListener("close", onChannelClose);
|
|
188
|
+
events.removeEventListener("error", onChannelError);
|
|
189
|
+
for (const track of audioTracks) track.removeEventListener?.("ended", onMicrophoneEnded);
|
|
190
|
+
try {
|
|
191
|
+
events.close();
|
|
192
|
+
} finally {
|
|
193
|
+
try {
|
|
194
|
+
if (ownsMedia) media?.getTracks().forEach((track) => track.stop());
|
|
195
|
+
} finally {
|
|
196
|
+
if (options.remoteAudio && options.remoteAudio.srcObject === remoteStream) {
|
|
197
|
+
options.remoteAudio.pause();
|
|
198
|
+
options.remoteAudio.srcObject = null;
|
|
199
|
+
}
|
|
200
|
+
remoteStream = null;
|
|
201
|
+
publishAudibleOutput("inactive");
|
|
202
|
+
peerConnection.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const getUserMedia = options.getUserMedia ?? defaultGetUserMedia;
|
|
210
|
+
if (options.media) {
|
|
211
|
+
media = options.media;
|
|
212
|
+
} else {
|
|
213
|
+
media = await acquireCodexRealtimeMicrophone({
|
|
214
|
+
getUserMedia,
|
|
215
|
+
signal: options.signal,
|
|
216
|
+
});
|
|
217
|
+
ownsMedia = true;
|
|
218
|
+
}
|
|
219
|
+
throwIfAborted(options.signal);
|
|
220
|
+
audioTracks = media.getAudioTracks();
|
|
221
|
+
if (audioTracks.length === 0) {
|
|
222
|
+
throw new CodexRealtimeMicrophoneError(
|
|
223
|
+
"device_not_found",
|
|
224
|
+
"No microphone audio track is available",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (!microphoneTracksHealthy(audioTracks)) {
|
|
228
|
+
throw new CodexRealtimeMicrophoneError(
|
|
229
|
+
"track_ended",
|
|
230
|
+
"The microphone audio track is no longer available",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
for (const track of audioTracks) {
|
|
234
|
+
track.addEventListener?.("ended", onMicrophoneEnded);
|
|
235
|
+
peerConnection.addTrack(track, media);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const offer = await peerConnection.createOffer();
|
|
239
|
+
if (offer.type !== "offer" || !offer.sdp) {
|
|
240
|
+
throw new Error("Browser did not create a WebRTC SDP offer");
|
|
241
|
+
}
|
|
242
|
+
await peerConnection.setLocalDescription(offer);
|
|
243
|
+
throwIfAborted(options.signal);
|
|
244
|
+
const localSdp = peerConnection.localDescription?.sdp ?? offer.sdp;
|
|
245
|
+
const answer = await options.negotiate(
|
|
246
|
+
{
|
|
247
|
+
realtimeId: options.realtimeId,
|
|
248
|
+
operationId: options.operationId,
|
|
249
|
+
browserInstanceId: options.browserInstanceId,
|
|
250
|
+
ownerKey: options.ownerKey,
|
|
251
|
+
expectedVersion: options.expectedVersion,
|
|
252
|
+
expectedConnectionEpoch: options.expectedConnectionEpoch,
|
|
253
|
+
rotate: options.rotate,
|
|
254
|
+
browserActivation: "required",
|
|
255
|
+
sdp: localSdp,
|
|
256
|
+
version: "v3",
|
|
257
|
+
...(options.instructions === undefined ? {} : { instructions: options.instructions }),
|
|
258
|
+
...(options.voice === undefined ? {} : { voice: options.voice }),
|
|
259
|
+
},
|
|
260
|
+
{ signal: options.signal },
|
|
261
|
+
);
|
|
262
|
+
throwIfAborted(options.signal);
|
|
263
|
+
if (answer.version !== "v3" || answer.model !== "gpt-live-1-boulder-alpha" || !answer.sdp) {
|
|
264
|
+
throw new Error("OpenGeni returned an incompatible Codex realtime answer");
|
|
265
|
+
}
|
|
266
|
+
await peerConnection.setRemoteDescription({
|
|
267
|
+
type: "answer",
|
|
268
|
+
sdp: answer.sdp,
|
|
269
|
+
});
|
|
270
|
+
throwIfAborted(options.signal);
|
|
271
|
+
return {
|
|
272
|
+
peerConnection,
|
|
273
|
+
events,
|
|
274
|
+
media,
|
|
275
|
+
operationId: options.operationId,
|
|
276
|
+
connectionId: answer.connectionId,
|
|
277
|
+
connectionEpoch: answer.connectionEpoch,
|
|
278
|
+
startupFenceSequence: answer.startupFenceSequence,
|
|
279
|
+
modeVersion: answer.modeVersion,
|
|
280
|
+
microphoneHealthy: () => microphoneTracksHealthy(audioTracks),
|
|
281
|
+
audibleOutputState: () => audibleOutput,
|
|
282
|
+
setOutputMuted: (muted) => {
|
|
283
|
+
outputMuted = muted;
|
|
284
|
+
if (options.remoteAudio) options.remoteAudio.muted = muted;
|
|
285
|
+
},
|
|
286
|
+
activateRemoteAudio: () => {
|
|
287
|
+
if (stopped || remoteAudioActive) return;
|
|
288
|
+
remoteAudioActive = true;
|
|
289
|
+
if (remoteStream) void playRemoteAudio();
|
|
290
|
+
},
|
|
291
|
+
retryAudibleOutput: playRemoteAudio,
|
|
292
|
+
stop,
|
|
293
|
+
};
|
|
294
|
+
} catch (error) {
|
|
295
|
+
stop();
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function acquireCodexRealtimeMicrophone(
|
|
301
|
+
options: AcquireCodexRealtimeMicrophoneOptions = {},
|
|
302
|
+
): Promise<MediaStream> {
|
|
303
|
+
const getUserMedia = options.getUserMedia ?? defaultGetUserMedia;
|
|
304
|
+
let media: MediaStream;
|
|
305
|
+
try {
|
|
306
|
+
media = await abortableMedia(getUserMedia({ audio: true }), options.signal);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
throw microphoneAcquisitionError(error, options.signal);
|
|
309
|
+
}
|
|
310
|
+
const tracks = media.getAudioTracks();
|
|
311
|
+
if (tracks.length === 0) {
|
|
312
|
+
media.getTracks().forEach((track) => track.stop());
|
|
313
|
+
throw new CodexRealtimeMicrophoneError(
|
|
314
|
+
"device_not_found",
|
|
315
|
+
"No microphone audio track is available",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (!microphoneTracksHealthy(tracks)) {
|
|
319
|
+
media.getTracks().forEach((track) => track.stop());
|
|
320
|
+
throw new CodexRealtimeMicrophoneError(
|
|
321
|
+
"track_ended",
|
|
322
|
+
"The microphone audio track is no longer available",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return media;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function codexRealtimeMicrophoneHealthy(media: MediaStream | null): boolean {
|
|
329
|
+
return media !== null && microphoneTracksHealthy(media.getAudioTracks());
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function microphoneTracksHealthy(tracks: readonly MediaStreamTrack[]): boolean {
|
|
333
|
+
return tracks.length > 0 && tracks.every((track) => track.readyState !== "ended");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function microphoneAcquisitionError(error: unknown, signal: AbortSignal | undefined): Error {
|
|
337
|
+
if (signal?.aborted) {
|
|
338
|
+
return signal.reason instanceof Error
|
|
339
|
+
? signal.reason
|
|
340
|
+
: new DOMException("Aborted", "AbortError");
|
|
341
|
+
}
|
|
342
|
+
const name = error instanceof Error ? error.name : "";
|
|
343
|
+
if (name === "NotAllowedError" || name === "SecurityError") {
|
|
344
|
+
return new CodexRealtimeMicrophoneError(
|
|
345
|
+
"permission_denied",
|
|
346
|
+
"Microphone permission was denied",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (name === "NotFoundError" || name === "DevicesNotFoundError") {
|
|
350
|
+
return new CodexRealtimeMicrophoneError("device_not_found", "No microphone device was found");
|
|
351
|
+
}
|
|
352
|
+
if (name === "NotReadableError" || name === "TrackStartError" || name === "AbortError") {
|
|
353
|
+
return new CodexRealtimeMicrophoneError(
|
|
354
|
+
"device_unavailable",
|
|
355
|
+
"The microphone device is unavailable",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return new CodexRealtimeMicrophoneError(
|
|
359
|
+
"acquisition_failed",
|
|
360
|
+
"Microphone capture could not be started",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function defaultPeerConnection(): RTCPeerConnection {
|
|
365
|
+
if (typeof RTCPeerConnection === "undefined") {
|
|
366
|
+
throw new Error("WebRTC is not available in this environment");
|
|
367
|
+
}
|
|
368
|
+
return new RTCPeerConnection();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function defaultGetUserMedia(constraints: MediaStreamConstraints): Promise<MediaStream> {
|
|
372
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
|
373
|
+
throw new Error("Microphone capture is not available in this environment");
|
|
374
|
+
}
|
|
375
|
+
return await navigator.mediaDevices.getUserMedia(constraints);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
379
|
+
if (signal?.aborted) {
|
|
380
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function abortableMedia(
|
|
385
|
+
pending: Promise<MediaStream>,
|
|
386
|
+
signal: AbortSignal | undefined,
|
|
387
|
+
): Promise<MediaStream> {
|
|
388
|
+
if (!signal) return await pending;
|
|
389
|
+
throwIfAborted(signal);
|
|
390
|
+
let rejectAbort: ((reason?: unknown) => void) | undefined;
|
|
391
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
392
|
+
rejectAbort = reject;
|
|
393
|
+
});
|
|
394
|
+
const onAbort = (): void => {
|
|
395
|
+
rejectAbort?.(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
396
|
+
};
|
|
397
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
398
|
+
// If permission resolves after cancellation won the race, stop the newly
|
|
399
|
+
// acquired tracks rather than leaking a detached microphone capture.
|
|
400
|
+
void pending.then(
|
|
401
|
+
(lateMedia) => {
|
|
402
|
+
if (signal.aborted) lateMedia.getTracks().forEach((track) => track.stop());
|
|
403
|
+
},
|
|
404
|
+
() => undefined,
|
|
405
|
+
);
|
|
406
|
+
try {
|
|
407
|
+
return await Promise.race([pending, aborted]);
|
|
408
|
+
} finally {
|
|
409
|
+
signal.removeEventListener("abort", onAbort);
|
|
410
|
+
}
|
|
411
|
+
}
|