@byokit/realtime 0.2.1 → 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,12 @@
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
+
5
11
  ## 0.2.1 (2026-09-30)
6
12
 
7
13
 
package/README.md CHANGED
@@ -169,6 +169,46 @@ The main entry is portable for web and React Native and loads no native code. `.
169
169
 
170
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.
171
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
+
172
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.
173
213
 
174
214
  ## Reconnect and host policy
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)
@@ -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.1",
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",