Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

macOS screen share audio #561

Draft
wants to merge 59 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
622caa9
Tests
hiroshihorie Jan 6, 2025
9908d8e
Update render test
hiroshihorie Jan 6, 2025
3b9bbb3
Backward compatible session config
hiroshihorie Jan 8, 2025
b1f3ae1
.mixWithOthers by default
hiroshihorie Jan 8, 2025
b1871da
Ducking config
hiroshihorie Jan 8, 2025
be593c1
Use 125.6422.12-exp.2
hiroshihorie Jan 8, 2025
89c084c
Muted speech activity
hiroshihorie Jan 8, 2025
4d3b752
Merge branch 'main' into hiroshi/adm-audioengine2
hiroshihorie Jan 13, 2025
282cbc7
Update node config methods
hiroshihorie Jan 13, 2025
8f70540
Move audio buffer
hiroshihorie Jan 13, 2025
92e3406
Update AudioManager.swift
hiroshihorie Jan 13, 2025
4b77f84
Use 125.6422.12-exp.3
hiroshihorie Jan 14, 2025
7cd4f29
Fix tests
hiroshihorie Jan 14, 2025
5e217e1
Merge branch 'main' into hiroshi/adm-audioengine2
hiroshihorie Jan 14, 2025
130e1d2
Fix tests
hiroshihorie Jan 14, 2025
ae16a3c
Merge branch 'hiroshi/adm-audioengine2' of https://github.com/livekit…
hiroshihorie Jan 14, 2025
874b3a4
AudioDuckingLevel type
hiroshihorie Jan 14, 2025
49c91ef
Use 125.6422.12-exp.4
hiroshihorie Jan 14, 2025
4b84621
Fix Xcode 14.2
hiroshihorie Jan 14, 2025
5a585a3
Change session config timing
hiroshihorie Jan 16, 2025
a0103ad
Update state tests
hiroshihorie Jan 20, 2025
256b42a
P1
hiroshihorie Jan 20, 2025
8c60160
Merge branch 'main' into hiroshi/adm-audioengine2
hiroshihorie Jan 20, 2025
68f77f3
Merge branch 'hiroshi/adm-audioengine2' of https://github.com/livekit…
hiroshihorie Jan 20, 2025
a987f77
Merge branch 'main' into hiroshi/adm-audioengine2
hiroshihorie Jan 21, 2025
81ed8c8
Merge branch 'hiroshi/adm-audioengine2' of https://github.com/livekit…
hiroshihorie Jan 21, 2025
3c9c0dd
Chained engine observer
hiroshihorie Jan 22, 2025
7e48b7b
lib 125.6422.12-exp.5
hiroshihorie Jan 22, 2025
d3deb72
Update test
hiroshihorie Jan 22, 2025
aa8977b
Fix test
hiroshihorie Jan 22, 2025
82323b4
Update manual render test
hiroshihorie Jan 23, 2025
d846a00
no processing option for capture options
hiroshihorie Jan 23, 2025
b6b51ef
pcm buffer helper
hiroshihorie Jan 23, 2025
c44b9e5
Implement
hiroshihorie Jan 23, 2025
0e05ca0
Runtime agc / bypass vp toggle
hiroshihorie Jan 28, 2025
8b35ab6
Runtime bypass is valid now
hiroshihorie Jan 28, 2025
a28b9c8
Use 125.6422.12-exp.6
hiroshihorie Jan 28, 2025
9c18ff5
Fix manual render test
hiroshihorie Jan 28, 2025
89e0620
Update manual render tests
hiroshihorie Jan 29, 2025
f88456e
Use 125.6422.12
hiroshihorie Jan 29, 2025
5ad8bd5
make state sync sendable
hiroshihorie Jan 29, 2025
c5ee683
Backward compatibility for custom config func
hiroshihorie Jan 29, 2025
38c563b
Strip unused code
hiroshihorie Jan 29, 2025
ce79ac6
Refactoring
hiroshihorie Jan 29, 2025
e57a5ad
Fix tests
hiroshihorie Jan 29, 2025
107e239
Rename onMutedSpeechActivity
hiroshihorie Jan 29, 2025
6820281
Squashed commit of the following:
hiroshihorie Jan 29, 2025
77c7cfe
Use 125.6422.13
hiroshihorie Jan 29, 2025
d44a090
Disable RTC audio options by default
hiroshihorie Jan 29, 2025
3c6d6b5
Fix apm tests
hiroshihorie Jan 29, 2025
75ed189
config tests
hiroshihorie Jan 30, 2025
58318ea
Update default config logic
hiroshihorie Jan 30, 2025
3a595db
Use 125.6422.14
hiroshihorie Jan 30, 2025
c2441e6
Update tests
hiroshihorie Jan 30, 2025
abd1a36
Merge branch 'hiroshi/adm-audioengine2' into hiroshi/mac-screenshare-…
hiroshihorie Jan 30, 2025
59171f6
Merge branch 'main' into hiroshi/mac-screenshare-audio
hiroshihorie Feb 2, 2025
18056e7
Ports
hiroshihorie Feb 2, 2025
e197d02
Merge branch 'main' into hiroshi/mac-screenshare-audio
hiroshihorie Feb 4, 2025
d690238
Fix
hiroshihorie Feb 4, 2025
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
36 changes: 36 additions & 0 deletions Sources/LiveKit/Audio/AudioPortManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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.
*/

