Skip to content

iOS screen share audio #576

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

Merged
merged 65 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
eed85f9
Create value type wrapper around `CFHTTPMessage`
ladvoc Jan 22, 2025
fd592db
Create `BroadcastImageCodec` to isolate encoding functionality
ladvoc Jan 22, 2025
ebaee5c
Create `BroadcastSampleEncoder`
ladvoc Jan 22, 2025
e223f5d
Decouple encoding from transport on broadcast extension side
ladvoc Jan 22, 2025
85f1b3d
Add support for decoding in `BroadcastImageCodec`
ladvoc Jan 22, 2025
68f3d45
Move single constant into type definition
ladvoc Jan 22, 2025
002f835
Define `BroadcastSample` enum and implement its decoder
ladvoc Jan 23, 2025
d8c3f9a
Implement `HTTPMessageReader`
ladvoc Jan 23, 2025
fd4d65d
Integrate new types
ladvoc Jan 23, 2025
e2beb05
Rename file
ladvoc Jan 23, 2025
8deb4df
Fully decouple networking component
ladvoc Jan 24, 2025
7b25d67
Rename methods
ladvoc Jan 24, 2025
eb1fc39
Create and integrate `BroadcastSampleReceiver`
ladvoc Jan 24, 2025
fddf1ed
Create `SocketPath` struct
ladvoc Jan 28, 2025
caa6923
Create `IPCChannel` abstraction
ladvoc Jan 29, 2025
c8ac5ba
Overhaul broadcast IPC
ladvoc Jan 29, 2025
b093eea
Relocate file
ladvoc Jan 29, 2025
e02d487
Merge remote-tracking branch 'upstream/main' into broadcast-ipc
ladvoc Jan 29, 2025
1b88945
Remove print
ladvoc Jan 29, 2025
e8a1036
Fix closure bugs
ladvoc Jan 29, 2025
e32ae5d
Refactor test
ladvoc Jan 29, 2025
2d7a8b8
Only compile broadcast components for iOS
ladvoc Jan 31, 2025
179cf31
Fix compilation errors with older Swift versions
ladvoc Jan 31, 2025
39224a3
Implement rate control
ladvoc Feb 3, 2025
a779853
Merge remote-tracking branch 'upstream/main' into broadcast-ipc
ladvoc Feb 3, 2025
7c4f8ea
Run SwiftFormat
ladvoc Feb 3, 2025
3205da2
Merge remote-tracking branch 'upstream/main' into broadcast-ipc
ladvoc Feb 3, 2025
6183979
Merge remote-tracking branch 'upstream/main' into broadcast-ipc
ladvoc Feb 4, 2025
97c227e
Begin implementation
ladvoc Feb 4, 2025
9559e65
Refactor
ladvoc Feb 5, 2025
2054862
Merge branch 'broadcast-ipc' into broadcast-audio
ladvoc Feb 5, 2025
38dfc7c
Handle app audio samples
ladvoc Feb 5, 2025
b794180
Improve tests
ladvoc Feb 5, 2025
06223de
Add support for audio demand
ladvoc Feb 5, 2025
c1be667
Merge specific files from hiroshi/mac-screenshare-audio
ladvoc Feb 5, 2025
0debe63
Schedule incoming audio buffers
ladvoc Feb 6, 2025
997a680
Receive audio samples only when audio is enabled
ladvoc Feb 6, 2025
8e5a384
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 6, 2025
cae4156
Nanpa
ladvoc Feb 5, 2025
6dd423d
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 10, 2025
19794b7
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 12, 2025
1dc4fbd
Remove import
ladvoc Feb 12, 2025
c6abe24
Integrate with app audio node
ladvoc Feb 12, 2025
648eaff
Define ReplayKit app audio format
ladvoc Feb 13, 2025
d18e85f
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 13, 2025
d886ac4
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 14, 2025
1275e1a
Implement
hiroshihorie Feb 20, 2025
7b4f3ce
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 20, 2025
474b589
Remove unneeded file
ladvoc Feb 20, 2025
2aae374
Merge remote-tracking branch 'upstream/hiroshi/dynamic-app-audio-form…
ladvoc Feb 20, 2025
2564f27
Remove hardcoded ReplayKit audio format
ladvoc Feb 20, 2025
380fb63
Update audio capture
ladvoc Feb 20, 2025
65cf59a
Fix cracking issue
hiroshihorie Feb 21, 2025
c8daba1
Fix tests
hiroshihorie Feb 21, 2025
e149657
Fix missing UnsafeMutableAudioBufferListPointer
hiroshihorie Feb 21, 2025
8b5c647
Merge remote-tracking branch 'upstream/hiroshi/dynamic-app-audio-form…
ladvoc Feb 21, 2025
5d8d2d8
Never drop audio samples
ladvoc Feb 21, 2025
b774832
Apply swiftformat
ladvoc Feb 21, 2025
8b1d546
Document use of app audio
ladvoc Feb 21, 2025
a370199
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 22, 2025
ae1f217
Apply swiftformat
ladvoc Feb 22, 2025
536d520
Remove duplicate source file
ladvoc Feb 23, 2025
df169cc
Fix compile error for older Swift versions
ladvoc Feb 23, 2025
0374695
Merge remote-tracking branch 'upstream/main' into broadcast-audio
ladvoc Feb 23, 2025
cf5f00e
Fix error introduced by SwiftFormat
ladvoc Feb 23, 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
1 change: 1 addition & 0 deletions .nanpa/ios-screen-share-audio.kdl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="added" "Add support for screen share audio on iOS when using a broadcast extension"
28 changes: 27 additions & 1 deletion Docs/ios-screen-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ LiveKit integrates with [ReplayKit](https://developer.apple.com/documentation/re

## In-app Capture

By default, LiveKit uses the In-app Capture mode, which requires no additional configuration. In this mode, when screen sharing is enabled, the system prompts the user with a screen recording permission dialog. Once granted, a screen share track is published. The user only needs to grant permission once per app execution.
By default, LiveKit uses the In-app Capture mode, which requires no additional configuration. In this mode, when screen sharing is enabled, the system prompts the user with a screen recording permission dialog. Once granted, a screen share track is published. The user only needs to grant permission once per app execution. Application audio is not supported with the In-App Capture mode.

<center>
<figure>
Expand Down Expand Up @@ -88,6 +88,32 @@ try await room.localParticipant.setScreenShare(enabled: true)

<small>Note: When using broadcast capture, custom capture options must be set as room defaults rather than passed when enabling screen share with `set(source:enabled:captureOptions:publishOptions:)`.</small>

### Application Audio

When using Broadcast Capture, you can capture app audio even when the user navigates away from your app. When enabled, the captured app audio is mixed with the local participant's microphone track. To enable this feature, set the default screen share capture options when connecting to the room:


```swift
let roomOptions = RoomOptions(
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
appAudio: true // enables capture of app audio
)
)

// Option 1: Using SwiftUI RoomScope component
RoomScope(url: wsURL, token: token, enableMicrophone: true, roomOptions: roomOptions) {
// your components here
}

// Option 2: Using Room object directly
try await room.connect(
url: wsURL,
token: token,
roomOptions: roomOptions
)
try await room.localParticipant.setMicrophone(enabled: true)
```

### Troubleshooting

While running your app in a debug session in Xcode, check the debug console for errors and use the Console app to inspect logs from the broadcast extension:
Expand Down
24 changes: 16 additions & 8 deletions Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal import LiveKitWebRTC
#endif

class BroadcastScreenCapturer: BufferCapturer {
private let appAudio: Bool
private var receiver: BroadcastReceiver?

override func startCapture() async throws -> Bool {
Expand All @@ -52,30 +53,32 @@ class BroadcastScreenCapturer: BufferCapturer {
}

private func createReceiver() -> Bool {
guard receiver == nil else {
return false
}
guard let socketPath = BroadcastBundleInfo.socketPath else {
logger.error("Bundle settings improperly configured for screen capture")
return false
}
Task { [weak self] in
guard let self else { return }
do {
let receiver = try await BroadcastReceiver(socketPath: socketPath)
logger.debug("Broadcast receiver connected")
self?.receiver = receiver
self.receiver = receiver

if self.appAudio {
try await receiver.enableAudio()
}

for try await sample in receiver.incomingSamples {
switch sample {
case let .image(imageBuffer, rotation):
self?.capture(imageBuffer, rotation: rotation)
case let .image(buffer, rotation): self.capture(buffer, rotation: rotation)
case let .audio(buffer): AudioManager.shared.mixer.capture(appAudio: buffer)
}
}
logger.debug("Broadcast receiver closed")
} catch {
logger.error("Broadcast receiver error: \(error)")
}
_ = try? await self?.stopCapture()
_ = try? await self.stopCapture()
}
return true
}
Expand All @@ -88,6 +91,11 @@ class BroadcastScreenCapturer: BufferCapturer {
receiver?.close()
return true
}

init(delegate: LKRTCVideoCapturerDelegate, options: ScreenShareCaptureOptions) {
appAudio = options.appAudio
super.init(delegate: delegate, options: BufferCaptureOptions(from: options))
}
}

public extension LocalVideoTrack {
Expand All @@ -98,7 +106,7 @@ public extension LocalVideoTrack {
reportStatistics: Bool = false) -> LocalVideoTrack
{
let videoSource = RTC.createVideoSource(forScreenShare: true)
let capturer = BroadcastScreenCapturer(delegate: videoSource, options: BufferCaptureOptions(from: options))
let capturer = BroadcastScreenCapturer(delegate: videoSource, options: options)
return LocalVideoTrack(
name: name,
source: source,
Expand Down
125 changes: 125 additions & 0 deletions Sources/LiveKit/Broadcast/IPC/BroadcastAudioCodec.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* 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.
*/

#if os(iOS)

import AVFoundation

/// Encode and decode audio samples for transport.
struct BroadcastAudioCodec {
struct Metadata: Codable {
let sampleCount: Int32
let description: AudioStreamBasicDescription
}

enum Error: Swift.Error {
case encodingFailed
case decodingFailed
}

func encode(_ audioBuffer: CMSampleBuffer) throws -> (Metadata, Data) {
guard let formatDescription = audioBuffer.formatDescription,
let basicDescription = formatDescription.audioStreamBasicDescription,
let blockBuffer = audioBuffer.dataBuffer
else {
throw Error.encodingFailed
}

var count = 0
var dataPointer: UnsafeMutablePointer<Int8>?

guard CMBlockBufferGetDataPointer(
blockBuffer,
atOffset: 0,
lengthAtOffsetOut: nil,
totalLengthOut: &count,
dataPointerOut: &dataPointer
) == kCMBlockBufferNoErr, let dataPointer else {
throw Error.encodingFailed
}

let data = Data(bytes: dataPointer, count: count)
let metadata = Metadata(
sampleCount: Int32(audioBuffer.numSamples),
description: basicDescription
)
return (metadata, data)
}

func decode(_ encodedData: Data, with metadata: Metadata) throws -> AVAudioPCMBuffer {
guard !encodedData.isEmpty else {
throw Error.decodingFailed
}

var description = metadata.description
guard let format = AVAudioFormat(streamDescription: &description) else {
throw Error.decodingFailed
}

let sampleCount = AVAudioFrameCount(metadata.sampleCount)
guard let pcmBuffer = AVAudioPCMBuffer(
pcmFormat: format,
frameCapacity: sampleCount
) else {
throw Error.decodingFailed
}
pcmBuffer.frameLength = sampleCount

guard format.isInterleaved else {
throw Error.decodingFailed
}

guard let mData = pcmBuffer.audioBufferList.pointee.mBuffers.mData else {
throw Error.decodingFailed
}
encodedData.copyBytes(
to: mData.assumingMemoryBound(to: UInt8.self),
count: encodedData.count
)
return pcmBuffer
}
}

extension AudioStreamBasicDescription: Codable {
public func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(mSampleRate)
try container.encode(mFormatID)
try container.encode(mFormatFlags)
try container.encode(mBytesPerPacket)
try container.encode(mFramesPerPacket)
try container.encode(mBytesPerFrame)
try container.encode(mChannelsPerFrame)
try container.encode(mBitsPerChannel)
}

public init(from decoder: any Decoder) throws {
var container = try decoder.unkeyedContainer()
try self.init(
mSampleRate: container.decode(Float64.self),
mFormatID: container.decode(AudioFormatID.self),
mFormatFlags: container.decode(AudioFormatFlags.self),
mBytesPerPacket: container.decode(UInt32.self),
mFramesPerPacket: container.decode(UInt32.self),
mBytesPerFrame: container.decode(UInt32.self),
mChannelsPerFrame: container.decode(UInt32.self),
mBitsPerChannel: container.decode(UInt32.self),
mReserved: 0 // as per documentation
)
}
}

#endif
6 changes: 6 additions & 0 deletions Sources/LiveKit/Broadcast/IPC/BroadcastIPCHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
enum BroadcastIPCHeader: Codable {
/// Image sample sent by uploader.
case image(BroadcastImageCodec.Metadata, VideoRotation)

/// Audio sample sent by uploader.
case audio(BroadcastAudioCodec.Metadata)

/// Request sent by receiver to set audio demand.
case wantsAudio(Bool)
}

#endif
38 changes: 29 additions & 9 deletions Sources/LiveKit/Broadcast/IPC/BroadcastReceiver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@

#if os(iOS)

import AVFoundation
import CoreImage
import Foundation

/// Receives broadcast samples from another process.
final class BroadcastReceiver: Sendable {
/// Sample received from the other process with associated metadata.
enum IncomingSample {
case image(CVImageBuffer, VideoRotation)
case audio(AVAudioPCMBuffer)
}

enum Error: Swift.Error {
Expand All @@ -49,18 +50,27 @@ final class BroadcastReceiver: Sendable {

struct AsyncSampleSequence: AsyncSequence, AsyncIteratorProtocol {
fileprivate let upstream: IPCChannel.AsyncMessageSequence<BroadcastIPCHeader>

private let imageCodec = BroadcastImageCodec()
private let audioCodec = BroadcastAudioCodec()

func next() async throws -> IncomingSample? {
guard let (header, payload) = try await upstream.next() else {
return nil
}
switch header {
case let .image(metadata, rotation):
guard let payload else { throw Error.missingSampleData }
let imageBuffer = try imageCodec.decode(payload, with: metadata)
return IncomingSample.image(imageBuffer, rotation)
while let (header, payload) = try await upstream.next(), let payload {
switch header {
case let .image(metadata, rotation):
let imageBuffer = try imageCodec.decode(payload, with: metadata)
return IncomingSample.image(imageBuffer, rotation)

case let .audio(metadata):
let audioBuffer = try audioCodec.decode(payload, with: metadata)
return IncomingSample.audio(audioBuffer)

default:
logger.debug("Unhandled incoming message: \(header)")
continue
}
}
return nil
}

func makeAsyncIterator() -> Self { self }
Expand All @@ -74,6 +84,16 @@ final class BroadcastReceiver: Sendable {
var incomingSamples: AsyncSampleSequence {
AsyncSampleSequence(upstream: channel.incomingMessages(BroadcastIPCHeader.self))
}

/// Tells the uploader to begin sending audio samples.
func enableAudio() async throws {
try await channel.send(header: BroadcastIPCHeader.wantsAudio(true))
}

/// Tells the uploader to stop sending audio samples.
func disableAudio() async throws {
try await channel.send(header: BroadcastIPCHeader.wantsAudio(false))
}
}

#endif
23 changes: 23 additions & 0 deletions Sources/LiveKit/Broadcast/IPC/BroadcastUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import ReplayKit
/// Uploads broadcast samples to another process.
final class BroadcastUploader: Sendable {
private let channel: IPCChannel

private let imageCodec = BroadcastImageCodec()
private let audioCodec = BroadcastAudioCodec()

private struct State {
var isUploadingImage = false
var shouldUploadAudio = false
}

private let state = StateSync(State())
Expand All @@ -38,6 +41,7 @@ final class BroadcastUploader: Sendable {
/// Creates an uploader with an open connection to another process.
init(socketPath: SocketPath) async throws {
channel = try await IPCChannel(connectingTo: socketPath)
Task { try await handleIncomingMessages() }
}

/// Whether or not the connection to the receiver has been closed.
Expand Down Expand Up @@ -76,10 +80,29 @@ final class BroadcastUploader: Sendable {
state.mutate { $0.isUploadingImage = false }
throw error
}
case .audioApp:
guard state.shouldUploadAudio else { return }
let (metadata, audioData) = try audioCodec.encode(sampleBuffer)
Task {
let header = BroadcastIPCHeader.audio(metadata)
try await channel.send(header: header, payload: audioData)
}
default:
throw Error.unsupportedSample
}
}

private func handleIncomingMessages() async throws {
for try await (header, _) in channel.incomingMessages(BroadcastIPCHeader.self) {
switch header {
case let .wantsAudio(wantsAudio):
state.mutate { $0.shouldUploadAudio = wantsAudio }
default:
logger.debug("Unhandled incoming message: \(header)")
continue
}
}
}
}

private extension CMSampleBuffer {
Expand Down
Loading
Loading