Skip to content

Commit 6533052

Browse files
committed
feat(macos): record system audio from a Core Audio tap with Apple's picker
A take picked in Apple's system picker holds no Screen Recording grant, and ScreenCaptureKit hands such a capture its system audio as silence (measured on macOS 26.5). So #737 dropped system audio unless Screen Recording was granted, which brought back the grant the picker removes. Picked takes now record system audio from a Core Audio process tap instead, under its own, narrower 'System Audio Recording Only' grant. Measured alongside a picker stream on 26.5: real audio, in the same process, with no relaunch and no Screen Recording grant. - SystemAudioTap: a global stereo process tap in a private aggregate device. Its IO cycles become CMSampleBuffers stamped on the host clock (makeAudioSampleBuffer, in OpenScreenCaptureCore with tests), so they take the recorder's existing pause/retime path into AudioTrackMixer. - The tap starts off the take's start path, since the first start blocks on macOS' prompt, and stops accepting audio before the writer is finalised. Takes from the app's own picker keep ScreenCaptureKit audio. - `--request-system-audio` raises the prompt on its own. The HUD's system-audio toggle calls it the first time it is turned on, so the prompt never lands on a take that is already counting down. - The permissions window gets a real 'System audio' row there. Its answer has no public read, so after asking it reads 'requested' and offers the matching System Settings pane (Privacy_AudioCapture), with the section named as macOS names it in each of the 15 locales.
1 parent 5137fc5 commit 6533052

27 files changed

Lines changed: 690 additions & 65 deletions

File tree

‎electron/ipc/handlers.ts‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,6 +2220,13 @@ export function registerIpcHandlers(
22202220
selectedSource = null;
22212221
broadcastSelectedSource(null);
22222222
}
2223+
// Turning system audio on is when its grant becomes wanted: ask now, so the prompt
2224+
// never lands on a take that is already counting down.
2225+
if (!previous.systemAudioEnabled && next.systemAudioEnabled && macPickerOwnsSources()) {
2226+
void getMacPermissions()
2227+
.askForSystemAudioOnce()
2228+
.catch((error) => console.warn("[permissions] system audio request failed:", error));
2229+
}
22232230
},
22242231
);
22252232

@@ -2996,18 +3003,12 @@ export function registerIpcHandlers(
29963003
// The helper reports the final ScreenCaptureKit permission status.
29973004
}
29983005
}
2999-
// System audio is the one thing a pick does not cover: without the Screen Recording
3000-
// grant ScreenCaptureKit delivers it as silence (measured). Recording silence and
3001-
// calling it system audio would be worse than saying so, so the take goes ahead
3002-
// without it and the permissions window explains what would bring it back.
3003-
let systemAudioUnavailable = false;
3006+
// A picked take records system audio from a Core Audio tap, under its own "System
3007+
// Audio Recording Only" grant (see SystemAudioTap.swift). The helper raises that
3008+
// prompt itself if it was never answered, so note it: the permissions window then
3009+
// offers System Settings rather than a prompt macOS will not show again.
30043010
if (pickerSession && request.audio?.system?.enabled) {
3005-
const permissions = await getMacPermissions().read();
3006-
if (permissions.screen !== "granted") {
3007-
systemAudioUnavailable = true;
3008-
request = { ...request, audio: { ...request.audio, system: { enabled: false } } };
3009-
showPermissionsWindow();
3010-
}
3011+
getMacPermissions().noteRequested("systemAudio");
30113012
}
30123013
if (request.audio?.microphone?.enabled) {
30133014
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
@@ -3136,7 +3137,6 @@ export function registerIpcHandlers(
31363137
path: outputPath,
31373138
helperPath,
31383139
microphoneDefaulted,
3139-
systemAudioUnavailable,
31403140
};
31413141
} catch (error) {
31423142
console.error("Failed to start native macOS recording:", error);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import AudioToolbox
2+
import CoreMedia
3+
import Foundation
4+
5+
/// Wraps one Core Audio IO cycle as the `CMSampleBuffer` the mixer already reads.
6+
///
7+
/// The system-audio tap delivers an `AudioBufferList` and the host time it was captured at.
8+
/// ScreenCaptureKit's audio output delivered `CMSampleBuffer`s stamped on the host clock.
9+
/// Stamping the tap's audio on that same clock is what lets it go through the recorder's
10+
/// existing pause and retime path, and land in `AudioTrackMixer` at the offset it belongs
11+
/// at, exactly as ScreenCaptureKit's did.
12+
///
13+
/// The data is copied: the IO proc's buffers are only valid for the duration of the callback.
14+
public func makeAudioSampleBuffer(
15+
copying bufferList: UnsafePointer<AudioBufferList>,
16+
format: CMAudioFormatDescription,
17+
hostTime: UInt64
18+
) -> CMSampleBuffer? {
19+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(format)?.pointee,
20+
asbd.mBytesPerFrame > 0,
21+
asbd.mSampleRate > 0
22+
else {
23+
return nil
24+
}
25+
let buffers = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: bufferList))
26+
guard let first = buffers.first, first.mDataByteSize > 0 else {
27+
return nil
28+
}
29+
// Per buffer: one channel for non-interleaved audio, every channel for interleaved.
30+
let frameCount = Int(first.mDataByteSize / asbd.mBytesPerFrame)
31+
guard frameCount > 0 else {
32+
return nil
33+
}
34+
35+
var timing = CMSampleTimingInfo(
36+
duration: CMTime(value: 1, timescale: CMTimeScale(asbd.mSampleRate)),
37+
presentationTimeStamp: CMClockMakeHostTimeFromSystemUnits(hostTime),
38+
decodeTimeStamp: .invalid
39+
)
40+
var sampleBuffer: CMSampleBuffer?
41+
var status = CMSampleBufferCreate(
42+
allocator: kCFAllocatorDefault,
43+
dataBuffer: nil,
44+
dataReady: false,
45+
makeDataReadyCallback: nil,
46+
refcon: nil,
47+
formatDescription: format,
48+
sampleCount: frameCount,
49+
sampleTimingEntryCount: 1,
50+
sampleTimingArray: &timing,
51+
sampleSizeEntryCount: 0,
52+
sampleSizeArray: nil,
53+
sampleBufferOut: &sampleBuffer
54+
)
55+
guard status == noErr, let sampleBuffer else {
56+
return nil
57+
}
58+
status = CMSampleBufferSetDataBufferFromAudioBufferList(
59+
sampleBuffer,
60+
blockBufferAllocator: kCFAllocatorDefault,
61+
blockBufferMemoryAllocator: kCFAllocatorDefault,
62+
flags: 0,
63+
bufferList: bufferList
64+
)
65+
return status == noErr ? sampleBuffer : nil
66+
}

