@byokit/realtime 0.2.0 → 0.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.
- package/CHANGELOG.md +12 -0
- package/README.md +58 -6
- package/dist/auth.d.ts +15 -1
- package/dist/auth.js +35 -6
- package/dist/client.d.ts +2 -0
- package/dist/client.js +18 -1
- package/dist/node.d.ts +1 -1
- package/dist/rn-webrtc.d.ts +4 -1
- package/dist/types.d.ts +2 -0
- package/dist/webrtc.d.ts +9 -2
- package/dist/webrtc.js +102 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- Add opt-in lazy WebRTC microphone capture: preconnect without a microphone lease, attach and release capture without renegotiation, and release pending capture on close.
|
|
10
|
+
|
|
11
|
+
## 0.2.1 (2026-09-30)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
- FIX: Report saved-sign-in failure reasons and plain remedies in advisory auth checks while preserving existing status results.
|
|
16
|
+
|
|
5
17
|
## 0.2.0 (2026-09-30)
|
|
6
18
|
|
|
7
19
|
|
package/README.md
CHANGED
|
@@ -85,28 +85,40 @@ The app's `peek` callback must also never refresh, prompt or start login.
|
|
|
85
85
|
|
|
86
86
|
```ts
|
|
87
87
|
import { realtimeEngine, toolBridge } from '@byokit/realtime/node';
|
|
88
|
-
import type { RealtimeAuth, RealtimeHostFrame, RealtimeTool } from '@byokit/realtime';
|
|
88
|
+
import type { RealtimeAuth, RealtimeHostFrame, RealtimeTool, RealtimeAuthPeekResult, RealtimeAuthResult } from '@byokit/realtime';
|
|
89
89
|
|
|
90
90
|
declare const auth: Extract<RealtimeAuth, { kind: 'plan' }>;
|
|
91
91
|
declare const tools: RealtimeTool[];
|
|
92
92
|
declare const bridge: ReturnType<typeof toolBridge>;
|
|
93
93
|
declare const emit: (frame: RealtimeHostFrame) => void;
|
|
94
|
-
declare const peekSavedSignIn: (signal: AbortSignal) => Promise<
|
|
94
|
+
declare const peekSavedSignIn: (signal: AbortSignal) => Promise<RealtimeAuthPeekResult>;
|
|
95
95
|
declare const updateSettingsBadge: (status: 'ready' | 'signed-out' | 'unknown') => void;
|
|
96
|
+
declare const showSignInMessage: (result: RealtimeAuthResult) => void;
|
|
96
97
|
const engine = realtimeEngine({
|
|
97
98
|
engine: 'chatgpt', auth,
|
|
98
99
|
tools, bridge, emit,
|
|
99
|
-
authCheck: { peek: peekSavedSignIn, onStatus: status => updateSettingsBadge(status) },
|
|
100
|
+
authCheck: { peek: peekSavedSignIn, onStatus: status => updateSettingsBadge(status), onResult: showSignInMessage },
|
|
100
101
|
});
|
|
101
102
|
```
|
|
102
103
|
|
|
103
104
|
`ready` means the app reports a saved sign-in, not provider entitlement.
|
|
104
|
-
`signed-out` means the lookup returned false
|
|
105
|
-
`unknown`; the default timeout is one second, capped at ten seconds. Close
|
|
105
|
+
`signed-out` means the lookup returned false or found an unusable saved sign-in.
|
|
106
|
+
Unrecognized errors or a timeout produce `unknown`; the default timeout is one second, capped at ten seconds. Close
|
|
106
107
|
cancels pending checks and suppresses late notifications. Normal session
|
|
107
108
|
authentication still needs the credential from `auth.access`.
|
|
108
109
|
For a standalone settings lookup, `realtimeAuthCheck({ peek })` is also exported
|
|
109
|
-
from the portable entry and returns `{ result, close }`.
|
|
110
|
+
from the portable entry and returns `{ result, details, close }`. `result` still
|
|
111
|
+
resolves to the status string; `details` resolves to `{ state, reason?, message? }`,
|
|
112
|
+
and engine `onResult` receives the same diagnostic object. A boolean or
|
|
113
|
+
`{ state }` peek keeps the previous behavior without adding a reason.
|
|
114
|
+
|
|
115
|
+
To report an expired saved sign-in, the read-only peek can return
|
|
116
|
+
`{ state: 'signed-out', reason: 'login-expired' }`. Built-in reasons are
|
|
117
|
+
`credential-permissions`, `login-expired`, `missing` and `unknown`; apps can
|
|
118
|
+
supply additional reason strings. Messages use fixed plain wording and never
|
|
119
|
+
copy exception text or credential paths. Missing-file errors map to `missing`,
|
|
120
|
+
permission errors to `credential-permissions`, and expired-login errors to
|
|
121
|
+
`login-expired`; unrecognized errors and timeouts report `unknown`. Do not await this check
|
|
110
122
|
before opening a voice session or show a login prompt from its result.
|
|
111
123
|
|
|
112
124
|
## Lifecycle announcements and semantic app tools
|
|
@@ -157,6 +169,46 @@ The main entry is portable for web and React Native and loads no native code. `.
|
|
|
157
169
|
|
|
158
170
|
`AudioPorts` requires microphone acquisition/release, PCM capture, playback admission/drain and audio routing. The app owns permissions and its microphone service. Acquire must resolve only after that service is ready. The client awaits it before capture. Each client owns its ports; the app must arbitrate shared microphones.
|
|
159
171
|
|
|
172
|
+
## Preconnect without microphone capture
|
|
173
|
+
|
|
174
|
+
Set `capture: 'lazy'` for a browser or React Native WebRTC session. Startup
|
|
175
|
+
negotiates one `sendrecv` audio transceiver with no sender track: it calls
|
|
176
|
+
neither `audio.microphone.acquire()` nor `getUserMedia`, so the app's microphone
|
|
177
|
+
foreground service stays stopped. The playback route remains available.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { realtimeClient, type AudioPorts, type RealtimeStream } from '@byokit/realtime';
|
|
181
|
+
import { webRtcPeer } from '@byokit/realtime/webrtc';
|
|
182
|
+
declare const audio: AudioPorts;
|
|
183
|
+
declare const openVoiceStream: () => Promise<RealtimeStream>;
|
|
184
|
+
const client = realtimeClient({
|
|
185
|
+
open: openVoiceStream, audio, webrtc: webRtcPeer, capture: 'lazy',
|
|
186
|
+
onStatus() {}, onTurn() {},
|
|
187
|
+
});
|
|
188
|
+
// Once WebRTC setup has started, await this before accepting speech:
|
|
189
|
+
async function beginTalking() { await client.attachMic(); }
|
|
190
|
+
// When idle, release hardware capture while keeping the call connected:
|
|
191
|
+
async function becomeIdle() { await client.releaseMic(); }
|
|
192
|
+
// End the call and release all media:
|
|
193
|
+
function endCall() { client.stop(); }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`attachMic()` awaits the app's microphone permission/service lease, captures
|
|
197
|
+
an audio track and uses `sender.replaceTrack(track)`. `releaseMic()` awaits
|
|
198
|
+
`replaceTrack(null)`, stops capture tracks and releases the lease so the app
|
|
199
|
+
can stop its microphone service and clear the OS indicator. Neither operation
|
|
200
|
+
renegotiates. Repeated calls are safe; release or stop during pending capture
|
|
201
|
+
cleans up when that capture resolves. Reconnecting starts another lazy peer;
|
|
202
|
+
the app explicitly attaches again when it needs input.
|
|
203
|
+
|
|
204
|
+
The built-in `webRtcPeer` handle also exposes both methods for direct use.
|
|
205
|
+
Custom peer factories may implement these optional methods; client attach
|
|
206
|
+
rejects if the current peer does not support them or setup has not started.
|
|
207
|
+
The default is eager capture, and PCM capture is unchanged. `setMuted()` still
|
|
208
|
+
only toggles track delivery; use `releaseMic()` to relinquish capture. Mute
|
|
209
|
+
state persists when a new track is attached. Each client owns its `AudioPorts`;
|
|
210
|
+
`microphone.release()` must stop the app-owned foreground service.
|
|
211
|
+
|
|
160
212
|
Close the client and engine when the call ends. `client.interrupt()` clears playback and aborts unfinished tools; old response output is fenced. Transient provider and transport failures retry twice, after 500 and 1,000 ms, with a fresh budget after 30 healthy seconds. Sent audio, speech and tool calls are never replayed. The app can mark transport closure terminal with `retryableClose`. Explicit ChatGPT interruption opens a fresh call; explicit Gemini interruption rotates its provider session. Native speech interruption remains provider-driven. No STT/LLM/TTS chain is exported. See [the lift contract](../../docs/realtime-kit.md) for bounds, events and usage.
|
|
161
213
|
|
|
162
214
|
## Reconnect and host policy
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
export type RealtimeAuthStatus = 'ready' | 'signed-out' | 'unknown';
|
|
2
|
+
/** Additional app-defined reasons are allowed without changing the state contract. */
|
|
3
|
+
export type RealtimeAuthReason = 'credential-permissions' | 'login-expired' | 'missing' | 'unknown' | (string & {});
|
|
4
|
+
export type RealtimeAuthResult = {
|
|
5
|
+
state: RealtimeAuthStatus;
|
|
6
|
+
reason?: RealtimeAuthReason;
|
|
7
|
+
message?: string;
|
|
8
|
+
};
|
|
9
|
+
export type RealtimeAuthPeekResult = boolean | {
|
|
10
|
+
state: RealtimeAuthStatus;
|
|
11
|
+
reason?: RealtimeAuthReason;
|
|
12
|
+
};
|
|
2
13
|
export type RealtimeAuthCheckOptions = {
|
|
3
14
|
/** Read-only saved-sign-in check. Must never start login, refresh access or prompt. */
|
|
4
|
-
peek(signal: AbortSignal): Promise<
|
|
15
|
+
peek(signal: AbortSignal): Promise<RealtimeAuthPeekResult>;
|
|
16
|
+
/** Diagnostic observer, also available through the standalone details promise. */
|
|
17
|
+
onResult?(result: RealtimeAuthResult): void;
|
|
5
18
|
onStatus?(status: RealtimeAuthStatus): void;
|
|
6
19
|
/** Defaults to 1 second; capped at 10 seconds. */
|
|
7
20
|
timeoutMs?: number;
|
|
@@ -9,5 +22,6 @@ export type RealtimeAuthCheckOptions = {
|
|
|
9
22
|
/** Advisory only: session startup must proceed independently of this check. */
|
|
10
23
|
export declare function realtimeAuthCheck(options: RealtimeAuthCheckOptions): {
|
|
11
24
|
result: Promise<RealtimeAuthStatus>;
|
|
25
|
+
details: Promise<RealtimeAuthResult>;
|
|
12
26
|
close(): void;
|
|
13
27
|
};
|
package/dist/auth.js
CHANGED
|
@@ -1,29 +1,58 @@
|
|
|
1
|
+
function describe(state, reason) {
|
|
2
|
+
if (!reason)
|
|
3
|
+
return { state };
|
|
4
|
+
const message = reason === 'credential-permissions' ? 'Your saved sign-in must be readable only by you.'
|
|
5
|
+
: reason === 'login-expired' ? 'Your saved sign-in has expired. Sign in again in settings.'
|
|
6
|
+
: reason === 'missing' ? 'No saved sign-in was found. Sign in in settings.'
|
|
7
|
+
: 'Your saved sign-in could not be checked.';
|
|
8
|
+
return { state, reason, message };
|
|
9
|
+
}
|
|
10
|
+
function failure(error) {
|
|
11
|
+
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
12
|
+
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : undefined;
|
|
13
|
+
if (reason === 'credential-permissions' || reason === 'login-expired' || reason === 'missing' || reason === 'unknown')
|
|
14
|
+
return describe(reason === 'unknown' ? 'unknown' : 'signed-out', reason);
|
|
15
|
+
if (code === 'ENOENT')
|
|
16
|
+
return describe('signed-out', 'missing');
|
|
17
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'credential-permissions' || (error instanceof Error && /owner.only|readable only by|permissions.*(?:credential|sign-in)|(?:credential|sign-in).*permissions/i.test(error.message)))
|
|
18
|
+
return describe('signed-out', 'credential-permissions');
|
|
19
|
+
if (code === 'login-expired' || code === 'TOKEN_EXPIRED' || (error instanceof Error && /\b(?:login|sign-in|token|credential)\b.*\bexpired\b/i.test(error.message)))
|
|
20
|
+
return describe('signed-out', 'login-expired');
|
|
21
|
+
return describe('unknown', 'unknown');
|
|
22
|
+
}
|
|
1
23
|
/** Advisory only: session startup must proceed independently of this check. */
|
|
2
24
|
export function realtimeAuthCheck(options) {
|
|
3
25
|
const controller = new AbortController();
|
|
4
26
|
let settled = false;
|
|
5
27
|
let resolve;
|
|
6
28
|
const result = new Promise(done => { resolve = done; });
|
|
7
|
-
|
|
29
|
+
let resolveDetails;
|
|
30
|
+
const details = new Promise(done => { resolveDetails = done; });
|
|
31
|
+
const finish = (value, notify = true) => {
|
|
8
32
|
if (settled)
|
|
9
33
|
return;
|
|
10
34
|
settled = true;
|
|
11
35
|
clearTimeout(timer);
|
|
12
36
|
controller.abort();
|
|
13
|
-
resolve(
|
|
37
|
+
resolve(value.state);
|
|
38
|
+
resolveDetails(value);
|
|
14
39
|
// A settings observer cannot fail a call or create an unhandled rejection.
|
|
15
40
|
if (notify) {
|
|
16
41
|
try {
|
|
17
|
-
options.onStatus?.(
|
|
42
|
+
options.onStatus?.(value.state);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
try {
|
|
46
|
+
options.onResult?.(value);
|
|
18
47
|
}
|
|
19
48
|
catch { }
|
|
20
49
|
}
|
|
21
50
|
};
|
|
22
51
|
const timeout = options.timeoutMs ?? 1000;
|
|
23
|
-
const timer = setTimeout(() => finish('unknown'), Number.isFinite(timeout) ? Math.min(10000, Math.max(1, timeout)) : 1000);
|
|
52
|
+
const timer = setTimeout(() => finish(describe('unknown', 'unknown')), Number.isFinite(timeout) ? Math.min(10000, Math.max(1, timeout)) : 1000);
|
|
24
53
|
void Promise.resolve().then(() => {
|
|
25
54
|
if (!settled)
|
|
26
55
|
return options.peek(controller.signal);
|
|
27
|
-
}).then(value => finish(value === true ? 'ready' : value === false ? 'signed-out' : 'unknown'),
|
|
28
|
-
return { result, close() { finish('unknown', false); } };
|
|
56
|
+
}).then(value => finish(typeof value === 'object' && value !== null ? describe(value.state, value.reason) : describe(value === true ? 'ready' : value === false ? 'signed-out' : 'unknown')), error => finish(failure(error)));
|
|
57
|
+
return { result, details, close() { finish(describe('unknown', 'unknown'), false); } };
|
|
29
58
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { RealtimeClientOptions } from './types.ts';
|
|
|
2
2
|
/** Provider-blind client with bounded reconnects and exclusive media ownership. */
|
|
3
3
|
export declare function realtimeClient(options: RealtimeClientOptions): {
|
|
4
4
|
stop: (reason?: string) => void;
|
|
5
|
+
attachMic(): Promise<void>;
|
|
6
|
+
releaseMic(): Promise<void>;
|
|
5
7
|
interrupt(): void;
|
|
6
8
|
setMuted(value: boolean): void;
|
|
7
9
|
speak(text: string): void;
|
package/dist/client.js
CHANGED
|
@@ -18,6 +18,7 @@ export function realtimeClient(options) {
|
|
|
18
18
|
audio.player.unbind(current.stream);
|
|
19
19
|
await current.media?.catch(() => { });
|
|
20
20
|
current.peer?.stop();
|
|
21
|
+
await current.peer?.releaseMic?.();
|
|
21
22
|
await current.routeRelease;
|
|
22
23
|
try {
|
|
23
24
|
await current.capture?.release();
|
|
@@ -205,7 +206,7 @@ export function realtimeClient(options) {
|
|
|
205
206
|
break;
|
|
206
207
|
if (!options.webrtc)
|
|
207
208
|
throw new Error('Voice media is unsupported.');
|
|
208
|
-
current.media = options.webrtc({ label: frame.dataChannelLabel, audio: { ...audio, unroute() { return current.routeRelease = audio.unroute(); } }, signal: current.signal.signal,
|
|
209
|
+
current.media = options.webrtc({ label: frame.dataChannelLabel, capture: options.capture, audio: { ...audio, unroute() { return current.routeRelease = audio.unroute(); } }, signal: current.signal.signal,
|
|
209
210
|
onOffer(sdp) { if (active() && !current.stream?.send({ type: 'realtime.webrtc.offer', sdp }))
|
|
210
211
|
reconnect(current, 'Voice offer could not be sent.'); },
|
|
211
212
|
onData(data) { if (active() && !current.stream?.send({ type: 'realtime.webrtc.data', data }))
|
|
@@ -303,6 +304,22 @@ export function realtimeClient(options) {
|
|
|
303
304
|
connect();
|
|
304
305
|
return {
|
|
305
306
|
stop,
|
|
307
|
+
async attachMic() {
|
|
308
|
+
const current = attempt;
|
|
309
|
+
if (stopped || !current?.media)
|
|
310
|
+
throw new Error('Voice media is unavailable.');
|
|
311
|
+
await current.media;
|
|
312
|
+
if (stopped || attempt !== current || !current.peer?.attachMic)
|
|
313
|
+
throw new Error('Voice media is unavailable.');
|
|
314
|
+
await current.peer.attachMic();
|
|
315
|
+
},
|
|
316
|
+
async releaseMic() {
|
|
317
|
+
const current = attempt;
|
|
318
|
+
if (!current?.media)
|
|
319
|
+
return;
|
|
320
|
+
await current.media;
|
|
321
|
+
await current.peer?.releaseMic?.();
|
|
322
|
+
},
|
|
306
323
|
interrupt() { if (stopped)
|
|
307
324
|
return; audio.player.clear(); speech.length = 0; if (!attempt?.stream?.send({ type: 'realtime.control', action: 'interrupt' })) {
|
|
308
325
|
if (attempt)
|
package/dist/node.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { toolBridge } from './tools.ts';
|
|
|
5
5
|
import type { RealtimeAuthCheckOptions } from './auth.ts';
|
|
6
6
|
export { toolBridge, appBridge, delegationHandler } from './tools.ts';
|
|
7
7
|
export { realtimeAuthCheck } from './auth.ts';
|
|
8
|
-
export type { RealtimeAuthCheckOptions, RealtimeAuthStatus } from './auth.ts';
|
|
8
|
+
export type { RealtimeAuthCheckOptions, RealtimeAuthStatus, RealtimeAuthReason, RealtimeAuthResult, RealtimeAuthPeekResult } from './auth.ts';
|
|
9
9
|
export type { ToolHandler, ToolBridgeOptions } from './tools.ts';
|
|
10
10
|
export type { RealtimeSignalingIdentity } from './adapter.ts';
|
|
11
11
|
export type RealtimeEngineOptions = RealtimeConfig & {
|
package/dist/rn-webrtc.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type WebRtcOptions } from './webrtc.ts';
|
|
2
2
|
export type { WebRtcOptions, WebRtcHandle } from './webrtc.ts';
|
|
3
3
|
/** Native media stays unloaded until the app actually starts a voice session. */
|
|
4
|
-
export declare function webRtcPeer(options: WebRtcOptions): Promise<import("./webrtc.ts").WebRtcHandle
|
|
4
|
+
export declare function webRtcPeer(options: WebRtcOptions): Promise<import("./webrtc.ts").WebRtcHandle & {
|
|
5
|
+
attachMic(): Promise<void>;
|
|
6
|
+
releaseMic(): Promise<void>;
|
|
7
|
+
}>;
|
|
5
8
|
export { webRtcPeer as reactNativeWebRtcPeer };
|
package/dist/types.d.ts
CHANGED
|
@@ -89,6 +89,8 @@ export type RealtimeClientOptions = {
|
|
|
89
89
|
/** Retain ready PCM capture and the playback tail across carrier reconnects.
|
|
90
90
|
* WebRTC still needs a fresh peer. Pending microphone acquisition is never retained. */
|
|
91
91
|
preserveMediaOnReconnect?: boolean;
|
|
92
|
+
/** WebRTC only: negotiate without capture, then use attachMic/releaseMic. PCM is unchanged. */
|
|
93
|
+
capture?: 'eager' | 'lazy';
|
|
92
94
|
webrtc?: (options: import('./webrtc.ts').WebRtcOptions) => Promise<import('./webrtc.ts').WebRtcHandle>;
|
|
93
95
|
onStatus(status: 'connecting' | 'connected' | 'thinking' | 'speaking' | 'disconnected', reason?: string): void;
|
|
94
96
|
onTurn(role: 'user' | 'agent', text: string): void;
|
package/dist/webrtc.d.ts
CHANGED
|
@@ -3,11 +3,15 @@ export type WebRtcHandle = {
|
|
|
3
3
|
acceptAnswer(sdp: string): Promise<void>;
|
|
4
4
|
sendData(data: string): boolean;
|
|
5
5
|
setMuted(muted: boolean): void;
|
|
6
|
+
attachMic?(): Promise<void>;
|
|
7
|
+
releaseMic?(): Promise<void>;
|
|
6
8
|
stop(): void;
|
|
7
9
|
};
|
|
8
10
|
export type WebRtcOptions = {
|
|
9
11
|
label: string;
|
|
10
12
|
audio: Pick<AudioPorts, 'microphone' | 'route' | 'unroute'>;
|
|
13
|
+
/** Lazy WebRTC starts with no microphone lease or capture; attach explicitly when speaking. */
|
|
14
|
+
capture?: 'eager' | 'lazy';
|
|
11
15
|
signal?: AbortSignal;
|
|
12
16
|
platform?: {
|
|
13
17
|
createPeer(): RTCPeerConnection;
|
|
@@ -20,5 +24,8 @@ export type WebRtcOptions = {
|
|
|
20
24
|
onInterruption(active: boolean): void;
|
|
21
25
|
onError(error: Error): void;
|
|
22
26
|
};
|
|
23
|
-
/** One peer per handle. Microphone permission/service readiness precedes
|
|
24
|
-
export declare function webRtcPeer(options: WebRtcOptions): Promise<WebRtcHandle
|
|
27
|
+
/** One peer per handle. Microphone permission/service readiness precedes each capture. */
|
|
28
|
+
export declare function webRtcPeer(options: WebRtcOptions): Promise<WebRtcHandle & {
|
|
29
|
+
attachMic(): Promise<void>;
|
|
30
|
+
releaseMic(): Promise<void>;
|
|
31
|
+
}>;
|
package/dist/webrtc.js
CHANGED
|
@@ -4,27 +4,40 @@ function sdp(value) {
|
|
|
4
4
|
throw new Error('Invalid voice connection answer.');
|
|
5
5
|
return value;
|
|
6
6
|
}
|
|
7
|
-
/** One peer per handle. Microphone permission/service readiness precedes
|
|
7
|
+
/** One peer per handle. Microphone permission/service readiness precedes each capture. */
|
|
8
8
|
export async function webRtcPeer(options) {
|
|
9
9
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(options.label))
|
|
10
10
|
throw new Error('Invalid voice channel.');
|
|
11
11
|
const platform = options.platform ?? { createPeer: () => new RTCPeerConnection({ bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require' }), getUserMedia: () => navigator.mediaDevices.getUserMedia({ audio: true, video: false }) };
|
|
12
12
|
let acquired = false, stopped = false, stream, peer, channel;
|
|
13
|
+
let sender, muted = false, micEpoch = 0;
|
|
14
|
+
let micOperation = Promise.resolve();
|
|
15
|
+
const serializeMic = (run) => {
|
|
16
|
+
const operation = micOperation.then(run);
|
|
17
|
+
micOperation = operation.catch(() => { });
|
|
18
|
+
return operation;
|
|
19
|
+
};
|
|
20
|
+
const clearMic = () => {
|
|
21
|
+
const previous = stream;
|
|
22
|
+
stream = undefined;
|
|
23
|
+
previous?.getTracks().forEach(track => { track.onmute = null; track.onunmute = null; track.onended = null; track.stop(); });
|
|
24
|
+
if (acquired) {
|
|
25
|
+
acquired = false;
|
|
26
|
+
options.audio.microphone.release();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
13
29
|
const queue = [];
|
|
14
30
|
let queuedBytes = 0;
|
|
15
31
|
const stop = () => {
|
|
16
32
|
if (stopped)
|
|
17
33
|
return;
|
|
18
34
|
stopped = true;
|
|
19
|
-
|
|
35
|
+
micEpoch++;
|
|
36
|
+
clearMic();
|
|
20
37
|
channel?.close();
|
|
21
38
|
peer?.close();
|
|
22
39
|
queue.length = 0;
|
|
23
40
|
void options.audio.unroute().catch(() => { });
|
|
24
|
-
if (acquired) {
|
|
25
|
-
options.audio.microphone.release();
|
|
26
|
-
acquired = false;
|
|
27
|
-
}
|
|
28
41
|
options.signal?.removeEventListener('abort', stop);
|
|
29
42
|
options.onRemoteAudio(false);
|
|
30
43
|
options.onConnectionState('disconnected');
|
|
@@ -34,26 +47,87 @@ export async function webRtcPeer(options) {
|
|
|
34
47
|
options.onError(error);
|
|
35
48
|
stop();
|
|
36
49
|
} };
|
|
50
|
+
const captureMic = async (generation) => {
|
|
51
|
+
await options.audio.microphone.acquire();
|
|
52
|
+
acquired = true;
|
|
53
|
+
if (stopped || generation !== micEpoch) {
|
|
54
|
+
clearMic();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
stream = await platform.getUserMedia();
|
|
58
|
+
if (stopped || generation !== micEpoch) {
|
|
59
|
+
clearMic();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const input = stream.getAudioTracks()[0];
|
|
63
|
+
if (!input)
|
|
64
|
+
throw new Error('Microphone is unavailable.');
|
|
65
|
+
input.enabled = !muted;
|
|
66
|
+
input.onmute = () => options.onInterruption(true);
|
|
67
|
+
input.onunmute = () => options.onInterruption(false);
|
|
68
|
+
input.onended = () => fail(new Error('Microphone ended.'));
|
|
69
|
+
return stream;
|
|
70
|
+
};
|
|
71
|
+
const attachMic = () => {
|
|
72
|
+
const generation = micEpoch;
|
|
73
|
+
return serializeMic(async () => {
|
|
74
|
+
if (stopped)
|
|
75
|
+
throw new Error('Voice is closed.');
|
|
76
|
+
if (generation !== micEpoch || stream)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
const captured = await captureMic(generation);
|
|
80
|
+
if (captured) {
|
|
81
|
+
await sender.replaceTrack(captured.getAudioTracks()[0]);
|
|
82
|
+
if (stopped || generation !== micEpoch) {
|
|
83
|
+
if (!stopped)
|
|
84
|
+
await sender.replaceTrack(null);
|
|
85
|
+
clearMic();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
clearMic();
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const releaseMic = () => {
|
|
96
|
+
micEpoch++;
|
|
97
|
+
stream?.getAudioTracks().forEach(track => { track.enabled = false; });
|
|
98
|
+
return serializeMic(async () => {
|
|
99
|
+
try {
|
|
100
|
+
if (!stopped && stream)
|
|
101
|
+
await sender.replaceTrack(null);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearMic();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
37
108
|
try {
|
|
38
109
|
options.onConnectionState('connecting');
|
|
39
110
|
if (options.signal?.aborted)
|
|
40
111
|
throw new Error('Voice is closed.');
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
112
|
+
if (options.capture !== 'lazy') {
|
|
113
|
+
await options.audio.microphone.acquire();
|
|
114
|
+
acquired = true;
|
|
115
|
+
if (stopped) {
|
|
116
|
+
clearMic();
|
|
117
|
+
throw new Error('Voice is closed.');
|
|
118
|
+
}
|
|
47
119
|
}
|
|
48
120
|
await options.audio.route();
|
|
49
121
|
if (stopped) {
|
|
50
122
|
await options.audio.unroute();
|
|
51
123
|
throw new Error('Voice is closed.');
|
|
52
124
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
125
|
+
if (options.capture !== 'lazy') {
|
|
126
|
+
stream = await platform.getUserMedia();
|
|
127
|
+
if (stopped) {
|
|
128
|
+
clearMic();
|
|
129
|
+
throw new Error('Voice is closed.');
|
|
130
|
+
}
|
|
57
131
|
}
|
|
58
132
|
peer = platform.createPeer();
|
|
59
133
|
channel = peer.createDataChannel(options.label);
|
|
@@ -79,13 +153,17 @@ export async function webRtcPeer(options) {
|
|
|
79
153
|
options.onConnectionState('connecting'); };
|
|
80
154
|
current.ontrack = event => { const track = event.track; if (track.kind !== 'audio' || stopped)
|
|
81
155
|
return; options.onRemoteAudio(!track.muted); track.onmute = () => options.onRemoteAudio(false); track.onunmute = () => options.onRemoteAudio(true); track.onended = () => options.onRemoteAudio(false); };
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
156
|
+
if (options.capture === 'lazy')
|
|
157
|
+
sender = current.addTransceiver('audio', { direction: 'sendrecv' }).sender;
|
|
158
|
+
else {
|
|
159
|
+
const input = stream.getAudioTracks()[0];
|
|
160
|
+
if (!input)
|
|
161
|
+
throw new Error('Microphone is unavailable.');
|
|
162
|
+
input.onmute = () => options.onInterruption(true);
|
|
163
|
+
input.onunmute = () => options.onInterruption(false);
|
|
164
|
+
input.onended = () => fail(new Error('Microphone ended.'));
|
|
165
|
+
sender = current.addTrack(input, stream);
|
|
166
|
+
}
|
|
89
167
|
await current.setLocalDescription(await current.createOffer());
|
|
90
168
|
if (current.iceGatheringState !== 'complete')
|
|
91
169
|
await new Promise((resolve, reject) => {
|
|
@@ -124,7 +202,7 @@ export async function webRtcPeer(options) {
|
|
|
124
202
|
flush();
|
|
125
203
|
return true;
|
|
126
204
|
},
|
|
127
|
-
setMuted(
|
|
205
|
+
setMuted(value) { muted = value; stream?.getAudioTracks().forEach(track => { track.enabled = !value; }); }, attachMic, releaseMic, stop,
|
|
128
206
|
};
|
|
129
207
|
}
|
|
130
208
|
catch (error) {
|