Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changes/xcode-27-beta-build
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Fixed Xcode 27 beta build errors"
23 changes: 23 additions & 0 deletions Sources/LKObjCHelpers/LKObjCHelpers.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ @implementation LKObjCHelpers

NS_ASSUME_NONNULL_BEGIN

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+ (void)finishBroadcastWithoutError:(RPBroadcastSampleHandler *)handler API_AVAILABLE(ios(10.0), macCatalyst(13.1), macos(11.0), tvos(10.0)) {
// Call finishBroadcastWithError with nil error, which ends the broadcast without an error popup
// This is unsupported/undocumented but appears to work and is preferable to an error dialog with a cryptic default message
Expand All @@ -13,6 +15,7 @@ + (void)finishBroadcastWithoutError:(RPBroadcastSampleHandler *)handler API_AVAI
[handler finishBroadcastWithError:nil];
#pragma clang diagnostic pop
}
#pragma clang diagnostic pop

+ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
@try {
Expand All @@ -25,6 +28,26 @@ + (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **
}
}

// MARK: - Xcode 27 availability workarounds

+ (AUAudioFrameCount)maximumFramesToRenderForNode:(AVAudioNode *)node {
return node.AUAudioUnit.maximumFramesToRender;
}

+ (void)setMaximumFramesToRender:(AUAudioFrameCount)maximumFramesToRender forNode:(AVAudioNode *)node {
node.AUAudioUnit.maximumFramesToRender = maximumFramesToRender;
}

#if TARGET_OS_OSX
+ (void)setWidth:(size_t)width height:(size_t)height onConfiguration:(SCStreamConfiguration *)configuration {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunguarded-availability-new"
configuration.width = width;
configuration.height = height;
#pragma clang diagnostic pop
}
#endif

NS_ASSUME_NONNULL_END

@end
22 changes: 22 additions & 0 deletions Sources/LKObjCHelpers/include/LKObjCHelpers.h
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
#import <Foundation/Foundation.h>
#import <ReplayKit/ReplayKit.h>
#import <AVFAudio/AVFAudio.h>
#import <AudioToolbox/AudioToolbox.h>

#if TARGET_OS_OSX
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#endif

@interface LKObjCHelpers : NSObject

#pragma clang diagnostic push
// RPBroadcastSampleHandler is deprecated in the iOS 27 SDK; suppress so the module still builds (#1037).
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+ (void)finishBroadcastWithoutError:(RPBroadcastSampleHandler *)handler API_AVAILABLE(ios(10.0), macCatalyst(13.1), macos(11.0), tvos(10.0));
#pragma clang diagnostic pop

+ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error;

// MARK: - Xcode 27 availability workarounds
// The macOS 27 SDK bumped these APIs past the OS versions they actually ship in (only the Swift
// importer enforces it). Reaching them from ObjC keeps full behavior on every SDK/OS version (#1035).

+ (AUAudioFrameCount)maximumFramesToRenderForNode:(AVAudioNode *)node;

+ (void)setMaximumFramesToRender:(AUAudioFrameCount)maximumFramesToRender forNode:(AVAudioNode *)node;

#if TARGET_OS_OSX
+ (void)setWidth:(size_t)width height:(size_t)height onConfiguration:(SCStreamConfiguration *)configuration API_AVAILABLE(macos(12.3));
#endif

@end
34 changes: 17 additions & 17 deletions Sources/LiveKit/Audio/MixerEngineObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable {
($0.appNode, $0.appMixerNode, $0.micNode, $0.micMixerNode, $0.soundPlayerNodes)
}

