@@ -206,6 +206,8 @@ type VapiEventListeners = {
206206} ;
207207
208208type StartCallOptions = {
209+ /** Acquire the microphone before creating a backend call. Defaults to true. */
210+ microphonePreflightEnabled ?: boolean ;
209211 /**
210212 * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
211213 * If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
@@ -356,6 +358,40 @@ export default class Vapi extends VapiEventEmitter {
356358 private speakingTimeout : NodeJS . Timeout | null = null ;
357359 private dailyCallConfig : DailyAdvancedConfig = { } ;
358360 private dailyCallObject : DailyFactoryOptions = { } ;
361+ private microphoneTrack : MediaStreamTrack | null = null ;
362+ private cancelMicrophonePreflight : ( ( ) => void ) | null = null ;
363+
364+ private async microphoneAcquire ( ) : Promise < MediaStreamTrack > {
365+ const source = this . dailyCallObject . audioSource ;
366+ if ( source && typeof source === 'object' ) {
367+ if ( source . readyState === 'ended' ) {
368+ throw new DOMException ( 'The supplied microphone track has ended.' , 'NotReadableError' ) ;
369+ }
370+ return source ;
371+ }
372+ return new Promise < MediaStreamTrack > ( ( resolve , reject ) => {
373+ let cancelled = false ;
374+ this . cancelMicrophonePreflight = ( ) => {
375+ cancelled = true ;
376+ reject ( new DOMException ( 'Microphone preflight cancelled.' , 'AbortError' ) ) ;
377+ } ;
378+ navigator . mediaDevices . getUserMedia ( {
379+ audio : typeof source === 'string' ? { deviceId : { exact : source } } : true ,
380+ } ) . then ( ( stream ) => {
381+ const track = stream . getAudioTracks ( ) [ 0 ] ;
382+ if ( cancelled || ! track || track . readyState === 'ended' ) {
383+ stream . getTracks ( ) . forEach ( ( item ) => item . stop ( ) ) ;
384+ reject ( new DOMException ( 'No live microphone track available.' , 'NotFoundError' ) ) ;
385+ return ;
386+ }
387+ stream . getTracks ( ) . filter ( ( item ) => item !== track ) . forEach ( ( item ) => item . stop ( ) ) ;
388+ this . microphoneTrack = track ;
389+ resolve ( track ) ;
390+ } , reject ) ;
391+ } ) . finally ( ( ) => {
392+ this . cancelMicrophonePreflight = null ;
393+ } ) ;
394+ }
359395
360396 private hasEmittedCallEndedStatus : boolean = false ;
361397
@@ -376,6 +412,8 @@ export default class Vapi extends VapiEventEmitter {
376412 }
377413
378414 private async cleanup ( ) {
415+ this . microphoneTrack ?. stop ( ) ;
416+ this . microphoneTrack = null ;
379417 this . started = false ;
380418 this . hasEmittedCallEndedStatus = false ;
381419 if ( this . call ) {
@@ -498,7 +536,16 @@ export default class Vapi extends VapiEventEmitter {
498536
499537 this . started = true ;
500538
539+ let failureStage = 'microphone-preflight' ;
501540 try {
541+ if ( this . call ) {
542+ await this . cleanup ( ) ;
543+ this . started = true ;
544+ }
545+ const audioSource = options ?. microphonePreflightEnabled === false
546+ ? this . dailyCallObject . audioSource ?? true
547+ : await this . microphoneAcquire ( ) ;
548+ failureStage = 'unknown' ;
502549 // Stage 1: Create web call
503550 this . emit ( 'call-start-progress' , {
504551 stage : 'web-call-creation' ,
@@ -535,16 +582,6 @@ export default class Vapi extends VapiEventEmitter {
535582 }
536583 } ) ;
537584
538- if ( this . call ) {
539- this . emit ( 'call-start-progress' , {
540- stage : 'daily-call-object-creation' ,
541- status : 'started' ,
542- timestamp : new Date ( ) . toISOString ( ) ,
543- metadata : { action : 'cleanup-existing' }
544- } ) ;
545- await this . cleanup ( ) ;
546- }
547-
548585 const isVideoRecordingEnabled =
549586 webCall ?. artifactPlan ?. videoRecordingEnabled ?? false ;
550587
@@ -575,7 +612,7 @@ export default class Vapi extends VapiEventEmitter {
575612
576613 try {
577614 this . call = DailyIframe . createCallObject ( {
578- audioSource : this . dailyCallObject . audioSource ?? true ,
615+ audioSource,
579616 videoSource : this . dailyCallObject . videoSource ?? isVideoRecordingEnabled ,
580617 dailyConfig : this . dailyCallConfig ,
581618 } ) ;
@@ -994,9 +1031,10 @@ export default class Vapi extends VapiEventEmitter {
9941031 } catch ( e ) {
9951032 const totalDuration = Date . now ( ) - startTime ;
9961033 const serializedError = serializeError ( e ) ;
1034+ await this . cleanup ( ) ;
9971035
9981036 this . emit ( 'call-start-failed' , {
999- stage : 'unknown' ,
1037+ stage : failureStage ,
10001038 totalDuration,
10011039 error : serializedError . message ,
10021040 errorStack : serializedError . stack || 'No stack trace available' ,
@@ -1011,8 +1049,10 @@ export default class Vapi extends VapiEventEmitter {
10111049
10121050 // Also emit the generic error event for backward compatibility
10131051 this . emit ( 'error' , {
1014- type : 'start-method-error' ,
1015- stage : 'unknown' ,
1052+ type : failureStage === 'microphone-preflight'
1053+ ? `microphone-preflight-${ e && typeof e === 'object' && 'name' in e ? e . name : 'error' } `
1054+ : 'start-method-error' ,
1055+ stage : failureStage ,
10161056 error : serializedError ,
10171057 totalDuration,
10181058 timestamp : new Date ( ) . toISOString ( ) ,
@@ -1024,7 +1064,6 @@ export default class Vapi extends VapiEventEmitter {
10241064 }
10251065 } ) ;
10261066
1027- await this . cleanup ( ) ;
10281067 return null ;
10291068 }
10301069 }
@@ -1092,6 +1131,12 @@ export default class Vapi extends VapiEventEmitter {
10921131 * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
10931132 */
10941133 async stop ( ) : Promise < void > {
1134+ if ( this . cancelMicrophonePreflight ) {
1135+ this . cancelMicrophonePreflight ( ) ;
1136+ return ;
1137+ }
1138+ this . microphoneTrack ?. stop ( ) ;
1139+ this . microphoneTrack = null ;
10951140 this . started = false ;
10961141 if ( this . call ) {
10971142 await this . call . destroy ( ) ;
0 commit comments