@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 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<boolean>;
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. Errors or a timeout produce
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 }`. Do not await this check
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<boolean>;
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
- const finish = (status, notify = true) => {
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(status);
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?.(status);
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'), () => finish('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 & {
@@ -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 media capture. */
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 media capture. */
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
- stream?.getTracks().forEach(track => track.stop());
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
- await options.audio.microphone.acquire();
42
- acquired = true;
43
- if (stopped) {
44
- options.audio.microphone.release();
45
- acquired = false;
46
- throw new Error('Voice is closed.');
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
- stream = await platform.getUserMedia();
54
- if (stopped) {
55
- stream.getTracks().forEach(track => track.stop());
56
- throw new Error('Voice is closed.');
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
- const input = stream.getAudioTracks()[0];
83
- if (!input)
84
- throw new Error('Microphone is unavailable.');
85
- input.onmute = () => options.onInterruption(true);
86
- input.onunmute = () => options.onInterruption(false);
87
- input.onended = () => fail(new Error('Microphone ended.'));
88
- current.addTrack(input, stream);
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(muted) { stream?.getAudioTracks().forEach(track => { track.enabled = !muted; }); }, stop,
205
+ setMuted(value) { muted = value; stream?.getAudioTracks().forEach(track => { track.enabled = !value; }); }, attachMic, releaseMic, stop,
128
206
  };
129
207
  }
130
208
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/realtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Realtime voice sessions with host-held credentials and injected audio.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",