@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 +6 -0
- package/README.md +40 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +18 -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
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)
|
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) {
|