From e4d366dbf8cdf3e85bd7e34a1db8de670e908973 Mon Sep 17 00:00:00 2001 From: Mathieu Dubart Date: Mon, 19 Oct 2026 08:49:22 +0000 Subject: [PATCH] Make the player's output sample rate configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `outputAudioFormat` was pinned to 44100 Hz, so decoded audio was always converted to that rate before reaching the engine. When the output hardware runs at another rate the system then resamples a second time, and a 48 kHz source makes a full round trip for nothing: down to 44100 by the converter and back up to 48000 by the output unit. On iOS the built-in speaker runs at 48 kHz, so this affects the default route on current devices. Add `outputSampleRate` to `AudioPlayerConfiguration`, defaulting to 44100 so existing behaviour and every existing call site are unchanged. Callers that know their hardware rate — on iOS, `AVAudioSession.sharedInstance().sampleRate` — can now match it and drop one conversion. The per-entry converter is created from the source format to the output format already, so nothing else needs to change. `normalizeValues()` clamps a non-positive rate to the default, which is what makes the `AVAudioFormat` initialiser in `AudioPlayer` safe. `outputAudioFormat` also becomes a `let`, as it was never reassigned. --- .../Streaming/AudioPlayer/AudioPlayer.swift | 13 ++++++++++--- .../AudioPlayerConfiguration.swift | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift index 13fba34..9dda4c1 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift @@ -145,8 +145,9 @@ open class AudioPlayer { frameFilterProcessor } - /// An `AVAudioFormat` object for the canonical audio stream - private var outputAudioFormat: AVAudioFormat = .init(commonFormat: .pcmFormatFloat32, sampleRate: 44100.0, channels: 2, interleaved: true)! + /// An `AVAudioFormat` object for the canonical audio stream. + /// Built from `configuration.outputSampleRate` in `init` and fixed for the player's lifetime. + private let outputAudioFormat: AVAudioFormat /// Keeps track of the player's state before being paused. private var stateBeforePaused: InternalState = .initial @@ -185,7 +186,13 @@ open class AudioPlayer { public init(configuration: AudioPlayerConfiguration = .default, clientCertificateProvider: ClientCertificateProvider? = nil) { - self.configuration = configuration.normalizeValues() + let normalizedConfiguration = configuration.normalizeValues() + self.configuration = normalizedConfiguration + // `normalizeValues` guarantees a positive rate, so this cannot fail. + outputAudioFormat = .init(commonFormat: .pcmFormatFloat32, + sampleRate: normalizedConfiguration.outputSampleRate, + channels: 2, + interleaved: true)! let engine = AVAudioEngine() audioEngine = engine rendererContext = AudioRendererContext(configuration: configuration, outputAudioFormat: outputAudioFormat) diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift index 949a823..5c39dd0 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift @@ -20,6 +20,15 @@ public struct AudioPlayerConfiguration: Equatable { /// - note: Must be larger that `bufferSizeInSeconds` let secondsRequiredToStartPlayingAfterBufferUnderrun: Int + /// The sample rate, in hertz, of the player's output format. + /// + /// Decoded audio is converted to this rate before it reaches the engine, so it should match + /// the rate the output hardware is actually running at — on iOS, + /// `AVAudioSession.sharedInstance().sampleRate`. Leaving it at the 44100 default on hardware + /// running at another rate makes the system resample a second time: a 48 kHz source is + /// converted down to 44100 here and back up to 48000 by the output unit. + let outputSampleRate: Double + /// Enables the internal logs let enableLogs: Bool @@ -28,6 +37,7 @@ public struct AudioPlayerConfiguration: Equatable { secondsRequiredToStartPlaying: 1, gracePeriodAfterSeekInSeconds: 0.5, secondsRequiredToStartPlayingAfterBufferUnderrun: 7, + outputSampleRate: 44100, enableLogs: false) /// Initializes the configuration for the `AudioPlayer` /// @@ -38,6 +48,7 @@ public struct AudioPlayerConfiguration: Equatable { /// - parameter secondsRequiredToStartPlaying: Number of seconds of audio required to before playback first starts. /// - parameter gracePeriodAfterSeekInSeconds: Number of seconds of audio required after seek occurs. /// - parameter secondsRequiredToStartPlayingAfterBufferUnderrun: Number of seconds of audio required to before playback resumes after a buffer underrun + /// - parameter outputSampleRate: Sample rate, in hertz, of the player's output format /// - parameter enableLogs: Enables the internal logs /// public init(flushQueueOnSeek: Bool = true, @@ -45,6 +56,7 @@ public struct AudioPlayerConfiguration: Equatable { secondsRequiredToStartPlaying: Double = 1, gracePeriodAfterSeekInSeconds: Double = 0.5, secondsRequiredToStartPlayingAfterBufferUnderrun: Int = 1, + outputSampleRate: Double = 44100, enableLogs: Bool = true) { self.flushQueueOnSeek = flushQueueOnSeek @@ -52,6 +64,7 @@ public struct AudioPlayerConfiguration: Equatable { self.secondsRequiredToStartPlaying = secondsRequiredToStartPlaying self.gracePeriodAfterSeekInSeconds = gracePeriodAfterSeekInSeconds self.secondsRequiredToStartPlayingAfterBufferUnderrun = secondsRequiredToStartPlayingAfterBufferUnderrun + self.outputSampleRate = outputSampleRate self.enableLogs = enableLogs Logger.isEnabled = enableLogs } @@ -76,11 +89,17 @@ public struct AudioPlayerConfiguration: Equatable { ? defaults.secondsRequiredToStartPlayingAfterBufferUnderrun : self.secondsRequiredToStartPlayingAfterBufferUnderrun + // A non-positive rate would make `AVAudioFormat` fail to initialise. + let outputSampleRate = self.outputSampleRate > 0 + ? self.outputSampleRate + : defaults.outputSampleRate + return AudioPlayerConfiguration(flushQueueOnSeek: flushQueueOnSeek, bufferSizeInSeconds: bufferSizeInSeconds, secondsRequiredToStartPlaying: secondsRequiredToStartPlaying, gracePeriodAfterSeekInSeconds: gracePeriodAfterSeekInSeconds, secondsRequiredToStartPlayingAfterBufferUnderrun: secondsRequiredToStartPlayingAfterBufferUnderrun, + outputSampleRate: outputSampleRate, enableLogs: enableLogs) } }