import AVFAudio

public class AudioPortManager {
struct State {
var ports: [String: AVAudioPlayerNode] = [:]
}

let _state = StateSync(State())

func node(for portId: String) -> AVAudioPlayerNode {
_state.mutate {
if let r = $0.ports[portId] {
return r
}
let r = AVAudioPlayerNode()
$0.ports[portId] = r
return r
}
}
}
104 changes: 104 additions & 0 deletions Sources/LiveKit/Audio/DefaultAudioInputObserver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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 AVFoundation

#if swift(>=5.9)
internal import LiveKitWebRTC
#else
@_implementationOnly import LiveKitWebRTC
#endif

enum AudioPort: Sendable {
case defaultInput
case custom(AVAudioPlayerNode)
}

public final class DefaultAudioInputObserver: AudioEngineObserver, Loggable {
// <AVAudioFormat 0x600003055180: 2 ch, 48000 Hz, Float32, deinterleaved>
let playerNodeFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32,
sampleRate: 48000,
channels: 2,
interleaved: false)

struct State {
var next: (any AudioEngineObserver)?
public let playerNode: AVAudioPlayerNode
public let playerMixerNode = AVAudioMixerNode()
public let micMixerNode = AVAudioMixerNode()
}

let _state: StateSync<State>

public var next: (any AudioEngineObserver)? {
get { _state.next }
set { _state.mutate { $0.next = newValue } }
}

public init(playerNode: AVAudioPlayerNode) {
_state = StateSync(State(playerNode: playerNode))
}

public func setNext(_ handler: any AudioEngineObserver) {
next = handler
}

public func engineDidCreate(_ engine: AVAudioEngine) {
let (playerNode, playerMixerNode, micMixerNode) = _state.read {
($0.playerNode, $0.playerMixerNode, $0.micMixerNode)
}

engine.attach(playerNode)
engine.attach(playerMixerNode)
engine.attach(micMixerNode)

micMixerNode.outputVolume = 0.0

// Invoke next
next?.engineDidCreate(engine)
}

public func engineWillRelease(_ engine: AVAudioEngine) {
// Invoke next
next?.engineWillRelease(engine)

let (playerNode, playerMixerNode, micMixerNode) = _state.read {
($0.playerNode, $0.playerMixerNode, $0.micMixerNode)
}

engine.detach(playerNode)
engine.detach(playerMixerNode)
engine.detach(micMixerNode)
}

public func engineWillConnectInput(_ engine: AVAudioEngine, src: AVAudioNode?, dst: AVAudioNode, format: AVAudioFormat) -> Bool {
let (playerNode, playerMixerNode, micMixerNode) = _state.read {
($0.playerNode, $0.playerMixerNode, $0.micMixerNode)
}

// inputPlayer -> playerMixer -> mainMixer
engine.connect(playerNode, to: playerMixerNode, format: playerNodeFormat)
engine.connect(playerMixerNode, to: dst, format: format)

if let src {
// mic -> micMixer -> mainMixer
engine.connect(src, to: micMixerNode, format: format)
engine.connect(micMixerNode, to: dst, format: format)
}

return true
}
}
11 changes: 11 additions & 0 deletions Sources/LiveKit/Convenience/AudioProcessing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ public extension LKAudioBuffer {
}
}

public extension CMSampleBuffer {
func toAVAudioPCMBuffer() -> AVAudioPCMBuffer? {
let format = AVAudioFormat(cmAudioFormatDescription: formatDescription!)
let numSamples = AVAudioFrameCount(numSamples)
let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: numSamples)!
pcmBuffer.frameLength = numSamples
CMSampleBufferCopyPCMDataIntoAudioBufferList(self, at: 0, frameCount: Int32(numSamples), into: pcmBuffer.mutableAudioBufferList)
return pcmBuffer
}
}