‎electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift‎

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
159159
private let microphoneOutputTypeRawValue = 2
160160
private let hostClock = CMClockGetHostTimeClock()
161161
private let picked: PickedSource?
162+
/// A `SystemAudioTap` (macOS 14.2+), held untyped so this class keeps its macOS 13 floor.
163+
private var systemAudioTap: AnyObject?
164+
/// Sample queue only. Cleared before the writer is finalised: a tap stops on its own
165+
/// queue, and a buffer it had in flight must not reach a finished writer input.
166+
private var acceptsTappedAudio = true
167+
private let tapControlQueue = DispatchQueue(label: "app.openscreen.sck-helper.system-audio-control")
162168

163169
/// `picked` is a choice already made in Apple's system picker (`PickerSession`). The take
164170
/// then records exactly that filter: no source lookup, and no Screen Recording check,
@@ -169,6 +175,21 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
169175
self.picked = picked
170176
}
171177

178+
/// Whether this take's system audio comes from a Core Audio tap instead of ScreenCaptureKit.
179+
///
180+
/// Only for a source from Apple's picker: that capture holds no Screen Recording grant,
181+
/// and ScreenCaptureKit hands a grantless capture its system audio as silence. Every other
182+
/// source keeps ScreenCaptureKit's audio, which its grant already covers.
183+
private var tapsSystemAudio: Bool {
184+
guard picked != nil, request.audio.system.enabled else {
185+
return false
186+
}
187+
if #available(macOS 14.2, *) {
188+
return true
189+
}
190+
return false
191+
}
192+
172193
func start() async throws {
173194
try ensureRequestedPermissions(screen: picked == nil)
174195

@@ -189,7 +210,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
189210
let stream = SCStream(filter: target.filter, configuration: configuration, delegate: self)
190211

191212
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue)
192-
if request.audio.system.enabled {
213+
if request.audio.system.enabled && !tapsSystemAudio {
193214
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: sampleQueue)
194215
}
195216
if nativeMicrophoneEnabled {
@@ -201,6 +222,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
201222
try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: sampleQueue)
202223
}
203224
try setupWriter()
225+
startSystemAudioTapIfNeeded()
204226

205227
self.stream = stream
206228
emit([
@@ -238,10 +260,75 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
238260
"message": "\(error)",
239261
])
240262
}
263+
stopSystemAudioTap()
241264

