@@ -523,6 +523,19 @@ class SoundPlayer: SoundPlayerManaging {
523523 if !self . isAudioEngineIsSetup {
524524 try ensureAudioEngineIsSetup ( )
525525 }
526+
527+ // Ensure playback session configuration to avoid VPIO constraints at non-48k sample rates
528+ let session = AVAudioSession . sharedInstance ( )
529+ do {
530+ if session. category != . playback {
531+ try session. setActive ( false , options: . notifyOthersOnDeactivation)
532+ try session. setCategory ( . playback, mode: . default, options: [ . defaultToSpeaker] )
533+ try session. setActive ( true )
534+ Logger . debug ( " [SoundPlayer] Switched AVAudioSession to .playback for output " )
535+ }
536+ } catch {
537+ Logger . debug ( " [SoundPlayer] Failed to switch AVAudioSession to playback: \( error) " )
538+ }
526539
527540 guard let buffer = try processAudioChunk ( base64String, commonFormat: commonFormat) else {
528541 Logger . debug ( " [SoundPlayer] Failed to process audio chunk " )
@@ -579,11 +592,8 @@ class SoundPlayer: SoundPlayerManaging {
579592 /// 3. Scheduling the next audio buffer for playback
580593 /// 4. Handling completion callbacks and recursively playing the next chunk
581594 private func playNextInQueue( ) {
582- // Start the audio player node if it's not already playing
583- if !self . audioPlayerNode. isPlaying {
584- Logger . debug ( " [SoundPlayer] Starting Player " )
585- self . audioPlayerNode. play ( )
586- }
595+ // Start the player only when scheduling the first buffer to avoid underruns
596+ // We'll call play() right after scheduling below if needed.
587597
588598 // Use a dedicated queue for buffer access to avoid blocking the main thread
589599 self . bufferAccessQueue. async {
@@ -600,22 +610,29 @@ class SoundPlayer: SoundPlayerManaging {
600610 // Remove the buffer from the queue immediately to avoid playing it twice
601611 self . audioQueue. removeFirst ( )
602612
603- // If the buffer sample rate doesn't match the hardware output, resample for reliable playback
604- var buffer = originalBuffer
613+ // Use AVAudioEngine's built-in format converter between player->mixer->output.
614+ // Just log if there is a mismatch so we can diagnose without altering the buffer.
615+ let buffer = originalBuffer
605616 let outputFormat = self . audioEngine. outputNode. outputFormat ( forBus: 0 )
606617 let inputSR = buffer. format. sampleRate
607618 let outputSR = outputFormat. sampleRate
608619 if abs ( inputSR - outputSR) > 0.5 {
609- Logger . debug ( " [SoundPlayer] Resampling buffer: inputSR= \( inputSR) -> outputSR= \( outputSR) " )
610- if let resampled = AudioUtils . resampleAudioBuffer ( buffer, from: inputSR, to: outputSR) {
611- buffer = resampled
612- } else {
613- Logger . debug ( " [SoundPlayer] Resampling failed; proceeding with original buffer (may sound off) " )
620+ Logger . debug ( " [SoundPlayer] Format SR mismatch (engine will convert): bufferSR= \( inputSR) -> outputSR= \( outputSR) " )
621+ }
622+
623+ // Optional: apply tiny fade-in ramp on first 64 frames to avoid clicks
624+ var scheduledBuffer = buffer
625+ if self . segmentsLeftToPlay == 0 , let channelData = buffer. floatChannelData {
626+ let rampFrames = min ( Int ( buffer. frameLength) , 64 )
627+ for i in 0 ..< rampFrames {
628+ let gain = Float ( i) / Float( rampFrames)
629+ channelData. pointee [ i] *= gain
614630 }
631+ scheduledBuffer = buffer
615632 }
616633
617634 // Schedule the buffer for playback with a completion handler
618- self . audioPlayerNode. scheduleBuffer ( buffer ) { [ weak self] in
635+ self . audioPlayerNode. scheduleBuffer ( scheduledBuffer ) { [ weak self] in
619636 // ✅ Move to main queue to avoid blocking Core Audio's realtime thread
620637 DispatchQueue . main. async {
621638 guard let self = self else {
@@ -660,6 +677,11 @@ class SoundPlayer: SoundPlayerManaging {
660677 }
661678 }
662679 }
680+
681+ // Start the player after scheduling if it isn't already playing
682+ if !self . audioPlayerNode. isPlaying {
683+ self . audioPlayerNode. play ( )
684+ }
663685 }
664686 }
665687 }
0 commit comments