public extension AVAudioPCMBuffer {
/// Computes Peak and Linear Scale RMS Value (Average) for all channels.
func audioLevels() -> [AudioLevel] {
Expand Down
8 changes: 6 additions & 2 deletions Sources/LiveKit/Track/AudioManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@
public var localTracksCount: Int = 0
public var remoteTracksCount: Int = 0
public var isSpeakerOutputPreferred: Bool = true
public var customConfigureFunc: ConfigureAudioSessionFunc?

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, iOS Simulator,OS=17.5,name=iPhone 15 Pro)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, iOS Simulator,OS=17.5,name=iPhone 15 Pro)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, macOS,variant=Mac Catalyst)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, macOS,variant=Mac Catalyst)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-15, 16.2, macOS,variant=Mac Catalyst)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-15, 16.2, iOS Simulator,OS=18.1,name=iPhone 16 Pro)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, tvOS Simulator,name=Apple TV)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, tvOS Simulator,name=Apple TV)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, visionOS Simulator,name=Apple Vision Pro)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, visionOS Simulator,name=Apple Vision Pro)

'ConfigureAudioSessionFunc' is deprecated
public var sessionConfiguration: AudioSessionConfiguration?

public var trackState: TrackState {

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, iOS Simulator,OS=17.5,name=iPhone 15 Pro)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, macOS,variant=Mac Catalyst)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, macOS,variant=Mac Catalyst)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-15, 16.2, macOS,variant=Mac Catalyst)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-15, 16.2, iOS Simulator,OS=18.1,name=iPhone 16 Pro)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, tvOS Simulator,name=Apple TV)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, tvOS Simulator,name=Apple TV)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, visionOS Simulator,name=Apple Vision Pro)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Track/AudioManager.swift

View workflow job for this annotation

GitHub Actions / test (macos-14, 15.4, visionOS Simulator,name=Apple Vision Pro)

'TrackState' is deprecated
switch (localTracksCount > 0, remoteTracksCount > 0) {
case (true, false): return .localOnly
case (false, true): return .remoteOnly
Expand Down Expand Up @@ -255,6 +255,8 @@
_state.mutate { $0.engineObservers = engineObservers }
}

public let ports = AudioPortManager()

// MARK: - For testing

var isPlayoutInitialized: Bool {
Expand Down Expand Up @@ -305,9 +307,11 @@

init() {
#if os(iOS) || os(visionOS) || os(tvOS)
let engineObservers: [any AudioEngineObserver] = [DefaultAudioSessionObserver()]
let playerNode = ports.node(for: "default")
let engineObservers: [any AudioEngineObserver] = [DefaultAudioSessionObserver(), DefaultAudioInputObserver(playerNode: playerNode)]
#else
let engineObservers: [any AudioEngineObserver] = []
let playerNode = ports.node(for: "default")
let engineObservers: [any AudioEngineObserver] = [DefaultAudioInputObserver(playerNode: playerNode)]
#endif
_state = StateSync(State(engineObservers: engineObservers))
_admDelegateAdapter.audioManager = self
Expand Down
80 changes: 48 additions & 32 deletions Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,16 @@ public class MacOSScreenCapturer: VideoCapturer {
configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
configuration.showsCursor = options.showCursor

if #available(macOS 13.0, *) {
configuration.capturesAudio = options.appAudio
}

// Why does SCStream hold strong reference to delegate?
let stream = SCStream(filter: filter, configuration: configuration, delegate: nil)
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: nil)
if #available(macOS 13.0, *) {
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: nil)
}
try await stream.startCapture()