242265
await finishWriter()
243266
}
244267

268+
private func startSystemAudioTapIfNeeded() {
269+
guard tapsSystemAudio, #available(macOS 14.2, *) else {
270+
return
271+
}
272+
let tap = SystemAudioTap { [weak self] sampleBuffer in
273+
guard let self else {
274+
return
275+
}
276+
self.sampleQueue.async {
277+
self.ingestTappedSystemAudio(sampleBuffer)
278+
}
279+
}
280+
systemAudioTap = tap
281+
// Off the start path: the first time, starting a tap blocks until the user answers
282+
// macOS' prompt, and the app gives a take only seconds to report it has started.
283+
// Audio that arrives late lands where it belongs, with silence in front of it.
284+
tapControlQueue.async {
285+
do {
286+
try tap.start()
287+
} catch {
288+
emit([
289+
"event": "warning",
290+
"code": "system-audio-unavailable",
291+
"message": "System audio could not be captured: \(error)",
292+
])
293+
}
294+
}
295+
}
296+
297+
/// Queued behind the start rather than run beside it: a tap stopped while its start is
298+
/// still inside AudioDeviceStart is torn down under its own feet. Not awaited, so a
299+
/// prompt left unanswered cannot hold the recording's stop hostage; what the tap still
300+
/// delivers is dropped by `acceptsTappedAudio`.
301+
private func stopSystemAudioTap() {
302+
sampleQueue.sync {
303+
acceptsTappedAudio = false
304+
}
305+
guard let tap = systemAudioTap else {
306+
return
307+
}
308+
systemAudioTap = nil
309+
tapControlQueue.async {
310+
if #available(macOS 14.2, *), let tap = tap as? SystemAudioTap {
311+
tap.stop()
312+
}
313+
}
314+
}
315+
316+
/// Sample queue. The tap's audio takes the same pause and retime path ScreenCaptureKit's
317+
/// system audio took in `stream(_:didOutputSampleBuffer:of:)`.
318+
private func ingestTappedSystemAudio(_ sampleBuffer: CMSampleBuffer) {
319+
guard acceptsTappedAudio else {
320+
return
321+
}
322+
let pauseState = currentPauseState()
323+
if pauseState.paused {
324+
return
325+
}
326+
guard let sampleBuffer = retimedSampleBuffer(sampleBuffer, subtracting: pauseState.offset) else {
327+
return
328+
}
329+
audioMixer?.ingest(sampleBuffer, from: .system)
330+
}
331+
245332
func pause() {
246333
let didPause = stateQueue.sync {
247334
if shutdownTask != nil || isPaused {
@@ -618,7 +705,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
618705
configuration.sampleRate = 48_000
619706
configuration.channelCount = 2
620707
configuration.excludesCurrentProcessAudio = true
621-
configuration.capturesAudio = request.audio.system.enabled
708+
configuration.capturesAudio = request.audio.system.enabled && !tapsSystemAudio
622709

623710
if request.audio.microphone.enabled {
624711
guard supportsNativeMicrophoneCapture(streamConfig: configuration) else {
@@ -1054,6 +1141,10 @@ struct OpenScreenScreenCaptureKitHelper {
10541141
/// picker. See `PickerSession`.
10551142
private static let pickerSessionFlag = "--picker-session"
10561143

1144+
/// The flag that raises macOS' "record system audio" prompt and exits once it is
1145+
/// answered. See `SystemAudioTap.requestAccess`.
1146+
private static let requestSystemAudioFlag = "--request-system-audio"
1147+
10571148
static func main() async {
10581149
do {
10591150
initializeCoreGraphicsWindowServerConnection()
@@ -1079,6 +1170,24 @@ struct OpenScreenScreenCaptureKitHelper {
10791170
exit(0)
10801171
}
10811172

1173+
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == requestSystemAudioFlag {
1174+
guard #available(macOS 14.2, *) else {
1175+
emitError(
1176+
code: "system-audio-unsupported",
1177+
message: "Core Audio process taps need macOS 14.2 or later."
1178+
)
1179+
exit(2)
1180+
}
1181+
do {
1182+
try SystemAudioTap.requestAccess()
1183+
emit(["event": "system-audio-access-requested"])
1184+
exit(0)
1185+
} catch {
1186+
emitError(code: "system-audio-request-failed", message: "\(error)")
1187+
exit(1)
1188+
}
1189+
}
1190+
10821191
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == pickerSessionFlag {
10831192
guard #available(macOS 15.2, *) else {
10841193
emitError(

0 commit comments

Comments
 (0)