#if os(iOS) || os(visionOS) || os(tvOS)
// Match the outputNode's maximumFramesToRender so our nodes can handle the same
// buffer sizes the engine expects, preventing kAudioUnitErr_TooManyFramesToProcess (-10874).
// Must be set before render resources are allocated (i.e. before attach/connect/start).
Expand All @@ -118,28 +117,29 @@ public final class MixerEngineObserver: AudioEngineObserver, Loggable {
// must move into the WebRTC layer (audio_engine_device.mm).
// See: https://developer.apple.com/documentation/audiotoolbox/auaudiounit/maximumframestorender
// See: https://developer.apple.com/forums/thread/111968
let maxFrames = engine.outputNode.auAudioUnit.maximumFramesToRender
let maxFrames = engine.outputNode.maximumFramesToRender

log("maximumFramesToRender before: " +
"appNode=\(appNode.auAudioUnit.maximumFramesToRender), " +
"appMixerNode=\(appMixerNode.auAudioUnit.maximumFramesToRender), " +
"micNode=\(micNode.auAudioUnit.maximumFramesToRender), " +
"micMixerNode=\(micMixerNode.auAudioUnit.maximumFramesToRender), " +
"soundPlayerMixerNode=\(soundPlayerNodes.mixerNode.auAudioUnit.maximumFramesToRender)", .debug)

appNode.auAudioUnit.maximumFramesToRender = maxFrames
appMixerNode.auAudioUnit.maximumFramesToRender = maxFrames
micNode.auAudioUnit.maximumFramesToRender = maxFrames
micMixerNode.auAudioUnit.maximumFramesToRender = maxFrames
"appNode=\(appNode.maximumFramesToRender), " +
"appMixerNode=\(appMixerNode.maximumFramesToRender), " +
"micNode=\(micNode.maximumFramesToRender), " +
"micMixerNode=\(micMixerNode.maximumFramesToRender), " +
"soundPlayerMixerNode=\(soundPlayerNodes.mixerNode.maximumFramesToRender)", .debug)

appNode.maximumFramesToRender = maxFrames
appMixerNode.maximumFramesToRender = maxFrames
micNode.maximumFramesToRender = maxFrames
micMixerNode.maximumFramesToRender = maxFrames
soundPlayerNodes.setMaximumFramesToRender(maxFrames)

log("maximumFramesToRender setting to \(maxFrames): " +
"appNode=\(appNode.auAudioUnit.maximumFramesToRender), " +
"appMixerNode=\(appMixerNode.auAudioUnit.maximumFramesToRender), " +
"micNode=\(micNode.auAudioUnit.maximumFramesToRender), " +
"micMixerNode=\(micMixerNode.auAudioUnit.maximumFramesToRender), " +
"soundPlayerMixerNode=\(soundPlayerNodes.mixerNode.auAudioUnit.maximumFramesToRender)", .debug)
"appNode=\(appNode.maximumFramesToRender), " +
"appMixerNode=\(appMixerNode.maximumFramesToRender), " +
"micNode=\(micNode.maximumFramesToRender), " +
"micMixerNode=\(micMixerNode.maximumFramesToRender), " +
"soundPlayerMixerNode=\(soundPlayerNodes.mixerNode.maximumFramesToRender)", .debug)

