Skip to content

Commit 82190bc

Browse files
committed
feat: browser support for grpc/webrtc client
1 parent c32d1b7 commit 82190bc

4 files changed

Lines changed: 140 additions & 98 deletions

File tree

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.59",
3+
"version": "1.1.60",
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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,9 +670,13 @@ export class VoiceAgent extends Agent {
670670
*/
671671
public getInputByteFrequencyData = (): Uint8Array | undefined => {
672672
const analyser = this.webrtcTransport?.inputAnalyserNode;
673+
if (!analyser) return undefined;
673674

674675
if (analyser) {
675-
this.inputFrequencyData = new Uint8Array(analyser.frequencyBinCount);
676+
const size = analyser.frequencyBinCount;
677+
if (!this.inputFrequencyData || this.inputFrequencyData.length !== size) {
678+
this.inputFrequencyData = new Uint8Array(size);
679+
}
676680
(analyser.getByteFrequencyData as (array: Uint8Array) => void)(
677681
this.inputFrequencyData
678682
);
@@ -686,9 +690,13 @@ export class VoiceAgent extends Agent {
686690
*/
687691
public getOutputByteFrequencyData = (): Uint8Array | undefined => {
688692
const analyser = this.webrtcTransport?.outputAnalyserNode;
693+
if (!analyser) return undefined;
689694

690695
if (analyser) {
691-
this.outputFrequencyData = new Uint8Array(analyser.frequencyBinCount);
696+
const size = analyser.frequencyBinCount;
697+
if (!this.outputFrequencyData || this.outputFrequencyData.length !== size) {
698+
this.outputFrequencyData = new Uint8Array(size);
699+
}
692700
(analyser.getByteFrequencyData as (array: Uint8Array) => void)(
693701
this.outputFrequencyData
694702
);

src/audio/audio-media-manager.ts

Lines changed: 65 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export class AudioMediaManager {
5858
private _outputAnalyser: AnalyserNode | null = null;
5959
private isMuted = false;
6060
private _volume = 1;
61+
// Prevents registering duplicate click/touchstart autoplay-recovery listeners
62+
private _userInteractionHandlerRegistered = false;
6163

6264
constructor(private agentConfig: AgentConfig) { }
6365

@@ -72,10 +74,12 @@ export class AudioMediaManager {
7274
try {
7375
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: constraints, video: false });
7476
} catch (error: any) {
75-
// On Windows, some browsers may fail with specific constraints
76-
// Try with simplified constraints as fallback
77-
if (isWindows() && error?.name === "OverconstrainedError") {
78-
console.warn("[AudioMediaManager] Retrying with simplified audio constraints for Windows");
77+
// OverconstrainedError can occur on any platform when an 'exact' deviceId
78+
// constraint cannot be satisfied (e.g. the device was unplugged).
79+
// Retry with simplified constraints so the call degrades gracefully
80+
// to the default device rather than failing completely.
81+
if (error?.name === "OverconstrainedError") {
82+
console.warn("[AudioMediaManager] Retrying with simplified audio constraints after OverconstrainedError");
7983
this.localStream = await navigator.mediaDevices.getUserMedia({
8084
audio: this.getSimplifiedAudioConstraints(),
8185
video: false,
@@ -120,15 +124,8 @@ export class AudioMediaManager {
120124
}
121125

122126
try {
123-
// Create AudioContext with explicit sample rate for Windows compatibility
124-
const options: AudioContextOptions = {};
125-
126-
// On Windows, specify sample rate explicitly to avoid potential issues
127-
if (isWindows()) {
128-
options.sampleRate = OPUS_SAMPLE_RATE;
129-
}
130-
131-
this.audioContext = new AudioContextClass(options);
127+
// No sampleRate option — browser picks native system rate (avoids WASAPI conflict on Windows)
128+
this.audioContext = new AudioContextClass();
132129

133130
// Handle suspended state (common due to autoplay policies)
134131
if (this.audioContext.state === "suspended") {
@@ -181,6 +178,12 @@ export class AudioMediaManager {
181178
}
182179
}
183180

181+
// On Windows, remove sampleRate constraint — WebRTC handles resampling internally,
182+
// and forcing 48kHz can conflict with WASAPI on 44100Hz audio hardware
183+
if (isWindows()) {
184+
delete base.sampleRate;
185+
}
186+
184187
// Chrome and Edge (Chromium-based) support additional constraints
185188
if (isChrome() || isEdge()) {
186189
return {
@@ -196,12 +199,6 @@ export class AudioMediaManager {
196199
};
197200
}
198201

199-
// Firefox on Windows may need different handling
200-
if (isFirefox() && isWindows()) {
201-
// Firefox doesn't support sampleRate constraint well on Windows
202-
delete base.sampleRate;
203-
}
204-
205202
return base;
206203
}
207204

@@ -315,12 +312,13 @@ export class AudioMediaManager {
315312
await this.audioElement.play();
316313
} catch (error: any) {
317314
if (error?.name === "NotAllowedError") {
318-
// Autoplay blocked - setup user interaction handler
315+
// Autoplay policy blocked playback — wait for a user gesture to resume.
319316
console.debug("[AudioMediaManager] Autoplay blocked, waiting for user interaction");
320317
this.setupUserInteractionHandler();
321-
} else {
318+
} else if (error?.name !== "AbortError") {
319+
// AbortError is a transient race (srcObject reassignment); ignore it.
320+
// Any other error is unexpected and worth logging.
322321
console.error("[AudioMediaManager] Failed to start playback:", error);
323-
this.setupUserInteractionHandler();
324322
}
325323
}
326324
}
@@ -331,11 +329,23 @@ export class AudioMediaManager {
331329
private async recoverAudioPlayback(): Promise<void> {
332330
if (!this.audioElement || !this.remoteStream) return;
333331

332+
const el = this.audioElement;
334333
try {
335-
// Reconnect the stream
336-
this.audioElement.srcObject = null;
337-
this.audioElement.srcObject = this.remoteStream;
338-
await this.audioElement.play();
334+
el.pause();
335+
el.srcObject = null;
336+
el.srcObject = this.remoteStream;
337+
338+
// Same canplay-wait as interruptPlayback: srcObject reassignment triggers
339+
// the load algorithm (which internally pauses), so play() must wait until
340+
// the element is ready or AbortError follows.
341+
if (el.readyState < 3 /* HAVE_FUTURE_DATA */) {
342+
await new Promise<void>((resolve) => {
343+
el.addEventListener('canplay', resolve as EventListener, { once: true });
344+
setTimeout(resolve, 200);
345+
});
346+
}
347+
348+
await el.play();
339349
} catch (error) {
340350
console.debug("[AudioMediaManager] Audio recovery failed:", error);
341351
}
@@ -370,16 +380,29 @@ export class AudioMediaManager {
370380

371381
try {
372382
// Pause playback to flush the browser's internal audio buffer.
373-
this.audioElement.pause();
383+
const el = this.audioElement;
384+
el.pause();
374385

375386
// Detach and re-attach the stream to discard any buffered frames.
376-
const stream = this.audioElement.srcObject;
377-
this.audioElement.srcObject = null;
378-
this.audioElement.srcObject = stream;
387+
const stream = el.srcObject;
388+
el.srcObject = null;
389+
el.srcObject = stream;
390+
391+
// Reassigning srcObject triggers the browser's media load algorithm,
392+
// which internally issues a pause step. Calling play() before that
393+
// settles produces an AbortError. Wait for canplay so the load is
394+
// complete before resuming. 200 ms timeout guards against streams
395+
// with no active audio tracks where canplay may never fire.
396+
if (el.readyState < 3 /* HAVE_FUTURE_DATA */) {
397+
await new Promise<void>((resolve) => {
398+
el.addEventListener('canplay', resolve as EventListener, { once: true });
399+
setTimeout(resolve, 200);
400+
});
401+
}
379402

380-
// Resume immediately — new frames from the server (post-interruption)
381-
// will play as soon as they arrive.
382-
await this.audioElement.play();
403+
// Resume — new frames from the server (post-interruption) will play
404+
// as soon as they arrive.
405+
await el.play();
383406
} catch (error) {
384407
// play() may throw NotAllowedError if autoplay policy blocks it;
385408
// non-fatal since the next server audio will trigger playback anyway.
@@ -408,11 +431,18 @@ export class AudioMediaManager {
408431

409432

410433
private setupUserInteractionHandler(): void {
434+
// Guard: only one pair of listeners at a time. Without this, every failed
435+
// play() call (e.g. on repeated reconnects) stacks up duplicate handlers.
436+
if (this._userInteractionHandlerRegistered) return;
437+
this._userInteractionHandlerRegistered = true;
438+
411439
const startAudio = async () => {
440+
this._userInteractionHandlerRegistered = false;
412441
try {
413442
if (this.audioContext?.state === "suspended") await this.audioContext.resume();
414443
if (this.audioElement?.paused) await this.audioElement.play();
415444
} catch { }
445+
// once:true removes the firing listener; manually remove the other one.
416446
document.removeEventListener("click", startAudio);
417447
document.removeEventListener("touchstart", startAudio);
418448
};
@@ -526,6 +556,8 @@ export class AudioMediaManager {
526556
this.audioElement.srcObject = null;
527557
}
528558

559+
// Allow the user-interaction handler to re-register after a reconnect.
560+
this._userInteractionHandlerRegistered = false;
529561
this.remoteStream = null;
530562
} catch (error) {
531563
console.error("Failed to disconnect audio", error);

0 commit comments

Comments
 (0)