From 488bc25b28936f78045e4887e669a5ee23c7d9a5 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:12:38 +0700 Subject: [PATCH 1/5] squash progress --- .../LiveKit/Audio/MixerEngineObserver.swift | 123 +++++++++--- Sources/LiveKit/Audio/SoundPlayer.swift | 178 ++++++++++++++++++ .../Support/Audio/AudioConverter.swift | 4 +- .../LiveKit/Support/AudioPlayerRenderer.swift | 2 + 4 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 Sources/LiveKit/Audio/SoundPlayer.swift diff --git a/Sources/LiveKit/Audio/MixerEngineObserver.swift b/Sources/LiveKit/Audio/MixerEngineObserver.swift index df4422056..3cbbad2c7 100644 --- a/Sources/LiveKit/Audio/MixerEngineObserver.swift +++ b/Sources/LiveKit/Audio/MixerEngineObserver.swift @@ -37,8 +37,8 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { /// Adjust the volume of captured app audio. Range is 0.0 ~ 1.0. public var appVolume: Float { - get { _state.read { $0.appMixerNode.outputVolume } } - set { _state.mutate { $0.appMixerNode.outputVolume = newValue } } + get { _state.read { $0.inputPlayerMixerNode.outputVolume } } + set { _state.mutate { $0.inputPlayerMixerNode.outputVolume = newValue } } } /// Adjust the volume of microphone audio. Range is 0.0 ~ 1.0. @@ -50,7 +50,7 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { // MARK: - Internal var appAudioNode: AVAudioPlayerNode { - _state.read { $0.appNode } + _state.read { $0.inputPlayerNode } } var micAudioNode: AVAudioPlayerNode { @@ -60,26 +60,32 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { struct State { var next: (any AudioEngineObserver)? - // AppAudio - let appNode = AVAudioPlayerNode() - let appMixerNode = AVAudioMixerNode() + // Input app audio + let inputPlayerNode = AVAudioPlayerNode() + let inputPlayerMixerNode = AVAudioMixerNode() + var inputPlayerNodeFormat: AVAudioFormat? // Not connected for device rendering mode. let micNode = AVAudioPlayerNode() let micMixerNode = AVAudioMixerNode() + // + let outputPlayerNode = AVAudioPlayerNode() + let outputPlayerMixerNode = AVAudioMixerNode() + var outputPlayerNodeFormat: AVAudioFormat? + // Reference to mainMixerNode weak var mainMixerNode: AVAudioMixerNode? + + // Keep track of preferred output volume before nodes attach var outputVolume: Float = 1.0 // Internal states var isInputConnected: Bool = false + var isOutputConnected: Bool = false // Cached converters var converters: [AVAudioFormat: AudioConverter] = [:] - - // Reference to engine format - var playerNodeFormat: AVAudioFormat? } let _state = StateSync(State()) @@ -92,15 +98,19 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { public func engineDidCreate(_ engine: AVAudioEngine) -> Int { log("isManualRenderingMode: \(engine.isInManualRenderingMode)") - let (appNode, appMixerNode, micNode, micMixerNode) = _state.read { - ($0.appNode, $0.appMixerNode, $0.micNode, $0.micMixerNode) + + let (inputPlayerNode, inputPlayerMixerNode, micNode, micMixerNode, outputPlayerNode, outputPlayerMixerNode) = _state.read { + ($0.inputPlayerNode, $0.inputPlayerMixerNode, $0.micNode, $0.micMixerNode, $0.outputPlayerNode, $0.outputPlayerMixerNode) } - engine.attach(appNode) - engine.attach(appMixerNode) + engine.attach(inputPlayerNode) + engine.attach(inputPlayerMixerNode) engine.attach(micNode) engine.attach(micMixerNode) + engine.attach(outputPlayerNode) + engine.attach(outputPlayerMixerNode) + // Invoke next return next?.engineDidCreate(engine) ?? 0 } @@ -110,15 +120,18 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { // Invoke next let nextResult = next?.engineWillRelease(engine) - let (appNode, appMixerNode, micNode, micMixerNode) = _state.read { - ($0.appNode, $0.appMixerNode, $0.micNode, $0.micMixerNode) + let (inputPlayerNode, inputPlayerMixerNode, micNode, micMixerNode, outputPlayerNode, outputPlayerMixerNode) = _state.read { + ($0.inputPlayerNode, $0.inputPlayerMixerNode, $0.micNode, $0.micMixerNode, $0.outputPlayerNode, $0.outputPlayerMixerNode) } - engine.detach(appNode) - engine.detach(appMixerNode) + engine.detach(inputPlayerNode) + engine.detach(inputPlayerMixerNode) engine.detach(micNode) engine.detach(micMixerNode) + engine.detach(outputPlayerNode) + engine.detach(outputPlayerMixerNode) + return nextResult ?? 0 } @@ -134,7 +147,7 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { // Read nodes from state lock. let (appNode, appMixerNode, micNode, micMixerNode) = _state.read { - ($0.appNode, $0.appMixerNode, $0.micNode, $0.micMixerNode) + ($0.inputPlayerNode, $0.inputPlayerMixerNode, $0.micNode, $0.micMixerNode) } // AVAudioPlayerNode doesn't support Int16 so we ensure to use Float32 @@ -162,11 +175,11 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { engine.connect(micMixerNode, to: mainMixerNode, format: format) _state.mutate { - if let previousEngineFormat = $0.playerNodeFormat, previousEngineFormat != format { + if let previousInputEngineFormat = $0.inputPlayerNodeFormat, previousInputEngineFormat != format { // Clear cached converters when engine format changes $0.converters.removeAll() } - $0.playerNodeFormat = playerNodeFormat + $0.inputPlayerNodeFormat = playerNodeFormat $0.isInputConnected = true } @@ -174,6 +187,8 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { return next?.engineWillConnectInput(engine, src: src, dst: dst, format: format, context: context) ?? 0 } + // let sineWaveNode = SineWaveSourceNode() + public func engineWillConnectOutput(_ engine: AVAudioEngine, src: AVAudioNode, dst: AVAudioNode?, format: AVAudioFormat, context: [AnyHashable: Any]) -> Int { log("isManualRenderingMode: \(engine.isInManualRenderingMode)") // Get the main mixer @@ -184,15 +199,51 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { engine.mainMixerNode.outputVolume = outputVolume + // Read nodes from state lock. + let outAppPlayerNode = _state.read { + $0.outputPlayerNode + } + + // AVAudioPlayerNode doesn't support Int16 so we ensure to use Float32 + let playerNodeFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, + sampleRate: format.sampleRate, + channels: format.channelCount, + interleaved: format.isInterleaved)! + + engine.connect(outAppPlayerNode, to: engine.mainMixerNode, format: playerNodeFormat) + + _state.mutate { + if let previousOutputEngineFormat = $0.outputPlayerNodeFormat, previousOutputEngineFormat != format { + // Clear cached converters when engine format changes + $0.converters.removeAll() + } + $0.outputPlayerNodeFormat = playerNodeFormat + $0.isOutputConnected = true + } + return next?.engineWillConnectOutput(engine, src: src, dst: dst, format: format, context: context) ?? 0 } } extension MixerEngineObserver { // Create or use cached AudioConverter. - func converter(for format: AVAudioFormat) -> AudioConverter? { + func inputConverter(for format: AVAudioFormat) -> AudioConverter? { + _state.mutate { + guard let playerNodeFormat = $0.inputPlayerNodeFormat else { return nil } + + if let converter = $0.converters[format] { + return converter + } + + let newConverter = AudioConverter(from: format, to: playerNodeFormat) + $0.converters[format] = newConverter + return newConverter + } + } + + func outputConverter(for format: AVAudioFormat) -> AudioConverter? { _state.mutate { - guard let playerNodeFormat = $0.playerNodeFormat else { return nil } + guard let playerNodeFormat = $0.outputPlayerNodeFormat else { return nil } if let converter = $0.converters[format] { return converter @@ -207,7 +258,7 @@ extension MixerEngineObserver { // Capture appAudio and apply conversion automatically suitable for internal audio engine. public func capture(appAudio inputBuffer: AVAudioPCMBuffer) { let (isConnected, appNode) = _state.read { - ($0.isInputConnected, $0.appNode) + ($0.isInputConnected, $0.inputPlayerNode) } guard isConnected, let engine = appNode.engine, engine.isRunning else { @@ -216,7 +267,7 @@ extension MixerEngineObserver { } // Create or update the converter if needed - let converter = converter(for: inputBuffer.format) + let converter = inputConverter(for: inputBuffer.format) guard let converter else { return } @@ -227,4 +278,28 @@ extension MixerEngineObserver { appNode.play() } } + + // Capture out-appAudio and apply conversion automatically suitable for internal audio engine. + public func capture(outAppAudio outputBuffer: AVAudioPCMBuffer) { + let (isConnected, appNode) = _state.read { + ($0.isOutputConnected, $0.outputPlayerNode) + } + + guard isConnected, let engine = appNode.engine, engine.isRunning else { + log("Engine is not running", .warning) + return + } + + // Create or update the converter if needed + let converter = outputConverter(for: outputBuffer.format) + + guard let converter else { return } + + let buffer = converter.convert(from: outputBuffer) + appNode.scheduleBuffer(buffer) + + if !appNode.isPlaying { + appNode.play() + } + } } diff --git a/Sources/LiveKit/Audio/SoundPlayer.swift b/Sources/LiveKit/Audio/SoundPlayer.swift new file mode 100644 index 000000000..a59c3f8d7 --- /dev/null +++ b/Sources/LiveKit/Audio/SoundPlayer.swift @@ -0,0 +1,178 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@preconcurrency import AVFAudio + +public class SoundPlayer: Loggable, @unchecked Sendable { + private let engine = AVAudioEngine() + private let playerNodes = AVAudioPlayerNodePool() + public let outputFormat: AVAudioFormat + + private struct State { + var sounds: [String: AVAudioPCMBuffer] = [:] + } + + private let _state = StateSync(State()) + + public init() { + outputFormat = engine.outputNode.outputFormat(forBus: 0) + playerNodes.attach(to: engine) + } + + public func startEngine() async throws { + guard !engine.isRunning else { + log("Engine already running", .info) + return + } + log("Starting audio engine...") + + playerNodes.connect(to: engine, node: engine.mainMixerNode, format: outputFormat) + + try engine.start() + log("Audio engine started") + } + + public func stopEngine() { + guard engine.isRunning else { + log("Engine already stopped", .info) + return + } + log("Stopping audio engine...") + + playerNodes.stop() + engine.stop() + + log("Audio engine stopped") + } + + public func prepare(url: URL, withId id: String) throws { + // Prepare audio file + let audioFile = try AVAudioFile(forReading: url) + // Prepare buffer + guard let readBuffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(audioFile.length)) else { + throw NSError(domain: "Audio", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot allocate buffer"]) + } + // Read all into buffer + try audioFile.read(into: readBuffer, frameCount: AVAudioFrameCount(audioFile.length)) + // Compute required convert buffer capacity + let outputBufferCapacity = AudioConverter.frameCapacity(from: readBuffer.format, to: outputFormat, inputFrameCount: readBuffer.frameLength) + // Create converter + guard let converter = AudioConverter(from: readBuffer.format, to: outputFormat, outputBufferCapacity: outputBufferCapacity) else { + throw NSError(domain: "Audio", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot create converter"]) + } + // Convert to suitable format for audio engine + let convertedBuffer = converter.convert(from: readBuffer) + // Register + _state.mutate { + $0.sounds[id] = convertedBuffer + } + } + + public func play(id: String) async throws { + guard engine.isRunning else { + throw NSError(domain: "Audio", code: 2, userInfo: [NSLocalizedDescriptionKey: "Engine not running"]) + } + guard let audioBuffer = _state.read(\.sounds[id]) else { + throw NSError(domain: "Audio", code: 2, userInfo: [NSLocalizedDescriptionKey: "Sound not prepared"]) + } + try playerNodes.scheduleBuffer(audioBuffer) + } +} + +// Support scheduling buffer to play concurrently +class AVAudioPlayerNodePool: @unchecked Sendable, Loggable { + let poolSize: Int + private let mixerNode = AVAudioMixerNode() + + private struct State { + var playerNodes: [AVAudioPlayerNode] + } + + private let audioCallbackQueue = DispatchQueue(label: "audio.playerNodePool.queue") + + private let _state: StateSync + + init(poolSize: Int = 10) { + self.poolSize = poolSize + let playerNodes = (0 ..< poolSize).map { _ in AVAudioPlayerNode() } + _state = StateSync(State(playerNodes: playerNodes)) + } + + func attach(to engine: AVAudioEngine) { + let playerNodes = _state.read(\.playerNodes) + // Attach playerNodes + for playerNode in playerNodes { + engine.attach(playerNode) + } + // Attach mixerNode + engine.attach(mixerNode) + } + + func connect(to engine: AVAudioEngine, node: AVAudioNode, format: AVAudioFormat? = nil) { + let playerNodes = _state.read(\.playerNodes) + for playerNode in playerNodes { + engine.connect(playerNode, to: mixerNode, format: format) + } + engine.connect(mixerNode, to: node, format: format) + } + + func detach(from engine: AVAudioEngine) { + let playerNodes = _state.read(\.playerNodes) + // Detach playerNodes + for playerNode in playerNodes { + playerNode.stop() + engine.detach(playerNode) + } + // Detach mixerNode + engine.detach(mixerNode) + } + + func scheduleBuffer(_ buffer: AVAudioPCMBuffer) throws { + guard let node = nextAvailablePlayerNode() else { + throw NSError(domain: "Audio", code: 2, userInfo: [NSLocalizedDescriptionKey: "No available player nodes"]) + } + log("Next node: \(node)") + + node.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self, weak node] _ in + guard let self else { return } + audioCallbackQueue.async { [weak node] in + guard let node else { return } + node.stop() // Stop the node + } + } + node.play() + } + + // Stops all player nodes + func stop() { + for node in _state.read(\.playerNodes) { + node.stop() + } + } + + private func nextAvailablePlayerNode() -> AVAudioPlayerNode? { + // Find first available node + guard let node = _state.read({ $0.playerNodes.first(where: { !$0.isPlaying }) }) else { + return nil + } + + // Ensure node settings + node.volume = 1.0 + node.pan = 0.0 + + return node + } +} diff --git a/Sources/LiveKit/Support/Audio/AudioConverter.swift b/Sources/LiveKit/Support/Audio/AudioConverter.swift index ce497a070..2177a0ac3 100644 --- a/Sources/LiveKit/Support/Audio/AudioConverter.swift +++ b/Sources/LiveKit/Support/Audio/AudioConverter.swift @@ -28,10 +28,10 @@ final class AudioConverter: Sendable { let inputSampleRate = inputFormat.sampleRate let outputSampleRate = outputFormat.sampleRate // Compute the output frame capacity based on sample rate ratio - return AVAudioFrameCount(Double(inputFrameCount) * (outputSampleRate / inputSampleRate)) + return AVAudioFrameCount(ceil(Double(inputFrameCount) * (outputSampleRate / inputSampleRate))) } - init?(from inputFormat: AVAudioFormat, to outputFormat: AVAudioFormat, outputBufferCapacity: AVAudioFrameCount = 9600) { + init?(from inputFormat: AVAudioFormat, to outputFormat: AVAudioFormat, outputBufferCapacity: AVAudioFrameCount = 1024 * 10) { guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat), let buffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: outputBufferCapacity) else { diff --git a/Sources/LiveKit/Support/AudioPlayerRenderer.swift b/Sources/LiveKit/Support/AudioPlayerRenderer.swift index 9d4567f92..c41c6e122 100644 --- a/Sources/LiveKit/Support/AudioPlayerRenderer.swift +++ b/Sources/LiveKit/Support/AudioPlayerRenderer.swift @@ -29,6 +29,7 @@ public class AudioPlayerRenderer: AudioRenderer, Loggable, @unchecked Sendable { } public func start() async throws { + guard !engine.isRunning else { return } log("Starting audio engine...") let format = engine.outputNode.outputFormat(forBus: 0) @@ -41,6 +42,7 @@ public class AudioPlayerRenderer: AudioRenderer, Loggable, @unchecked Sendable { } public func stop() { + guard engine.isRunning else { return } log("Stopping audio engine...") playerNode.stop() From b21ae42d7ac07f021116892b2c75052d71fcc41f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 31 Oct 2025 23:24:47 +0700 Subject: [PATCH 2/5] revert changes to mixer --- .../LiveKit/Audio/MixerEngineObserver.swift | 123 ++++-------------- 1 file changed, 24 insertions(+), 99 deletions(-) diff --git a/Sources/LiveKit/Audio/MixerEngineObserver.swift b/Sources/LiveKit/Audio/MixerEngineObserver.swift index 3cbbad2c7..df4422056 100644 --- a/Sources/LiveKit/Audio/MixerEngineObserver.swift +++ b/Sources/LiveKit/Audio/MixerEngineObserver.swift @@ -37,8 +37,8 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { /// Adjust the volume of captured app audio. Range is 0.0 ~ 1.0. public var appVolume: Float { - get { _state.read { $0.inputPlayerMixerNode.outputVolume } } - set { _state.mutate { $0.inputPlayerMixerNode.outputVolume = newValue } } + get { _state.read { $0.appMixerNode.outputVolume } } + set { _state.mutate { $0.appMixerNode.outputVolume = newValue } } } /// Adjust the volume of microphone audio. Range is 0.0 ~ 1.0. @@ -50,7 +50,7 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { // MARK: - Internal var appAudioNode: AVAudioPlayerNode { - _state.read { $0.inputPlayerNode } + _state.read { $0.appNode } } var micAudioNode: AVAudioPlayerNode { @@ -60,32 +60,26 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { struct State { var next: (any AudioEngineObserver)? - // Input app audio - let inputPlayerNode = AVAudioPlayerNode() - let inputPlayerMixerNode = AVAudioMixerNode() - var inputPlayerNodeFormat: AVAudioFormat? + // AppAudio + let appNode = AVAudioPlayerNode() + let appMixerNode = AVAudioMixerNode() // Not connected for device rendering mode. let micNode = AVAudioPlayerNode() let micMixerNode = AVAudioMixerNode() - // - let outputPlayerNode = AVAudioPlayerNode() - let outputPlayerMixerNode = AVAudioMixerNode() - var outputPlayerNodeFormat: AVAudioFormat? - // Reference to mainMixerNode weak var mainMixerNode: AVAudioMixerNode? - - // Keep track of preferred output volume before nodes attach var outputVolume: Float = 1.0 // Internal states var isInputConnected: Bool = false - var isOutputConnected: Bool = false // Cached converters var converters: [AVAudioFormat: AudioConverter] = [:] + + // Reference to engine format + var playerNodeFormat: AVAudioFormat? } let _state = StateSync(State()) @@ -98,19 +92,15 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { public func engineDidCreate(_ engine: AVAudioEngine) -> Int { log("isManualRenderingMode: \(engine.isInManualRenderingMode)") - - let (inputPlayerNode, inputPlayerMixerNode, micNode, micMixerNode, outputPlayerNode, outputPlayerMixerNode) = _state.read { - ($0.inputPlayerNode, $0.inputPlayerMixerNode, $0.micNode, $0.micMixerNode, $0.outputPlayerNode, $0.outputPlayerMixerNode) + let (appNode, appMixerNode, micNode, micMixerNode) = _state.read { + ($0.appNode, $0.appMixerNode, $0.micNode, $0.micMixerNode) } - engine.attach(inputPlayerNode) - engine.attach(inputPlayerMixerNode) + engine.attach(appNode) + engine.attach(appMixerNode) engine.attach(micNode) engine.attach(micMixerNode) - engine.attach(outputPlayerNode) - engine.attach(outputPlayerMixerNode) - // Invoke next return next?.engineDidCreate(engine) ?? 0 } @@ -120,18 +110,15 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { // Invoke next let nextResult = next?.engineWillRelease(engine) - let (inputPlayerNode, inputPlayerMixerNode, micNode, micMixerNode, outputPlayerNode, outputPlayerMixerNode) = _state.read { - ($0.inputPlayerNode, $0.inputPlayerMixerNode, $0.micNode, $0.micMixerNode, $0.outputPlayerNode, $0.outputPlayerMixerNode) + let (appNode, appMixerNode, micNode, micMixerNode) = _state.read { + ($0.appNode, $0.appMixerNode, $0.micNode, $0.micMixerNode) } - engine.detach(inputPlayerNode) - engine.detach(inputPlayerMixerNode) + engine.detach(appNode) + engine.detach(appMixerNode) engine.detach(micNode) engine.detach(micMixerNode) - engine.detach(outputPlayerNode) - engine.detach(outputPlayerMixerNode) - return nextResult ?? 0 } @@ -147,7 +134,7 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { // Read nodes from state lock. let (appNode, appMixerNode, micNode, micMixerNode) = _state.read { - ($0.inputPlayerNode, $0.inputPlayerMixerNode, $0.micNode, $0.micMixerNode) + ($0.appNode, $0.appMixerNode, $0.micNode, $0.micMixerNode) } // AVAudioPlayerNode doesn't support Int16 so we ensure to use Float32 @@ -175,11 +162,11 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { engine.connect(micMixerNode, to: mainMixerNode, format: format) _state.mutate { - if let previousInputEngineFormat = $0.inputPlayerNodeFormat, previousInputEngineFormat != format { + if let previousEngineFormat = $0.playerNodeFormat, previousEngineFormat != format { // Clear cached converters when engine format changes $0.converters.removeAll() } - $0.inputPlayerNodeFormat = playerNodeFormat + $0.playerNodeFormat = playerNodeFormat $0.isInputConnected = true } @@ -187,8 +174,6 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { return next?.engineWillConnectInput(engine, src: src, dst: dst, format: format, context: context) ?? 0 } - // let sineWaveNode = SineWaveSourceNode() - public func engineWillConnectOutput(_ engine: AVAudioEngine, src: AVAudioNode, dst: AVAudioNode?, format: AVAudioFormat, context: [AnyHashable: Any]) -> Int { log("isManualRenderingMode: \(engine.isInManualRenderingMode)") // Get the main mixer @@ -199,51 +184,15 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable { engine.mainMixerNode.outputVolume = outputVolume - // Read nodes from state lock. - let outAppPlayerNode = _state.read { - $0.outputPlayerNode - } - - // AVAudioPlayerNode doesn't support Int16 so we ensure to use Float32 - let playerNodeFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, - sampleRate: format.sampleRate, - channels: format.channelCount, - interleaved: format.isInterleaved)! - - engine.connect(outAppPlayerNode, to: engine.mainMixerNode, format: playerNodeFormat) - - _state.mutate { - if let previousOutputEngineFormat = $0.outputPlayerNodeFormat, previousOutputEngineFormat != format { - // Clear cached converters when engine format changes - $0.converters.removeAll() - } - $0.outputPlayerNodeFormat = playerNodeFormat - $0.isOutputConnected = true - } - return next?.engineWillConnectOutput(engine, src: src, dst: dst, format: format, context: context) ?? 0 } } extension MixerEngineObserver { // Create or use cached AudioConverter. - func inputConverter(for format: AVAudioFormat) -> AudioConverter? { - _state.mutate { - guard let playerNodeFormat = $0.inputPlayerNodeFormat else { return nil } - - if let converter = $0.converters[format] { - return converter - } - - let newConverter = AudioConverter(from: format, to: playerNodeFormat) - $0.converters[format] = newConverter - return newConverter - } - } - - func outputConverter(for format: AVAudioFormat) -> AudioConverter? { + func converter(for format: AVAudioFormat) -> AudioConverter? { _state.mutate { - guard let playerNodeFormat = $0.outputPlayerNodeFormat else { return nil } + guard let playerNodeFormat = $0.playerNodeFormat else { return nil } if let converter = $0.converters[format] { return converter @@ -258,7 +207,7 @@ extension MixerEngineObserver { // Capture appAudio and apply conversion automatically suitable for internal audio engine. public func capture(appAudio inputBuffer: AVAudioPCMBuffer) { let (isConnected, appNode) = _state.read { - ($0.isInputConnected, $0.inputPlayerNode) + ($0.isInputConnected, $0.appNode) } guard isConnected, let engine = appNode.engine, engine.isRunning else { @@ -267,7 +216,7 @@ extension MixerEngineObserver { } // Create or update the converter if needed - let converter = inputConverter(for: inputBuffer.format) + let converter = converter(for: inputBuffer.format) guard let converter else { return } @@ -278,28 +227,4 @@ extension MixerEngineObserver { appNode.play() } } - - // Capture out-appAudio and apply conversion automatically suitable for internal audio engine. - public func capture(outAppAudio outputBuffer: AVAudioPCMBuffer) { - let (isConnected, appNode) = _state.read { - ($0.isOutputConnected, $0.outputPlayerNode) - } - - guard isConnected, let engine = appNode.engine, engine.isRunning else { - log("Engine is not running", .warning) - return - } - - // Create or update the converter if needed - let converter = outputConverter(for: outputBuffer.format) - - guard let converter else { return } - - let buffer = converter.convert(from: outputBuffer) - appNode.scheduleBuffer(buffer) - - if !appNode.isPlaying { - appNode.play() - } - } } From 1911ce02f25add1262106be1d30324c1cdb1df7d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:08:11 +0700 Subject: [PATCH 3/5] temp: remove session setActive(false) --- Sources/LiveKit/Audio/AudioSessionEngineObserver.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index a01ecab59..5a265cdd5 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -92,14 +92,7 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck log("AudioSession activationCount: \(session.activationCount), webRTCSessionCount: \(session.webRTCSessionCount)") } - if (!newState.isPlayoutEnabled && !newState.isRecordingEnabled) && (oldState.isPlayoutEnabled || oldState.isRecordingEnabled) { - do { - log("AudioSession deactivating...") - try session.setActive(false) - } catch { - log("AudioSession failed to deactivate with error: \(error)", .error) - } - } else if newState.isRecordingEnabled || newState.isPlayoutEnabled { + if newState.isRecordingEnabled || newState.isPlayoutEnabled { // Configure and activate the session with the appropriate category let playAndRecord: AudioSessionConfiguration = newState.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver let config: AudioSessionConfiguration = newState.isRecordingEnabled ? playAndRecord : .playback From 38d225c5a40bb21e73dadba857012d0181fcabee Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:25:41 +0700 Subject: [PATCH 4/5] Revert "temp: remove session setActive(false)" This reverts commit 1911ce02f25add1262106be1d30324c1cdb1df7d. --- Sources/LiveKit/Audio/AudioSessionEngineObserver.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 5a265cdd5..a01ecab59 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -92,7 +92,14 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck log("AudioSession activationCount: \(session.activationCount), webRTCSessionCount: \(session.webRTCSessionCount)") } - if newState.isRecordingEnabled || newState.isPlayoutEnabled { + if (!newState.isPlayoutEnabled && !newState.isRecordingEnabled) && (oldState.isPlayoutEnabled || oldState.isRecordingEnabled) { + do { + log("AudioSession deactivating...") + try session.setActive(false) + } catch { + log("AudioSession failed to deactivate with error: \(error)", .error) + } + } else if newState.isRecordingEnabled || newState.isPlayoutEnabled { // Configure and activate the session with the appropriate category let playAndRecord: AudioSessionConfiguration = newState.isSpeakerOutputPreferred ? .playAndRecordSpeaker : .playAndRecordReceiver let config: AudioSessionConfiguration = newState.isRecordingEnabled ? playAndRecord : .playback From d14b092b58fdc83465506e0c634283f925c88e7f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:44:00 +0700 Subject: [PATCH 5/5] session requirement --- .../Audio/AudioSessionEngineObserver.swift | 45 ++++++++++++++----- Sources/LiveKit/Audio/SoundPlayer.swift | 12 +++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index a01ecab59..a3245ddfc 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -48,17 +48,40 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck set { _state.mutate { $0.isSpeakerOutputPreferred = newValue } } } + public struct SessionRequirement: Sendable { + public static let none = Self(isPlayoutEnabled: false, isRecordingEnabled: false) + public static let playbackOnly = Self(isPlayoutEnabled: true, isRecordingEnabled: false) + public static let recordingOnly = Self(isPlayoutEnabled: false, isRecordingEnabled: true) + public static let playbackAndRecording = Self(isPlayoutEnabled: true, isRecordingEnabled: true) + + public let isPlayoutEnabled: Bool + public let isRecordingEnabled: Bool + + public init(isPlayoutEnabled: Bool = false, isRecordingEnabled: Bool = false) { + self.isPlayoutEnabled = isPlayoutEnabled + self.isRecordingEnabled = isRecordingEnabled + } + } + struct State: Sendable { var next: (any AudioEngineObserver)? var isAutomaticConfigurationEnabled: Bool = true - var isPlayoutEnabled: Bool = false - var isRecordingEnabled: Bool = false var isSpeakerOutputPreferred: Bool = true + + // + var sessionRequirements: [UUID: SessionRequirement] = [:] + + // Computed + var isPlayoutEnabled: Bool { sessionRequirements.values.contains(where: \.isPlayoutEnabled) } + var isRecordingEnabled: Bool { sessionRequirements.values.contains(where: \.isRecordingEnabled) } } let _state = StateSync(State()) + // Session requirement id for this object + private let sessionRequirementId = UUID() + public var next: (any AudioEngineObserver)? { get { _state.next } set { _state.mutate { $0.next = newValue } } @@ -83,6 +106,10 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck } } + public func set(requirement: SessionRequirement, for id: UUID) { + _state.mutate { $0.sessionRequirements[id] = requirement } + } + @Sendable func configure(oldState: State, newState: State) { let session = LKRTCAudioSession.sharedInstance() @@ -92,6 +119,8 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck log("AudioSession activationCount: \(session.activationCount), webRTCSessionCount: \(session.webRTCSessionCount)") } + log("configure isRecordingEnabled: \(newState.isRecordingEnabled), isPlayoutEnabled: \(newState.isPlayoutEnabled)") + if (!newState.isPlayoutEnabled && !newState.isRecordingEnabled) && (oldState.isPlayoutEnabled || oldState.isRecordingEnabled) { do { log("AudioSession deactivating...") @@ -123,10 +152,8 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck } public func engineWillEnable(_ engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { - _state.mutate { - $0.isPlayoutEnabled = isPlayoutEnabled - $0.isRecordingEnabled = isRecordingEnabled - } + let requirement = SessionRequirement(isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) + set(requirement: requirement, for: sessionRequirementId) // Call next last return _state.next?.engineWillEnable(engine, isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) ?? 0 @@ -136,10 +163,8 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck // Call next first let nextResult = _state.next?.engineDidDisable(engine, isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) - _state.mutate { - $0.isPlayoutEnabled = isPlayoutEnabled - $0.isRecordingEnabled = isRecordingEnabled - } + let requirement = SessionRequirement(isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) + set(requirement: requirement, for: sessionRequirementId) return nextResult ?? 0 } diff --git a/Sources/LiveKit/Audio/SoundPlayer.swift b/Sources/LiveKit/Audio/SoundPlayer.swift index a59c3f8d7..56216890e 100644 --- a/Sources/LiveKit/Audio/SoundPlayer.swift +++ b/Sources/LiveKit/Audio/SoundPlayer.swift @@ -21,6 +21,9 @@ public class SoundPlayer: Loggable, @unchecked Sendable { private let playerNodes = AVAudioPlayerNodePool() public let outputFormat: AVAudioFormat + // Session requirement id for this object + private let sessionRequirementId = UUID() + private struct State { var sounds: [String: AVAudioPCMBuffer] = [:] } @@ -41,6 +44,11 @@ public class SoundPlayer: Loggable, @unchecked Sendable { playerNodes.connect(to: engine, node: engine.mainMixerNode, format: outputFormat) + // Request + #if os(iOS) || os(visionOS) || os(tvOS) + AudioManager.shared.audioSession.set(requirement: .playbackOnly, for: sessionRequirementId) + #endif + try engine.start() log("Audio engine started") } @@ -55,6 +63,10 @@ public class SoundPlayer: Loggable, @unchecked Sendable { playerNodes.stop() engine.stop() + #if os(iOS) || os(visionOS) || os(tvOS) + AudioManager.shared.audioSession.set(requirement: .none, for: sessionRequirementId) + #endif + log("Audio engine stopped") }