#if os(iOS) || os(visionOS) || os(tvOS)
let config = LKRTCAudioSessionConfiguration.webRTC()
log("webRTCPreferredFrames=\(AVAudioFrameCount(config.sampleRate * config.ioBufferDuration))", .debug)
#endif
Expand Down
4 changes: 2 additions & 2 deletions Sources/LiveKit/Audio/PlayerNodePool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ class AVAudioPlayerNodePool: @unchecked Sendable, Loggable {

func setMaximumFramesToRender(_ maxFrames: AUAudioFrameCount) {
executionQueue.sync {
mixerNode.auAudioUnit.maximumFramesToRender = maxFrames
mixerNode.maximumFramesToRender = maxFrames
for item in items {
item.node.auAudioUnit.maximumFramesToRender = maxFrames
item.node.maximumFramesToRender = maxFrames
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/LiveKit/Audio/SoundPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ extension SoundPlayer {
playerNodePool.stop()
engine.stop()
engine.disconnect(playerNodePool)
playerNodePool.setMaximumFramesToRender(engine.outputNode.auAudioUnit.maximumFramesToRender)
playerNodePool.setMaximumFramesToRender(engine.outputNode.maximumFramesToRender)
engine.connect(playerNodePool, to: engine.mainMixerNode,
format: outputFormat, playerNodeFormat: playerNodeFormat)
try engine.start()
Expand Down
2 changes: 1 addition & 1 deletion Sources/LiveKit/Broadcast/IPC/BroadcastAudioCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ struct BroadcastAudioCodec {
}
}

extension AudioStreamBasicDescription: Codable {
extension AudioStreamBasicDescription: Swift.Codable {
public func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(mSampleRate)
Expand Down
4 changes: 2 additions & 2 deletions Sources/LiveKit/Broadcast/IPC/BroadcastUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ final class BroadcastUploader: Sendable, Loggable {
let rotation = VideoRotation(sampleBuffer.replayKitOrientation ?? .up)
do {
let (metadata, imageData) = try imageCodec.encode(sampleBuffer)
Task {
Task.discarding {
let header = BroadcastIPCHeader.image(metadata, rotation)
try await channel.send(header: header, payload: imageData)
state.mutate { $0.isUploadingImage = false }
Expand All @@ -95,7 +95,7 @@ final class BroadcastUploader: Sendable, Loggable {
case .audioApp:
guard state.shouldUploadAudio else { return }
let (metadata, audioData) = try audioCodec.encode(sampleBuffer)
Task {
Task.discarding {
let header = BroadcastIPCHeader.audio(metadata)
try await channel.send(header: header, payload: audioData)
}
Expand Down
58 changes: 58 additions & 0 deletions Sources/LiveKit/Extensions/AVAudioNode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright 2026 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

#if compiler(>=6.4) && !COCOAPODS
internal import LKObjCHelpers
#endif

extension AVAudioNode {
/// The underlying audio unit's `maximumFramesToRender`.
///
/// The Xcode 27 SDK restricts `auAudioUnit` to macOS 13.0 and deprecates it in favor of
/// `withAUAudioUnit`, so this prefers `withAUAudioUnit` on OS 27+, uses the `auAudioUnit` property
/// on macOS 13–26, and falls back to ``LKObjCHelpers`` (original ObjC availability, macOS 10.13)
/// below macOS 13. See #1035.
var maximumFramesToRender: AUAudioFrameCount {
get {
#if compiler(>=6.4)

@pblazej pblazej Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little overengineered vs e.g.

if #available(macOS 13.0, *) {
  return auAudioUnit.maximumFramesToRender
}
return LKObjCHelpers.maximumFramesToRender(for: self)

which would surface the deprecation on 27.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May change in the upcoming betas tho...

if #available(macOS 27.0, iOS 27.0, tvOS 27.0, visionOS 27.0, *) {
return withAUAudioUnit { $0.maximumFramesToRender }
} else if #available(macOS 13.0, *) {
return auAudioUnit.maximumFramesToRender
} else {
return LKObjCHelpers.maximumFramesToRender(for: self)
}
#else
return auAudioUnit.maximumFramesToRender
#endif
}
set {
#if compiler(>=6.4)
if #available(macOS 27.0, iOS 27.0, tvOS 27.0, visionOS 27.0, *) {
withAUAudioUnit { $0.maximumFramesToRender = newValue }
} else if #available(macOS 13.0, *) {
auAudioUnit.maximumFramesToRender = newValue
} else {
LKObjCHelpers.setMaximumFramesToRender(newValue, for: self)
}
#else
auAudioUnit.maximumFramesToRender = newValue
#endif
}
}
}
7 changes: 6 additions & 1 deletion Sources/LiveKit/Support/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ class Utils: Loggable {
}
return identifier
#elseif os(macOS)
let service = IOServiceGetMatchingService(kIOMasterPortDefault,
let mainPort: mach_port_t = if #available(macOS 12.0, *) {
kIOMainPortDefault
} else {
0 // kIOMainPortDefault (macOS 12+) is a synonym for the NULL default port
}
let service = IOServiceGetMatchingService(mainPort,
IOServiceMatching("IOPlatformExpertDevice"))
defer { IOObjectRelease(service) }

Expand Down
10 changes: 10 additions & 0 deletions Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import ScreenCaptureKit

internal import LiveKitWebRTC

#if compiler(>=6.4) && !COCOAPODS
internal import LKObjCHelpers
#endif

#if os(macOS)

@available(macOS 12.3, *)
Expand Down Expand Up @@ -89,8 +93,14 @@ public class MacOSScreenCapturer: VideoCapturer, @unchecked Sendable {

let mainDisplay = CGMainDisplayID()
// try to capture in max resolution
#if compiler(>=6.4)
LKObjCHelpers.setWidth(CGDisplayPixelsWide(mainDisplay) * 2,
height: CGDisplayPixelsHigh(mainDisplay) * 2,
on: configuration)
#else
configuration.width = CGDisplayPixelsWide(mainDisplay) * 2
configuration.height = CGDisplayPixelsHigh(mainDisplay) * 2
#endif

configuration.scalesToFit = false
configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(options.fps))
Expand Down
Loading