@byokit/realtime 0.2.1 → 0.3.1

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.1 (2026-10-01)
6
+
7
+
8
+
9
+ - SECURITY: Lazy WebRTC capture no longer opens the Android microphone or sends untapped speech before `attachMic()`; `releaseMic()` closes recording while keeping the call connected. Apps using lazy capture should update.
10
+
11
+ ## 0.3.0 (2026-10-01)
12
+
13
+
14
+
15
+ - Add opt-in lazy WebRTC microphone capture: preconnect without a microphone lease, attach and release capture without renegotiation, and release pending capture on close.
16
+
5
17
  ## 0.2.1 (2026-09-30)
6
18
 
7
19
 
package/README.md CHANGED
@@ -169,6 +169,48 @@ 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)` before enabling the sender's
198
+ encoding. Lazy peers negotiate `sendrecv` with an inactive encoding: Android's
199
+ audio device cannot start recording just because an answer is applied.
200
+ `releaseMic()` disables that encoding before `replaceTrack(null)`, stops capture tracks and releases the lease so the app
201
+ can stop its microphone service and clear the OS indicator. Neither operation
202
+ renegotiates. Repeated calls are safe; release or stop during pending capture
203
+ cleans up when that capture resolves. Reconnecting starts another lazy peer;
204
+ the app explicitly attaches again when it needs input.
205
+
206
+ The built-in `webRtcPeer` handle also exposes both methods for direct use.
207
+ Custom peer factories may implement these optional methods; client attach
208
+ rejects if the current peer does not support them or setup has not started.
209
+ The default is eager capture, and PCM capture is unchanged. `setMuted()` still
210
+ only toggles track delivery; use `releaseMic()` to relinquish capture. Mute
211
+ state persists when a new track is attached. Each client owns its `AudioPorts`;
212
+ `microphone.release()` must stop the app-owned foreground service.
213
+
172
214
  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
215
 
174
216
  ## 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,121 @@ export async function webRtcPeer(options) {
34
47
  options.onError(error);
35
48
  stop();
36
49
  } };
50
+ const setSending = async (active) => {
51
+ if (options.capture !== 'lazy')
52
+ return;
53
+ const parameters = sender.getParameters();
54
+ if (!parameters.encodings.length)
55
+ throw new Error('Voice capture control is unavailable.');
56
+ parameters.encodings.forEach(encoding => { encoding.active = active; });
57
+ await sender.setParameters(parameters);
58
+ // The native bridge can resolve even when the engine rejects setParameters.
59
+ const applied = sender.getParameters().encodings;
60
+ if (!applied.length || applied.some(encoding => encoding.active !== active))
61
+ throw new Error('Voice capture control failed.');
62
+ };
63
+ const captureMic = async (generation) => {
64
+ await options.audio.microphone.acquire();
65
+ acquired = true;
66
+ if (stopped || generation !== micEpoch) {
67
+ clearMic();
68
+ return;
69
+ }
70
+ stream = await platform.getUserMedia();
71
+ if (stopped || generation !== micEpoch) {
72
+ clearMic();
73
+ return;
74
+ }
75
+ const input = stream.getAudioTracks()[0];
76
+ if (!input)
77
+ throw new Error('Microphone is unavailable.');
78
+ input.enabled = !muted;
79
+ input.onmute = () => options.onInterruption(true);
80
+ input.onunmute = () => options.onInterruption(false);
81
+ input.onended = () => fail(new Error('Microphone ended.'));
82
+ return stream;
83
+ };
84
+ const attachMic = () => {
85
+ const generation = micEpoch;
86
+ return serializeMic(async () => {
87
+ if (stopped)
88
+ throw new Error('Voice is closed.');
89
+ if (generation !== micEpoch || stream)
90
+ return;
91
+ try {
92
+ const captured = await captureMic(generation);
93
+ if (captured) {
94
+ await sender.replaceTrack(captured.getAudioTracks()[0]);
95
+ if (!stopped && generation === micEpoch)
96
+ await setSending(true);
97
+ if (stopped || generation !== micEpoch) {
98
+ if (!stopped) {
99
+ await setSending(false);
100
+ await sender.replaceTrack(null);
101
+ }
102
+ clearMic();
103
+ }
104
+ }
105
+ }
106
+ catch (error) {
107
+ // Never leave a trackless native send stream recording after a failure.
108
+ try {
109
+ if (!stopped && options.capture === 'lazy') {
110
+ await setSending(false);
111
+ await sender.replaceTrack(null);
112
+ }
113
+ }
114
+ catch {
115
+ stop();
116
+ }
117
+ clearMic();
118
+ throw error;
119
+ }
120
+ });
121
+ };
122
+ const releaseMic = () => {
123
+ micEpoch++;
124
+ stream?.getAudioTracks().forEach(track => { track.enabled = false; });
125
+ return serializeMic(async () => {
126
+ try {
127
+ if (!stopped && stream) {
128
+ await setSending(false);
129
+ await sender.replaceTrack(null);
130
+ }
131
+ }
132
+ catch (error) {
133
+ if (options.capture === 'lazy')
134
+ stop();
135
+ throw error;
136
+ }
137
+ finally {
138
+ clearMic();
139
+ }
140
+ });
141
+ };
37
142
  try {
38
143
  options.onConnectionState('connecting');
39
144
  if (options.signal?.aborted)
40
145
  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.');
146
+ if (options.capture !== 'lazy') {
147
+ await options.audio.microphone.acquire();
148
+ acquired = true;
149
+ if (stopped) {
150
+ clearMic();
151
+ throw new Error('Voice is closed.');
152
+ }
47
153
  }
48
154
  await options.audio.route();
49
155
  if (stopped) {
50
156
  await options.audio.unroute();
51
157
  throw new Error('Voice is closed.');
52
158
  }
53
- stream = await platform.getUserMedia();
54
- if (stopped) {
55
- stream.getTracks().forEach(track => track.stop());
56
- throw new Error('Voice is closed.');
159
+ if (options.capture !== 'lazy') {
160
+ stream = await platform.getUserMedia();
161
+ if (stopped) {
162
+ clearMic();
163
+ throw new Error('Voice is closed.');
164
+ }
57
165
  }
58
166
  peer = platform.createPeer();
59
167
  channel = peer.createDataChannel(options.label);
@@ -79,13 +187,21 @@ export async function webRtcPeer(options) {
79
187
  options.onConnectionState('connecting'); };
80
188
  current.ontrack = event => { const track = event.track; if (track.kind !== 'audio' || stopped)
81
189
  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);
190
+ if (options.capture === 'lazy') {
191
+ // Android starts the device module for an active send stream even without
192
+ // a track. Disable the encoding before either description can be applied.
193
+ sender = current.addTransceiver('audio', { direction: 'sendrecv', sendEncodings: [{ active: false }] }).sender;
194
+ await setSending(false);
195
+ }
196
+ else {
197
+ const input = stream.getAudioTracks()[0];
198
+ if (!input)
199
+ throw new Error('Microphone is unavailable.');
200
+ input.onmute = () => options.onInterruption(true);
201
+ input.onunmute = () => options.onInterruption(false);
202
+ input.onended = () => fail(new Error('Microphone ended.'));
203
+ sender = current.addTrack(input, stream);
204
+ }
89
205
  await current.setLocalDescription(await current.createOffer());
90
206
  if (current.iceGatheringState !== 'complete')
91
207
  await new Promise((resolve, reject) => {
@@ -124,7 +240,7 @@ export async function webRtcPeer(options) {
124
240
  flush();
125
241
  return true;
126
242
  },
127
- setMuted(muted) { stream?.getAudioTracks().forEach(track => { track.enabled = !muted; }); }, stop,
243
+ setMuted(value) { muted = value; stream?.getAudioTracks().forEach(track => { track.enabled = !value; }); }, attachMic, releaseMic, stop,
128
244
  };
129
245
  }
130
246
  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.1",
4
4
  "description": "Realtime voice sessions with host-held credentials and injected audio.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",