Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions Sources/LiveKit/Audio/AudioSessionEngineObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand All @@ -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()

Expand All @@ -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...")
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
190 changes: 190 additions & 0 deletions Sources/LiveKit/Audio/SoundPlayer.swift
Original file line number Diff line number Diff line change
@@ -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<State>

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
}
}
4 changes: 2 additions & 2 deletions Sources/LiveKit/Support/Audio/AudioConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/LiveKit/Support/AudioPlayerRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
Loading