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 new file mode 100644 index 000000000..56216890e --- /dev/null +++ b/Sources/LiveKit/Audio/SoundPlayer.swift @@ -0,0 +1,190 @@ +/* + * 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 + + // Session requirement id for this object + private let sessionRequirementId = UUID() + + 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) + + // Request + #if os(iOS) || os(visionOS) || os(tvOS) + AudioManager.shared.audioSession.set(requirement: .playbackOnly, for: sessionRequirementId) + #endif + + 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() + + #if os(iOS) || os(visionOS) || os(tvOS) + AudioManager.shared.audioSession.set(requirement: .none, for: sessionRequirementId) + #endif + + 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()