_screenCapturerState.mutate { $0.scStream = stream }
Expand Down Expand Up @@ -200,7 +207,6 @@ extension MacOSScreenCapturer {
@available(macOS 12.3, *)
extension MacOSScreenCapturer: SCStreamOutput {
public func stream(_: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,

of outputType: SCStreamOutputType)
{
guard case .started = captureState else {
Expand All @@ -211,40 +217,50 @@ extension MacOSScreenCapturer: SCStreamOutput {
// Return early if the sample buffer is invalid.
guard sampleBuffer.isValid else { return }

guard case .screen = outputType else { return }

// Retrieve the array of metadata attachments from the sample buffer.
guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer,
createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
let attachments = attachmentsArray.first else { return }

// Validate the status of the frame. If it isn't `.complete`, return nil.
guard let statusRawValue = attachments[SCStreamFrameInfo.status] as? Int,
let status = SCFrameStatus(rawValue: statusRawValue),
status == .complete else { return }

// Retrieve the content rectangle, scale, and scale factor.
guard let contentRectDict = attachments[.contentRect],
let contentRect = CGRect(dictionaryRepresentation: contentRectDict as! CFDictionary),
// let contentScale = attachments[.contentScale] as? CGFloat,
let scaleFactor = attachments[.scaleFactor] as? CGFloat else { return }

// Schedule resend timer
let newTimer = Task.detached(priority: .utility) { [weak self] in
while true {
try? await Task.sleep(nanoseconds: UInt64(1 * 1_000_000_000))
if Task.isCancelled { break }
guard let self else { break }
try await self._capturePreviousFrame()
if case .audio = outputType {
if let pcm = sampleBuffer.toAVAudioPCMBuffer() {
let node = AudioManager.shared.ports.node(for: "default")
if let engine = node.engine, engine.isRunning {
node.scheduleBuffer(pcm)
if !node.isPlaying {
node.play()
}
}
}
} else if case .screen = outputType {
// Retrieve the array of metadata attachments from the sample buffer.
guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer,
createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
let attachments = attachmentsArray.first else { return }

// Validate the status of the frame. If it isn't `.complete`, return nil.
guard let statusRawValue = attachments[SCStreamFrameInfo.status] as? Int,
let status = SCFrameStatus(rawValue: statusRawValue),
status == .complete else { return }

// Retrieve the content rectangle, scale, and scale factor.
guard let contentRectDict = attachments[.contentRect],
let contentRect = CGRect(dictionaryRepresentation: contentRectDict as! CFDictionary),
// let contentScale = attachments[.contentScale] as? CGFloat,
let scaleFactor = attachments[.scaleFactor] as? CGFloat else { return }

// Schedule resend timer
let newTimer = Task.detached(priority: .utility) { [weak self] in
while true {
try? await Task.sleep(nanoseconds: UInt64(1 * 1_000_000_000))
if Task.isCancelled { break }
guard let self else { break }
try await self._capturePreviousFrame()
}
}
}

_screenCapturerState.mutate {
$0.resendTimer?.cancel()
$0.resendTimer = newTimer
}
_screenCapturerState.mutate {
$0.resendTimer?.cancel()
$0.resendTimer = newTimer
}

capture(sampleBuffer, contentRect: contentRect, scaleFactor: scaleFactor)
capture(sampleBuffer, contentRect: contentRect, scaleFactor: scaleFactor)
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/LiveKit/Types/Options/ScreenShareCaptureOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public final class ScreenShareCaptureOptions: NSObject, VideoCaptureOptions, Sen
@objc
public let showCursor: Bool

@objc
public let appAudio: Bool

/// Use broadcast extension for screen capture (iOS only).
///
/// If a broadcast extension has been properly configured, this defaults to `true`.
Expand All @@ -49,12 +52,14 @@ public final class ScreenShareCaptureOptions: NSObject, VideoCaptureOptions, Sen
public init(dimensions: Dimensions = .h1080_169,
fps: Int = 30,
showCursor: Bool = true,
appAudio: Bool = true,
useBroadcastExtension: Bool = defaultToBroadcastExtension,
includeCurrentApplication: Bool = false)
{
self.dimensions = dimensions
self.fps = fps
self.showCursor = showCursor
self.appAudio = appAudio
self.useBroadcastExtension = useBroadcastExtension
self.includeCurrentApplication = includeCurrentApplication
}
Expand All @@ -66,6 +71,7 @@ public final class ScreenShareCaptureOptions: NSObject, VideoCaptureOptions, Sen
return dimensions == other.dimensions &&
fps == other.fps &&
showCursor == other.showCursor &&
appAudio == other.appAudio &&
useBroadcastExtension == other.useBroadcastExtension &&
includeCurrentApplication == other.includeCurrentApplication
}
Expand All @@ -75,6 +81,7 @@ public final class ScreenShareCaptureOptions: NSObject, VideoCaptureOptions, Sen
hasher.combine(dimensions)
hasher.combine(fps)
hasher.combine(showCursor)
hasher.combine(appAudio)
hasher.combine(useBroadcastExtension)
hasher.combine(includeCurrentApplication)
return hasher.finalize()
Expand Down
Loading