Skip to content

Commit 47665f8

Browse files
committed
feat: telemetry changes
1 parent bb1199e commit 47665f8

11 files changed

Lines changed: 1290 additions & 474 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.62",
3+
"version": "1.1.63",
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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export class VoiceAgent extends Agent {
9494
// console.log(`${LOG_PREFIX} WebRTC peer ${state} but gRPC still alive — staying connected`);
9595
this.switchToTextModeOnDisconnect();
9696
} else {
97+
// gRPC stream is gone — null the transport so the next connect()
98+
// call creates a fresh one instead of returning early on the
99+
// `if (this.webrtcTransport) return` guard.
100+
this.webrtcTransport = null;
97101
this.connectionState = ConnectionState.Disconnected;
98102
this.emit(AgentEvent.ConnectionStateEvent, ConnectionState.Disconnected);
99103
this.switchToTextModeOnDisconnect();
@@ -664,7 +668,14 @@ export class VoiceAgent extends Agent {
664668

665669
// Add or remove the audio transport
666670
if (input === Channel.Audio) {
667-
await this.webrtcTransport?.reconnectAudio();
671+
try {
672+
await this.webrtcTransport?.reconnectAudio();
673+
} catch (error) {
674+
// gRPC stream was lost between ensureConnected and reconnectAudio.
675+
// Trigger a full reconnect on the next user action.
676+
this.emit(AgentEvent.ErrorEvent, "client", `Audio reconnect failed: ${error}`);
677+
this.webrtcTransport = null;
678+
}
668679
} else {
669680
await this.webrtcTransport?.disconnectAudioOnly();
670681
}

src/audio/audio-media-manager.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
*/
2424

2525
import { AgentConfig } from "@/rapida/types/agent-config";
26-
import { isChrome, isEdge, isWindows, isSinkIdSupported } from "@/rapida/utils";
26+
import { isChrome, isEdge, isWindows, isSafari, isIOS, isSinkIdSupported } from "@/rapida/utils";
2727

2828
/** Sample rate for Opus */
2929
const OPUS_SAMPLE_RATE = 48000;
@@ -165,7 +165,7 @@ export class AudioMediaManager {
165165
const base: MediaTrackConstraints = {
166166
echoCancellation: true,
167167
noiseSuppression: true,
168-
autoGainControl: true,
168+
autoGainControl: false,
169169
};
170170

171171
if (this.agentConfig.inputOptions.device) {
@@ -183,7 +183,11 @@ export class AudioMediaManager {
183183
channelCount: { ideal: 1 },
184184
echoCancellation: true,
185185
noiseSuppression: true,
186-
autoGainControl: 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,
187191
};
188192

189193
if (this.agentConfig.inputOptions.device) {
@@ -197,8 +201,9 @@ export class AudioMediaManager {
197201
}
198202

199203
// On Windows, remove sampleRate constraint — WebRTC handles resampling internally,
200-
// and forcing 48kHz can conflict with WASAPI on 44100Hz audio hardware
201-
if (isWindows()) {
204+
// and forcing 48kHz can conflict with WASAPI on 44100Hz audio hardware.
205+
// On Safari/iOS, sampleRate is not a supported getUserMedia constraint and must be omitted.
206+
if (isWindows() || isSafari() || isIOS()) {
202207
delete base.sampleRate;
203208
}
204209

@@ -208,11 +213,11 @@ export class AudioMediaManager {
208213
...base,
209214
// @ts-ignore Chrome/Edge-specific
210215
googEchoCancellation: true,
211-
// @ts-ignore
212-
googAutoGainControl: true,
216+
// @ts-ignore AGC disabled — matches autoGainControl: false above
217+
googAutoGainControl: false,
213218
// @ts-ignore
214219
googNoiseSuppression: true,
215-
// @ts-ignore
220+
// @ts-ignore removes low-frequency rumble (fan/AC noise)
216221
googHighpassFilter: true,
217222
};
218223
}

src/audio/grpc-signaling-manager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ export class GrpcSignalingManager {
8787
});
8888

8989
this.grpcStream.on("end", () => {
90+
// Null the stream immediately so isConnected returns false.
91+
// Without this, callers checking isConnected after the stream
92+
// ends still see true and attempt writes on a dead stream.
93+
this.grpcStream = null;
94+
this.initializationSent = false;
9095
this.callbacks.onConnectionStateChange?.("disconnected");
9196
this.callbacks.onDisconnected?.();
9297
});

src/audio/webrtc-peer-manager.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,13 @@ export class WebRTCPeerManager {
8686
this.onRemoteTrack(stream);
8787
};
8888

89-
// ICE candidates — Safari may lack toJSON() on RTCIceCandidate
90-
this.peerConnection.onicecandidate = (event) => {
91-
if (event.candidate) {
92-
const c = event.candidate;
93-
const json = typeof c.toJSON === "function"
94-
? c.toJSON()
95-
: { candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex };
96-
this.onICECandidate(json);
97-
}
89+
// ICE candidates are NOT sent individually — all candidates are embedded
90+
// inline in the answer SDP (complete ICE, not trickle ICE). Trickle ICE
91+
// candidates sent before the server receives the answer SDP cause
92+
// "InvalidStateError: remote description is not set" on the server.
93+
// We capture them via waitForIceGathering() instead.
94+
this.peerConnection.onicecandidate = (_event) => {
95+
// no-op: candidates are captured via localDescription after gathering
9896
};
9997

10098
// Connection state
@@ -151,14 +149,20 @@ export class WebRTCPeerManager {
151149
* In text-only mode `localStream` is null — we still complete the WebRTC
152150
* negotiation (the server always requires it) but set the transceiver to
153151
* `recvonly` so no microphone is needed.
152+
*
153+
* After setLocalDescription we wait for local ICE gathering to complete
154+
* so that the answer SDP contains all client candidates inline. This pairs
155+
* with the server sending a complete offer (all server candidates inline).
156+
* Together they eliminate trickle-ICE timing bugs that cause Safari to fail.
154157
*/
155158
async handleOffer(sdp: string, localStream: MediaStream | null): Promise<void> {
156159
if (!this.peerConnection) this.setup();
160+
const pc = this.peerConnection!;
157161

158-
await this.peerConnection!.setRemoteDescription({ type: "offer", sdp });
162+
await pc.setRemoteDescription({ type: "offer", sdp });
159163

160164
const track = localStream?.getAudioTracks()[0];
161-
const transceivers = this.peerConnection!.getTransceivers();
165+
const transceivers = pc.getTransceivers();
162166

163167
// Safari (pre-14.1) leaves receiver.track null after setRemoteDescription
164168
// until the first remote packet arrives, so the receiver-track check alone
@@ -174,25 +178,25 @@ export class WebRTCPeerManager {
174178

175179
if (!audioTransceiver) {
176180
console.warn("No audio transceiver found in offer");
177-
const answer = await this.peerConnection!.createAnswer();
178-
await this.peerConnection!.setLocalDescription(answer);
181+
const answer = await pc.createAnswer();
182+
await pc.setLocalDescription(answer);
183+
await this.waitForIceGathering(pc);
179184
return;
180185
}
181186

182187
if (track) {
183188
// Audio mode — bidirectional.
184-
// Set direction first so the answer SDP reflects sendrecv intent.
185189
audioTransceiver.direction = "sendrecv";
186190

187-
// replaceTrack must succeed — if it throws, revert direction so the
188-
// answer SDP does not falsely advertise a send-capable track, and
189-
// re-throw so the caller can surface the failure rather than silently
190-
// completing negotiation with no mic audio reaching the server.
191+
// replaceTrack attaches the mic track to the transceiver's sender.
192+
// If it fails (e.g. on older Safari), fall back to recvonly rather
193+
// than throwing — the answer is still sent and the server can at least
194+
// push audio to the client (user hears the assistant, doesn't send mic).
191195
try {
192196
await audioTransceiver.sender.replaceTrack(track);
193197
} catch (error) {
198+
console.warn("[WebRTCPeerManager] replaceTrack failed, falling back to recvonly", error);
194199
audioTransceiver.direction = "recvonly";
195-
throw error;
196200
}
197201

198202
this.setCodecPreferences(audioTransceiver);
@@ -201,9 +205,37 @@ export class WebRTCPeerManager {
201205
audioTransceiver.direction = "recvonly";
202206
}
203207

204-
// Create and return answer
205-
const answer = await this.peerConnection!.createAnswer();
206-
await this.peerConnection!.setLocalDescription(answer);
208+
const answer = await pc.createAnswer();
209+
await pc.setLocalDescription(answer);
210+
211+
// Wait for local ICE gathering to complete so the answer SDP contains
212+
// all client candidates inline. Safari handles the complete SDP reliably;
213+
// it often drops or misorders trickle ICE candidates that arrive as
214+
// separate addIceCandidate() calls after the offer/answer exchange.
215+
await this.waitForIceGathering(pc);
216+
}
217+
218+
/**
219+
* Wait for the RTCPeerConnection's ICE gathering to reach "complete".
220+
* Resolves immediately if already complete.
221+
* Times out after 5 seconds to avoid blocking forever if STUN is unreachable.
222+
*/
223+
private waitForIceGathering(pc: RTCPeerConnection): Promise<void> {
224+
if (pc.iceGatheringState === "complete") return Promise.resolve();
225+
226+
return new Promise<void>((resolve) => {
227+
const timeout = setTimeout(resolve, 5000);
228+
229+
const handler = () => {
230+
if (pc.iceGatheringState === "complete") {
231+
clearTimeout(timeout);
232+
pc.removeEventListener("icegatheringstatechange", handler);
233+
resolve();
234+
}
235+
};
236+
237+
pc.addEventListener("icegatheringstatechange", handler);
238+
});
207239
}
208240

209241
/** Get local SDP (answer) after handleOffer was called */

src/clients/protos/artifacts

src/clients/protos/assistant-api_pb.d.ts

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,142 @@ export namespace GetAllAssistantTelemetryRequest {
405405
}
406406
}
407407

408+
export class TelemetryEvent extends jspb.Message {
409+
getMessageid(): string;
410+
setMessageid(value: string): void;
411+
412+
getAssistantid(): string;
413+
setAssistantid(value: string): void;
414+
415+
getAssistantconversationid(): string;
416+
setAssistantconversationid(value: string): void;
417+
418+
getProjectid(): string;
419+
setProjectid(value: string): void;
420+
421+
getOrganizationid(): string;
422+
setOrganizationid(value: string): void;
423+
424+
getName(): string;
425+
setName(value: string): void;
426+
427+
getDataMap(): jspb.Map<string, string>;
428+
clearDataMap(): void;
429+
hasTime(): boolean;
430+
clearTime(): void;
431+
getTime(): google_protobuf_timestamp_pb.Timestamp | undefined;
432+
setTime(value?: google_protobuf_timestamp_pb.Timestamp): void;
433+
434+
serializeBinary(): Uint8Array;
435+
toObject(includeInstance?: boolean): TelemetryEvent.AsObject;
436+
static toObject(includeInstance: boolean, msg: TelemetryEvent): TelemetryEvent.AsObject;
437+
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
438+
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
439+
static serializeBinaryToWriter(message: TelemetryEvent, writer: jspb.BinaryWriter): void;
440+
static deserializeBinary(bytes: Uint8Array): TelemetryEvent;
441+
static deserializeBinaryFromReader(message: TelemetryEvent, reader: jspb.BinaryReader): TelemetryEvent;
442+
}
443+
444+
export namespace TelemetryEvent {
445+
export type AsObject = {
446+
messageid: string,
447+
assistantid: string,
448+
assistantconversationid: string,
449+
projectid: string,
450+
organizationid: string,
451+
name: string,
452+
dataMap: Array<[string, string]>,
453+
time?: google_protobuf_timestamp_pb.Timestamp.AsObject,
454+
}
455+
}
456+
457+
export class TelemetryMetric extends jspb.Message {
458+
getContextid(): string;
459+
setContextid(value: string): void;
460+
461+
getAssistantid(): string;
462+
setAssistantid(value: string): void;
463+
464+
getAssistantconversationid(): string;
465+
setAssistantconversationid(value: string): void;
466+
467+
getProjectid(): string;
468+
setProjectid(value: string): void;
469+
470+
getOrganizationid(): string;
471+
setOrganizationid(value: string): void;
472+
473+
getScope(): string;
474+
setScope(value: string): void;
475+
476+
clearMetricsList(): void;
477+
getMetricsList(): Array<common_pb.Metric>;
478+
setMetricsList(value: Array<common_pb.Metric>): void;
479+
addMetrics(value?: common_pb.Metric, index?: number): common_pb.Metric;
480+
481+
hasTime(): boolean;
482+
clearTime(): void;
483+
getTime(): google_protobuf_timestamp_pb.Timestamp | undefined;
484+
setTime(value?: google_protobuf_timestamp_pb.Timestamp): void;
485+
486+
serializeBinary(): Uint8Array;
487+
toObject(includeInstance?: boolean): TelemetryMetric.AsObject;
488+
static toObject(includeInstance: boolean, msg: TelemetryMetric): TelemetryMetric.AsObject;
489+
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
490+
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
491+
static serializeBinaryToWriter(message: TelemetryMetric, writer: jspb.BinaryWriter): void;
492+
static deserializeBinary(bytes: Uint8Array): TelemetryMetric;
493+
static deserializeBinaryFromReader(message: TelemetryMetric, reader: jspb.BinaryReader): TelemetryMetric;
494+
}
495+
496+
export namespace TelemetryMetric {
497+
export type AsObject = {
498+
contextid: string,
499+
assistantid: string,
500+
assistantconversationid: string,
501+
projectid: string,
502+
organizationid: string,
503+
scope: string,
504+
metricsList: Array<common_pb.Metric.AsObject>,
505+
time?: google_protobuf_timestamp_pb.Timestamp.AsObject,
506+
}
507+
}
508+
509+
export class TelemetryRecord extends jspb.Message {
510+
hasEvent(): boolean;
511+
clearEvent(): void;
512+
getEvent(): TelemetryEvent | undefined;
513+
setEvent(value?: TelemetryEvent): void;
514+
515+
hasMetric(): boolean;
516+
clearMetric(): void;
517+
getMetric(): TelemetryMetric | undefined;
518+
setMetric(value?: TelemetryMetric): void;
519+
520+
getRecordCase(): TelemetryRecord.RecordCase;
521+
serializeBinary(): Uint8Array;
522+
toObject(includeInstance?: boolean): TelemetryRecord.AsObject;
523+
static toObject(includeInstance: boolean, msg: TelemetryRecord): TelemetryRecord.AsObject;
524+
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
525+
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
526+
static serializeBinaryToWriter(message: TelemetryRecord, writer: jspb.BinaryWriter): void;
527+
static deserializeBinary(bytes: Uint8Array): TelemetryRecord;
528+
static deserializeBinaryFromReader(message: TelemetryRecord, reader: jspb.BinaryReader): TelemetryRecord;
529+
}
530+
531+
export namespace TelemetryRecord {
532+
export type AsObject = {
533+
event?: TelemetryEvent.AsObject,
534+
metric?: TelemetryMetric.AsObject,
535+
}
536+
537+
export enum RecordCase {
538+
RECORD_NOT_SET = 0,
539+
EVENT = 1,
540+
METRIC = 2,
541+
}
542+
}
543+
408544
export class GetAllAssistantTelemetryResponse extends jspb.Message {
409545
getCode(): number;
410546
setCode(value: number): void;
@@ -413,9 +549,9 @@ export class GetAllAssistantTelemetryResponse extends jspb.Message {
413549
setSuccess(value: boolean): void;
414550

415551
clearDataList(): void;
416-
getDataList(): Array<common_pb.Telemetry>;
417-
setDataList(value: Array<common_pb.Telemetry>): void;
418-
addData(value?: common_pb.Telemetry, index?: number): common_pb.Telemetry;
552+
getDataList(): Array<TelemetryRecord>;
553+
setDataList(value: Array<TelemetryRecord>): void;
554+
addData(value?: TelemetryRecord, index?: number): TelemetryRecord;
419555

420556
hasError(): boolean;
421557
clearError(): void;
@@ -441,7 +577,7 @@ export namespace GetAllAssistantTelemetryResponse {
441577
export type AsObject = {
442578
code: number,
443579
success: boolean,
444-
dataList: Array<common_pb.Telemetry.AsObject>,
580+
dataList: Array<TelemetryRecord.AsObject>,
445581
error?: common_pb.Error.AsObject,
446582
paginated?: common_pb.Paginated.AsObject,
447583
}

0 commit comments

Comments
 (0)