Skip to content

Commit 4558ec8

Browse files
authored
Merge pull request #157 from anam-org/feat/non-blocking-mic
feat: request mic permissions aync
2 parents 04fc617 + 889a56d commit 4558ec8

File tree

6 files changed

+156
-42
lines changed

6 files changed

+156
-42
lines changed

src/AnamClient.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import {
2020
AnamClientOptions,
2121
AnamEvent,
22+
AudioPermissionState,
2223
ConnectionClosedCode,
2324
EventCallbacks,
2425
InputAudioState,
@@ -35,7 +36,10 @@ export default class AnamClient {
3536

3637
private personaConfig: PersonaConfig | undefined;
3738
private clientOptions: AnamClientOptions | undefined;
38-
private inputAudioState: InputAudioState = { isMuted: false };
39+
private inputAudioState: InputAudioState = {
40+
isMuted: false,
41+
permissionState: AudioPermissionState.NOT_REQUESTED,
42+
};
3943

4044
private sessionId: string | null = null;
4145
private organizationId: string | null = null;

src/modules/StreamingClient.ts

Lines changed: 136 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import {
88
AnamEvent,
99
InputAudioState,
10+
AudioPermissionState,
1011
InternalEvent,
1112
SignalMessage,
1213
SignalMessageAction,
@@ -40,7 +41,10 @@ export class StreamingClient {
4041
private videoElement: HTMLVideoElement | null = null;
4142
private videoStream: MediaStream | null = null;
4243
private audioStream: MediaStream | null = null;
43-
private inputAudioState: InputAudioState = { isMuted: false };
44+
private inputAudioState: InputAudioState = {
45+
isMuted: false,
46+
permissionState: AudioPermissionState.NOT_REQUESTED,
47+
};
4448
private audioDeviceId: string | undefined;
4549
private disableInputAudio: boolean;
4650
private successMetricPoller: ReturnType<typeof setInterval> | null = null;
@@ -307,7 +311,7 @@ export class StreamingClient {
307311
// start the connection
308312
this.signallingClient.connect();
309313
} catch (error) {
310-
console.log('StreamingClient - startConnection: error', error);
314+
console.error('StreamingClient - startConnection: error', error);
311315
this.handleWebrtcFailure(error);
312316
}
313317
}
@@ -363,6 +367,18 @@ export class StreamingClient {
363367
this.peerConnection.addTransceiver('audio', { direction: 'recvonly' });
364368
} else {
365369
this.peerConnection.addTransceiver('audio', { direction: 'sendrecv' });
370+
371+
// Handle audio setup after transceivers are configured
372+
if (this.inputAudioStream) {
373+
// User provided an audio stream, set it up immediately
374+
await this.setupAudioTrack();
375+
} else {
376+
// No user stream, start microphone permission request asynchronously
377+
// Don't await - let it run in parallel with connection setup
378+
this.requestMicrophonePermissionAsync().catch((error) => {
379+
console.error('Async microphone permission request failed:', error);
380+
});
381+
}
366382
}
367383
}
368384

@@ -392,7 +408,6 @@ export class StreamingClient {
392408
break;
393409
case SignalMessageAction.END_SESSION:
394410
const reason = signalMessage.payload as string;
395-
console.log('StreamingClient - onSignalMessage: reason', reason);
396411
this.publicEventEmitter.emit(
397412
AnamEvent.CONNECTION_CLOSED,
398413
ConnectionClosedCode.SERVER_CLOSED_CONNECTION,
@@ -548,48 +563,20 @@ export class StreamingClient {
548563
);
549564
return;
550565
}
566+
551567
/**
552-
* Audio
568+
* Audio - Validate user-provided stream only
553569
*
554-
* If the user hasn't provided an audio stream, capture the audio stream from the user's microphone and send it to the peer connection
555-
* If input audio is disabled we don't send any audio to the peer connection
570+
* If the user provided an audio stream, validate it has audio tracks
571+
* Microphone permission request will be handled asynchronously
556572
*/
557-
if (!this.disableInputAudio) {
558-
if (this.inputAudioStream) {
559-
// verify the user provided stream has audio tracks
560-
if (!this.inputAudioStream.getAudioTracks().length) {
561-
throw new Error(
562-
'StreamingClient - setupDataChannels: user provided stream does not have audio tracks',
563-
);
564-
}
565-
} else {
566-
const audioConstraints: MediaTrackConstraints = {
567-
echoCancellation: true,
568-
};
569-
570-
// If an audio device ID is provided in the options, use it
571-
if (this.audioDeviceId) {
572-
audioConstraints.deviceId = {
573-
exact: this.audioDeviceId,
574-
};
575-
}
576-
577-
this.inputAudioStream = await navigator.mediaDevices.getUserMedia({
578-
audio: audioConstraints,
579-
});
580-
}
581-
582-
// mute the audio tracks if the user has muted the microphone
583-
if (this.inputAudioState.isMuted) {
584-
this.muteAllAudioTracks();
573+
if (!this.disableInputAudio && this.inputAudioStream) {
574+
// verify the user provided stream has audio tracks
575+
if (!this.inputAudioStream.getAudioTracks().length) {
576+
throw new Error(
577+
'StreamingClient - setupDataChannels: user provided stream does not have audio tracks',
578+
);
585579
}
586-
const audioTrack = this.inputAudioStream.getAudioTracks()[0];
587-
this.peerConnection.addTrack(audioTrack, this.inputAudioStream);
588-
// pass the stream to the callback if it exists
589-
this.publicEventEmitter.emit(
590-
AnamEvent.INPUT_AUDIO_STREAM_STARTED,
591-
this.inputAudioStream,
592-
);
593580
}
594581

595582
/**
@@ -615,6 +602,114 @@ export class StreamingClient {
615602
};
616603
}
617604

605+
/**
606+
* Request microphone permission asynchronously without blocking connection
607+
*/
608+
private async requestMicrophonePermissionAsync() {
609+
if (this.inputAudioState.permissionState === AudioPermissionState.PENDING) {
610+
return; // Already requesting
611+
}
612+
613+
this.inputAudioState = {
614+
...this.inputAudioState,
615+
permissionState: AudioPermissionState.PENDING,
616+
};
617+
618+
this.publicEventEmitter.emit(AnamEvent.MIC_PERMISSION_PENDING);
619+
620+
try {
621+
const audioConstraints: MediaTrackConstraints = {
622+
echoCancellation: true,
623+
};
624+
625+
// If an audio device ID is provided in the options, use it
626+
if (this.audioDeviceId) {
627+
audioConstraints.deviceId = {
628+
exact: this.audioDeviceId,
629+
};
630+
}
631+
632+
this.inputAudioStream = await navigator.mediaDevices.getUserMedia({
633+
audio: audioConstraints,
634+
});
635+
636+
this.inputAudioState = {
637+
...this.inputAudioState,
638+
permissionState: AudioPermissionState.GRANTED,
639+
};
640+
641+
this.publicEventEmitter.emit(AnamEvent.MIC_PERMISSION_GRANTED);
642+
643+
// Now add the audio track to the existing connection
644+
await this.setupAudioTrack();
645+
} catch (error) {
646+
console.error('Failed to get microphone permission:', error);
647+
this.inputAudioState = {
648+
...this.inputAudioState,
649+
permissionState: AudioPermissionState.DENIED,
650+
};
651+
652+
const errorMessage =
653+
error instanceof Error ? error.message : String(error);
654+
this.publicEventEmitter.emit(
655+
AnamEvent.MIC_PERMISSION_DENIED,
656+
errorMessage,
657+
);
658+
}
659+
}
660+
661+
/**
662+
* Set up audio track and add it to the peer connection using replaceTrack
663+
*/
664+
private async setupAudioTrack() {
665+
if (!this.peerConnection || !this.inputAudioStream) {
666+
return;
667+
}
668+
669+
// verify the stream has audio tracks
670+
if (!this.inputAudioStream.getAudioTracks().length) {
671+
console.error(
672+
'StreamingClient - setupAudioTrack: stream does not have audio tracks',
673+
);
674+
return;
675+
}
676+
677+
// mute the audio tracks if the user has muted the microphone
678+
if (this.inputAudioState.isMuted) {
679+
this.muteAllAudioTracks();
680+
}
681+
682+
const audioTrack = this.inputAudioStream.getAudioTracks()[0];
683+
684+
// Find the audio sender
685+
const existingSenders = this.peerConnection.getSenders();
686+
const audioSender = existingSenders.find(
687+
(sender) =>
688+
sender.track?.kind === 'audio' ||
689+
(sender.track === null && sender.dtmf !== null), // audio sender without track
690+
);
691+
692+
if (audioSender) {
693+
// Replace existing track (or null track) with our audio track
694+
try {
695+
await audioSender.replaceTrack(audioTrack);
696+
} catch (error) {
697+
console.error('Failed to replace audio track:', error);
698+
// Fallback: add track normally
699+
this.peerConnection.addTrack(audioTrack, this.inputAudioStream);
700+
}
701+
} else {
702+
// No audio sender found, add track normally
703+
this.peerConnection.addTrack(audioTrack, this.inputAudioStream);
704+
}
705+
706+
// pass the stream to the callback
707+
this.publicEventEmitter.emit(
708+
AnamEvent.INPUT_AUDIO_STREAM_STARTED,
709+
this.inputAudioStream,
710+
);
711+
}
712+
618713
private async initPeerConnectionAndSendOffer() {
619714
await this.initPeerConnection();
620715

src/types/InputAudioState.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
export enum AudioPermissionState {
2+
PENDING = 'pending',
3+
GRANTED = 'granted',
4+
DENIED = 'denied',
5+
NOT_REQUESTED = 'not_requested',
6+
}
7+
18
export interface InputAudioState {
29
isMuted: boolean;
10+
permissionState: AudioPermissionState;
311
}

src/types/events/public/AnamEvent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ export enum AnamEvent {
1010
TALK_STREAM_INTERRUPTED = 'TALK_STREAM_INTERRUPTED',
1111
SESSION_READY = 'SESSION_READY',
1212
SERVER_WARNING = 'SERVER_WARNING',
13+
MIC_PERMISSION_PENDING = 'MIC_PERMISSION_PENDING',
14+
MIC_PERMISSION_GRANTED = 'MIC_PERMISSION_GRANTED',
15+
MIC_PERMISSION_DENIED = 'MIC_PERMISSION_DENIED',
1316
}

src/types/events/public/EventCallbacks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ export type EventCallbacks = {
1818
[AnamEvent.TALK_STREAM_INTERRUPTED]: (correlationId: string) => void;
1919
[AnamEvent.SESSION_READY]: (sessionId: string) => void;
2020
[AnamEvent.SERVER_WARNING]: (message: string) => void;
21+
[AnamEvent.MIC_PERMISSION_PENDING]: () => void;
22+
[AnamEvent.MIC_PERMISSION_GRANTED]: () => void;
23+
[AnamEvent.MIC_PERMISSION_DENIED]: (error: string) => void;
2124
};

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type * from './streaming';
55
export type * from './coreApi';
66
export type { PersonaConfig } from './PersonaConfig';
77
export type { InputAudioState } from './InputAudioState';
8+
export { AudioPermissionState } from './InputAudioState';
89
export type * from './messageHistory';
910
export { MessageRole } from './messageHistory'; // need to export this explicitly to avoid enum import issues
1011
export type * from './events';

0 commit comments

Comments
 (0)