@@ -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