Skip to content

Commit b137167

Browse files
committed
feat: refactored webrtc for bi-directional
1 parent 2125791 commit b137167

11 files changed

Lines changed: 583 additions & 133 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rapidaai/react",
3-
"version": "1.1.77",
3+
"version": "1.1.78",
44
"description": "An easy to use react client for building generative ai application using Rapida platform.",
55
"repository": {
66
"type": "git",

src/agents/voice-agent.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ export class VoiceAgent extends Agent {
8585
// (the server's ConversationInitialization response) to confirm
8686
// the conversation is truly established.
8787
// console.log(`${LOG_PREFIX} transport connected, waiting for conversation initialization`);
88-
} else if (state === "disconnected" || state === "closed" || state === "failed") {
88+
} else if (state === "disconnected") {
89+
// Browser WebRTC can briefly report disconnected during ICE changes.
90+
// Keep the selected channel stable while recovery/renegotiation runs.
91+
} else if (state === "closed" || state === "failed") {
8992
// Only mark as disconnected if the gRPC session is also gone.
9093
// When switching from audio → text the WebRTC peer fires
9194
// "disconnected"/"closed", but the gRPC stream is still alive.
@@ -679,32 +682,35 @@ export class VoiceAgent extends Agent {
679682
this.agentConfig.inputOptions.channel = input;
680683
this.agentConfig.outputOptions.channel = input;
681684

682-
// Track whether a fresh connection was just created.
683-
// If so, ConversationInitialization already carried the stream mode
684-
// — no need to send a redundant ConversationConfiguration.
685-
const wasConnected = this.webrtcTransport?.isGrpcConnected ?? false;
685+
// Fresh Audio connect captures mic during initialization. Runtime switches
686+
// recapture before sending configuration so the offer sees a stable track.
687+
const wasGrpcConnectedBeforeChannelChange = this.webrtcTransport?.isGrpcConnected ?? false;
686688

687689
// Ensure the session exists
688690
await this.ensureConnected();
689691

690692
// Add or remove the audio transport
691693
if (input === Channel.Audio) {
692-
try {
693-
await this.webrtcTransport?.reconnectAudio();
694-
} catch (error) {
695-
// gRPC stream was lost between ensureConnected and reconnectAudio.
696-
// Trigger a full reconnect on the next user action.
697-
this.emit(AgentEvent.ErrorEvent, "client", `Audio reconnect failed: ${error}`);
698-
this.webrtcTransport = null;
694+
if (wasGrpcConnectedBeforeChannelChange) {
695+
try {
696+
await this.webrtcTransport?.reconnectAudio();
697+
} catch (error) {
698+
// gRPC stream was lost between ensureConnected and reconnectAudio.
699+
// Trigger a full reconnect on the next user action.
700+
this.emit(AgentEvent.ErrorEvent, "client", `Audio reconnect failed: ${error}`);
701+
this.webrtcTransport = null;
702+
}
699703
}
700704
} else {
701-
await this.webrtcTransport?.disconnectAudioOnly();
705+
if (wasGrpcConnectedBeforeChannelChange) {
706+
await this.webrtcTransport?.disconnectAudioOnly();
707+
}
702708
}
703709

704710
// Only send ConversationConfiguration for runtime mode switches.
705711
// On a fresh connection the ConversationInitialization message
706712
// already includes the stream mode, so sending it again is redundant.
707-
if (wasConnected) {
713+
if (wasGrpcConnectedBeforeChannelChange) {
708714
this.webrtcTransport?.sendConversationConfiguration();
709715
}
710716

src/audio/audio-media-manager.ts

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,17 @@ export class AudioMediaManager {
102102
}
103103
}
104104

105-
const track = this.localStream.getAudioTracks()[0];
106-
if (track) {
107-
track.enabled = true;
108-
109-
// Store the actual device being used when none was explicitly selected
110-
const settings = track.getSettings();
111-
if (settings.deviceId && !this.agentConfig.inputOptions.device) {
112-
this.agentConfig.inputOptions.device = settings.deviceId;
113-
}
105+
const localAudioTrack = this.localStream.getAudioTracks()[0];
106+
if (!localAudioTrack) {
107+
throw new Error("[AudioMediaManager] Microphone capture returned no audio track");
108+
}
109+
110+
localAudioTrack.enabled = true;
111+
112+
// Store the actual device being used when none was explicitly selected.
113+
const localAudioTrackSettings = localAudioTrack.getSettings();
114+
if (localAudioTrackSettings.deviceId && !this.agentConfig.inputOptions.device) {
115+
this.agentConfig.inputOptions.device = localAudioTrackSettings.deviceId;
114116
}
115117

116118
// Audio context for visualization - use vendor-prefixed version if needed
@@ -165,7 +167,7 @@ export class AudioMediaManager {
165167
const base: MediaTrackConstraints = {
166168
echoCancellation: true,
167169
noiseSuppression: true,
168-
autoGainControl: false,
170+
autoGainControl: true,
169171
};
170172

171173
if (this.agentConfig.inputOptions.device) {
@@ -183,11 +185,9 @@ export class AudioMediaManager {
183185
channelCount: { ideal: 1 },
184186
echoCancellation: true,
185187
noiseSuppression: true,
186-
// Disable AGC for voice AI: when the assistant finishes speaking and the
187-
// user starts talking, AGC would abruptly boost the mic gain (it was
188-
// suppressed while output was playing), clipping the first syllable and
189-
// causing a jarring volume jump. The STT model handles normalization.
190-
autoGainControl: false,
188+
// Keep AGC enabled to prevent low microphone capture levels on quieter
189+
// mics and mobile hardware.
190+
autoGainControl: true,
191191
};
192192

193193
if (this.agentConfig.inputOptions.device) {
@@ -213,8 +213,8 @@ export class AudioMediaManager {
213213
...base,
214214
// @ts-ignore Chrome/Edge-specific
215215
googEchoCancellation: true,
216-
// @ts-ignore AGC disabled — matches autoGainControl: false above
217-
googAutoGainControl: false,
216+
// @ts-ignore Chrome/Edge-specific AGC.
217+
googAutoGainControl: true,
218218
// @ts-ignore
219219
googNoiseSuppression: true,
220220
// @ts-ignore removes low-frequency rumble (fan/AC noise)
@@ -362,9 +362,19 @@ export class AudioMediaManager {
362362
// the load algorithm (which internally pauses), so play() must wait until
363363
// the element is ready or AbortError follows.
364364
if (el.readyState < 3 /* HAVE_FUTURE_DATA */) {
365-
await new Promise<void>((resolve) => {
366-
el.addEventListener('canplay', resolve as EventListener, { once: true });
367-
setTimeout(resolve, 200);
365+
await new Promise<void>((resolvePromise) => {
366+
let isPromiseResolved = false;
367+
// Resolve from whichever arrives first: `canplay` or fallback timeout.
368+
const resolveOnce = () => {
369+
if (isPromiseResolved) return;
370+
isPromiseResolved = true;
371+
clearTimeout(fallbackTimeoutId);
372+
el.removeEventListener("canplay", handleCanPlay);
373+
resolvePromise();
374+
};
375+
const handleCanPlay: EventListener = () => resolveOnce();
376+
const fallbackTimeoutId = setTimeout(resolveOnce, 200);
377+
el.addEventListener("canplay", handleCanPlay, { once: true });
368378
});
369379
}
370380

@@ -417,9 +427,19 @@ export class AudioMediaManager {
417427
// complete before resuming. 200 ms timeout guards against streams
418428
// with no active audio tracks where canplay may never fire.
419429
if (el.readyState < 3 /* HAVE_FUTURE_DATA */) {
420-
await new Promise<void>((resolve) => {
421-
el.addEventListener('canplay', resolve as EventListener, { once: true });
422-
setTimeout(resolve, 200);
430+
await new Promise<void>((resolvePromise) => {
431+
let isPromiseResolved = false;
432+
// Resolve from whichever arrives first: `canplay` or fallback timeout.
433+
const resolveOnce = () => {
434+
if (isPromiseResolved) return;
435+
isPromiseResolved = true;
436+
clearTimeout(fallbackTimeoutId);
437+
el.removeEventListener("canplay", handleCanPlay);
438+
resolvePromise();
439+
};
440+
const handleCanPlay: EventListener = () => resolveOnce();
441+
const fallbackTimeoutId = setTimeout(resolveOnce, 200);
442+
el.addEventListener("canplay", handleCanPlay, { once: true });
423443
});
424444
}
425445

@@ -504,6 +524,12 @@ export class AudioMediaManager {
504524
}
505525
}
506526

527+
const localAudioTrack = this.localStream.getAudioTracks()[0];
528+
if (!localAudioTrack) {
529+
throw new Error("[AudioMediaManager] Microphone device switch returned no audio track");
530+
}
531+
localAudioTrack.enabled = !this.isMuted;
532+
507533
// Reconnect analyser (disconnect old source first to avoid leaking audio nodes)
508534
if (this.audioContext && this._inputAnalyser && this.localStream) {
509535
this._inputAnalyser.disconnect();
@@ -554,7 +580,7 @@ export class AudioMediaManager {
554580

555581
/** Get the new audio track after setInputDevice (caller wires it to peer) */
556582
getLocalAudioTrack(): MediaStreamTrack | undefined {
557-
return this.localStream?.getAudioTracks()[0];
583+
return this.localStream?.getAudioTracks().find(track => track.readyState === "live");
558584
}
559585

560586
// ---------------------------------------------------------------------------

src/audio/message-protocol-handler.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,13 @@ import type { AudioMediaManager } from "./audio-media-manager";
4545
* - Handle conversation init/config responses
4646
* - Handle assistant / user / interruption / tool call messages
4747
*
48-
* This is pure dispatch logic — it holds no state of its own.
48+
* State: tracks the active WebRTC signaling session to ignore stale offers/ICE.
4949
*/
5050
export class MessageProtocolHandler {
5151
// Serialization chain ensures signaling messages (CONFIG → OFFER → ICE)
5252
// are processed in order even though gRPC callbacks fire without await.
5353
private processingChain: Promise<void> = Promise.resolve();
54+
private activeSignalingSessionId: string | null = null;
5455

5556
constructor(
5657
private callbacks: AgentCallback,
@@ -159,45 +160,74 @@ export class MessageProtocolHandler {
159160
// ---------------------------------------------------------------------------
160161

161162
private async handleServerSignaling(signaling: ServerSignaling): Promise<void> {
162-
const sessionId = signaling.getSessionid();
163-
if (sessionId) this.signaling.setSessionId(sessionId);
164-
163+
const signalingSessionId = signaling.getSessionid();
165164
const messageCase = signaling.getMessageCase();
166165

166+
if (messageCase !== ServerSignaling.MessageCase.CONFIG) {
167+
if (this.activeSignalingSessionId && signalingSessionId && signalingSessionId !== this.activeSignalingSessionId) {
168+
console.debug("[Protocol] Ignoring stale WebRTC signaling", {
169+
activeSignalingSessionId: this.activeSignalingSessionId,
170+
signalingSessionId,
171+
messageCase,
172+
});
173+
return;
174+
}
175+
if (!this.activeSignalingSessionId && signalingSessionId) {
176+
this.activeSignalingSessionId = signalingSessionId;
177+
this.signaling.setSessionId(signalingSessionId);
178+
}
179+
}
180+
167181
switch (messageCase) {
168182
case ServerSignaling.MessageCase.CONFIG: {
169-
const config = signaling.getConfig();
170-
const iceServers = config?.getIceserversList()?.map(srv => ({
171-
urls: srv.getUrlsList(),
172-
username: srv.getUsername() || undefined,
173-
credential: srv.getCredential() || undefined,
174-
})) as RTCIceServer[] | undefined;
175-
this.peer.setup(iceServers);
183+
try {
184+
this.activeSignalingSessionId = signalingSessionId || null;
185+
if (this.activeSignalingSessionId) {
186+
this.signaling.setSessionId(this.activeSignalingSessionId);
187+
}
188+
189+
const config = signaling.getConfig();
190+
const iceServers = config?.getIceserversList()?.map(srv => ({
191+
urls: srv.getUrlsList(),
192+
username: srv.getUsername() || undefined,
193+
credential: srv.getCredential() || undefined,
194+
})) as RTCIceServer[] | undefined;
195+
this.peer.setup(iceServers);
196+
} catch (error) {
197+
this.peer.close();
198+
console.error("Failed to handle WebRTC config signaling", error);
199+
this.callbacks.onConnectionStateChange?.("failed");
200+
this.callbacks.onError?.(new Error(`WebRTC config signaling failed: ${error}`));
201+
}
176202
break;
177203
}
178204

179205
case ServerSignaling.MessageCase.SDP: {
206+
const sdpMessage = signaling.getSdp();
207+
const sdpType = sdpMessage?.getType();
180208
try {
181-
const sdpMsg = signaling.getSdp();
182-
if (!sdpMsg) break;
209+
if (!sdpMessage) break;
183210

184-
const sdpType = sdpMsg.getType();
185-
const sdp = sdpMsg.getSdp();
211+
const sdp = sdpMessage.getSdp();
186212

187213
if (sdpType === WebRTCSDP.SDPType.OFFER) {
188214
// console.log("[Protocol] Handling SDP OFFER");
189-
await this.peer.handleOffer(sdp, this.audio.mediaStream);
190-
const answerSdp = this.peer.getLocalSDP();
215+
const answerSdp = await this.peer.handleOffer(sdp, this.audio.mediaStream);
191216
if (answerSdp) {
192217
this.signaling.sendWebRTCAnswer(answerSdp);
193218
} else {
194-
console.error("No local SDP after creating answer");
219+
console.debug("[Protocol] SDP offer was superseded before answer was sent");
195220
}
196221
} else if (sdpType === WebRTCSDP.SDPType.ANSWER) {
197222
// console.log("[Protocol] Handling SDP ANSWER");
198223
await this.peer.handleAnswer(sdp);
199224
}
200225
} catch (error) {
226+
if (sdpType === WebRTCSDP.SDPType.OFFER) {
227+
// Cleanup strategy: on failed offer handling, close stale peer state.
228+
// Next CONFIG/OFFER cycle will recreate the peer cleanly.
229+
this.peer.close();
230+
}
201231
console.error("Failed to handle SDP signaling", error);
202232
this.callbacks.onConnectionStateChange?.("failed");
203233
this.callbacks.onError?.(new Error(`SDP signaling failed: ${error}`));

0 commit comments

Comments
 (0)