Skip to content

Commit 4f2f5c2

Browse files
ladvochiroshihorie
andauthored
iOS screen share audio (#576)
This PR adds support for capturing application audio during screen share when using a broadcast extension. Note: PR #598 should be merged first. --------- Co-authored-by: Hiroshi Horie <[email protected]>
1 parent 8317d31 commit 4f2f5c2

File tree

8 files changed

+375
-18
lines changed

8 files changed

+375
-18
lines changed

.nanpa/ios-screen-share-audio.kdl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="added" "Add support for screen share audio on iOS when using a broadcast extension"

Docs/ios-screen-sharing.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ LiveKit integrates with [ReplayKit](https://developer.apple.com/documentation/re
77

88
## In-app Capture
99

10-
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.
10+
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.
1111

1212
<center>
1313
<figure>
@@ -88,6 +88,32 @@ try await room.localParticipant.setScreenShare(enabled: true)
8888

8989
<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>
9090

91+
### Application Audio
92+
93+
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:
94+
95+
96+
```swift
97+
let roomOptions = RoomOptions(
98+
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
99+
appAudio: true // enables capture of app audio
100+
)
101+
)
102+
103+
// Option 1: Using SwiftUI RoomScope component
104+
RoomScope(url: wsURL, token: token, enableMicrophone: true, roomOptions: roomOptions) {
105+
// your components here
106+
}
107+
108+
// Option 2: Using Room object directly
109+
try await room.connect(
110+
url: wsURL,
111+
token: token,
112+
roomOptions: roomOptions
113+
)
114+
try await room.localParticipant.setMicrophone(enabled: true)
115+
```
116+
91117
### Troubleshooting
92118

93119
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:

Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal import LiveKitWebRTC
2929
#endif
3030

3131
class BroadcastScreenCapturer: BufferCapturer {
32+
private let appAudio: Bool
3233
private var receiver: BroadcastReceiver?
3334

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

5455
private func createReceiver() -> Bool {
55-
guard receiver == nil else {
56-
return false
57-
}
5856
guard let socketPath = BroadcastBundleInfo.socketPath else {
5957
logger.error("Bundle settings improperly configured for screen capture")
6058
return false
6159
}
6260
Task { [weak self] in
61+
guard let self else { return }
6362
do {
6463
let receiver = try await BroadcastReceiver(socketPath: socketPath)
6564
logger.debug("Broadcast receiver connected")
66-
self?.receiver = receiver
65+
self.receiver = receiver
66+
67+
if self.appAudio {
68+
try await receiver.enableAudio()
69+
}
6770

6871
for try await sample in receiver.incomingSamples {
6972
switch sample {
70-
case let .image(imageBuffer, rotation):
71-
self?.capture(imageBuffer, rotation: rotation)
73+
case let .image(buffer, rotation): self.capture(buffer, rotation: rotation)
74+
case let .audio(buffer): AudioManager.shared.mixer.capture(appAudio: buffer)
7275
}
7376
}
7477
logger.debug("Broadcast receiver closed")
7578
} catch {
7679
logger.error("Broadcast receiver error: \(error)")
7780
}
78-
_ = try? await self?.stopCapture()
81+
_ = try? await self.stopCapture()
7982
}
8083
return true
8184
}
@@ -88,6 +91,11 @@ class BroadcastScreenCapturer: BufferCapturer {
8891
receiver?.close()
8992
return true
9093
}
94+
95+
init(delegate: LKRTCVideoCapturerDelegate, options: ScreenShareCaptureOptions) {
96+
appAudio = options.appAudio
97+
super.init(delegate: delegate, options: BufferCaptureOptions(from: options))
98+
}
9199
}
92100

93101
public extension LocalVideoTrack {
@@ -98,7 +106,7 @@ public extension LocalVideoTrack {
98106
reportStatistics: Bool = false) -> LocalVideoTrack
99107
{
100108
let videoSource = RTC.createVideoSource(forScreenShare: true)
101-
let capturer = BroadcastScreenCapturer(delegate: videoSource, options: BufferCaptureOptions(from: options))
109+
let capturer = BroadcastScreenCapturer(delegate: videoSource, options: options)
102110
return LocalVideoTrack(
103111
name: name,
104112
source: source,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2025 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#if os(iOS)
18+
19+
import AVFoundation
20+
21+
/// Encode and decode audio samples for transport.
22+
struct BroadcastAudioCodec {
23+
struct Metadata: Codable {
24+
let sampleCount: Int32
25+
let description: AudioStreamBasicDescription
26+
}
27+
28+
enum Error: Swift.Error {
29+
case encodingFailed
30+
case decodingFailed
31+
}
32+
33+
func encode(_ audioBuffer: CMSampleBuffer) throws -> (Metadata, Data) {
34+
guard let formatDescription = audioBuffer.formatDescription,
35+
let basicDescription = formatDescription.audioStreamBasicDescription,
36+
let blockBuffer = audioBuffer.dataBuffer
37+
else {
38+
throw Error.encodingFailed
39+
}
40+
41+
var count = 0
42+
var dataPointer: UnsafeMutablePointer<Int8>?
43+
44+
guard CMBlockBufferGetDataPointer(
45+
blockBuffer,
46+
atOffset: 0,
47+
lengthAtOffsetOut: nil,
48+
totalLengthOut: &count,
49+
dataPointerOut: &dataPointer
50+
) == kCMBlockBufferNoErr, let dataPointer else {
51+
throw Error.encodingFailed
52+
}
53+
54+
let data = Data(bytes: dataPointer, count: count)
55+
let metadata = Metadata(
56+
sampleCount: Int32(audioBuffer.numSamples),
57+
description: basicDescription
58+
)
59+
return (metadata, data)
60+
}
61+
62+
func decode(_ encodedData: Data, with metadata: Metadata) throws -> AVAudioPCMBuffer {
63+
guard !encodedData.isEmpty else {
64+
throw Error.decodingFailed
65+
}
66+
67+
var description = metadata.description
68+
guard let format = AVAudioFormat(streamDescription: &description) else {
69+
throw Error.decodingFailed
70+
}
71+
72+
let sampleCount = AVAudioFrameCount(metadata.sampleCount)
73+
guard let pcmBuffer = AVAudioPCMBuffer(
74+
pcmFormat: format,
75+
frameCapacity: sampleCount
76+
) else {
77+
throw Error.decodingFailed
78+
}
79+
pcmBuffer.frameLength = sampleCount
80+
81+
guard format.isInterleaved else {
82+
throw Error.decodingFailed
83+
}
84+
85+
guard let mData = pcmBuffer.audioBufferList.pointee.mBuffers.mData else {
86+
throw Error.decodingFailed
87+
}
88+
encodedData.copyBytes(
89+
to: mData.assumingMemoryBound(to: UInt8.self),
90+
count: encodedData.count
91+
)
92+
return pcmBuffer
93+
}
94+
}
95+
96+
extension AudioStreamBasicDescription: Codable {
97+
public func encode(to encoder: any Encoder) throws {
98+
var container = encoder.unkeyedContainer()
99+
try container.encode(mSampleRate)
100+
try container.encode(mFormatID)
101+
try container.encode(mFormatFlags)
102+
try container.encode(mBytesPerPacket)
103+
try container.encode(mFramesPerPacket)
104+
try container.encode(mBytesPerFrame)
105+
try container.encode(mChannelsPerFrame)
106+
try container.encode(mBitsPerChannel)
107+
}
108+
109+
public init(from decoder: any Decoder) throws {
110+
var container = try decoder.unkeyedContainer()
111+
try self.init(
112+
mSampleRate: container.decode(Float64.self),
113+
mFormatID: container.decode(AudioFormatID.self),
114+
mFormatFlags: container.decode(AudioFormatFlags.self),
115+
mBytesPerPacket: container.decode(UInt32.self),
116+
mFramesPerPacket: container.decode(UInt32.self),
117+
mBytesPerFrame: container.decode(UInt32.self),
118+
mChannelsPerFrame: container.decode(UInt32.self),
119+
mBitsPerChannel: container.decode(UInt32.self),
120+
mReserved: 0 // as per documentation
121+
)
122+
}
123+
}
124+
125+
#endif

Sources/LiveKit/Broadcast/IPC/BroadcastIPCHeader.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
enum BroadcastIPCHeader: Codable {
2121
/// Image sample sent by uploader.
2222
case image(BroadcastImageCodec.Metadata, VideoRotation)
23+
24+
/// Audio sample sent by uploader.
25+
case audio(BroadcastAudioCodec.Metadata)
26+
27+
/// Request sent by receiver to set audio demand.
28+
case wantsAudio(Bool)
2329
}
2430

2531
#endif

Sources/LiveKit/Broadcast/IPC/BroadcastReceiver.swift

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@
1616

1717
#if os(iOS)
1818

19+
import AVFoundation
1920
import CoreImage
20-
import Foundation
2121

2222
/// Receives broadcast samples from another process.
2323
final class BroadcastReceiver: Sendable {
2424
/// Sample received from the other process with associated metadata.
2525
enum IncomingSample {
2626
case image(CVImageBuffer, VideoRotation)
27+
case audio(AVAudioPCMBuffer)
2728
}
2829

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

5051
struct AsyncSampleSequence: AsyncSequence, AsyncIteratorProtocol {
5152
fileprivate let upstream: IPCChannel.AsyncMessageSequence<BroadcastIPCHeader>
53+
5254
private let imageCodec = BroadcastImageCodec()
55+
private let audioCodec = BroadcastAudioCodec()
5356

5457
func next() async throws -> IncomingSample? {
55-
guard let (header, payload) = try await upstream.next() else {
56-
return nil
57-
}
58-
switch header {
59-
case let .image(metadata, rotation):
60-
guard let payload else { throw Error.missingSampleData }
61-
let imageBuffer = try imageCodec.decode(payload, with: metadata)
62-
return IncomingSample.image(imageBuffer, rotation)
58+
while let (header, payload) = try await upstream.next(), let payload {
59+
switch header {
60+
case let .image(metadata, rotation):
61+
let imageBuffer = try imageCodec.decode(payload, with: metadata)
62+
return IncomingSample.image(imageBuffer, rotation)
63+
64+
case let .audio(metadata):
65+
let audioBuffer = try audioCodec.decode(payload, with: metadata)
66+
return IncomingSample.audio(audioBuffer)
67+
68+
default:
69+
logger.debug("Unhandled incoming message: \(header)")
70+
continue
71+
}
6372
}
73+
return nil
6474
}
6575

6676
func makeAsyncIterator() -> Self { self }
@@ -74,6 +84,16 @@ final class BroadcastReceiver: Sendable {
7484
var incomingSamples: AsyncSampleSequence {
7585
AsyncSampleSequence(upstream: channel.incomingMessages(BroadcastIPCHeader.self))
7686
}
87+
88+
/// Tells the uploader to begin sending audio samples.
89+
func enableAudio() async throws {
90+
try await channel.send(header: BroadcastIPCHeader.wantsAudio(true))
91+
}
92+
93+
/// Tells the uploader to stop sending audio samples.
94+
func disableAudio() async throws {
95+
try await channel.send(header: BroadcastIPCHeader.wantsAudio(false))
96+
}
7797
}
7898

7999
#endif

Sources/LiveKit/Broadcast/IPC/BroadcastUploader.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ import ReplayKit
2222
/// Uploads broadcast samples to another process.
2323
final class BroadcastUploader: Sendable {
2424
private let channel: IPCChannel
25+
2526
private let imageCodec = BroadcastImageCodec()
27+
private let audioCodec = BroadcastAudioCodec()
2628

2729
private struct State {
2830
var isUploadingImage = false
31+
var shouldUploadAudio = false
2932
}
3033

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

4347
/// Whether or not the connection to the receiver has been closed.
@@ -76,10 +80,29 @@ final class BroadcastUploader: Sendable {
7680
state.mutate { $0.isUploadingImage = false }
7781
throw error
7882
}
83+
case .audioApp:
84+
guard state.shouldUploadAudio else { return }
85+
let (metadata, audioData) = try audioCodec.encode(sampleBuffer)
86+
Task {
87+
let header = BroadcastIPCHeader.audio(metadata)
88+
try await channel.send(header: header, payload: audioData)
89+
}
7990
default:
8091
throw Error.unsupportedSample
8192
}
8293
}
94+
95+
private func handleIncomingMessages() async throws {
96+
for try await (header, _) in channel.incomingMessages(BroadcastIPCHeader.self) {
97+
switch header {
98+
case let .wantsAudio(wantsAudio):
99+
state.mutate { $0.shouldUploadAudio = wantsAudio }
100+
default:
101+
logger.debug("Unhandled incoming message: \(header)")
102+
continue
103+
}
104+
}
105+
}
83106
}
84107

85108
private extension CMSampleBuffer {

0 commit comments

Comments
 (0)