feat(macos): per-app audio selection for screen sharing

Let users choose what the SCREEN_AUDIO stream captures before sharing:
share everything, only selected apps, or all except selected apps, plus a
first-class "Exclude screen reader (VoiceOver) audio" toggle.

ScreenCaptureKit filters audio per application, so ScreenAudioCapture now
takes a ScreenAudioSelection and builds the matching SCContentFilter
(including:/excludingApplications:). New ScreenSharePickerSheet lists
running apps from SCShareableContent. iOS left untouched -- ReplayKit only
delivers the mixed system stream, so per-app filtering is impossible there.
This commit is contained in:
2026-06-21 13:35:01 +02:00
parent 6b7f06a282
commit c1e6f4f7ff
6 changed files with 389 additions and 16 deletions

View File

@@ -1,6 +1,25 @@
import AVFoundation
import ScreenCaptureKit
// Which apps' audio the SCREEN_AUDIO stream captures. ScreenCaptureKit filters audio at the
// *application* level (not per-window), so the selection is expressed as bundle IDs. The
// picker UI (ScreenSharePickerSheet) produces a `ScreenAudioSelection`; `start()` turns it
// into the matching `SCContentFilter`.
enum ScreenAudioScope: Equatable {
case entireDesktop // whole display the original behaviour
case onlyApps([String]) // capture only these bundle IDs
case allExcept([String]) // capture everything except these bundle IDs
}
struct ScreenAudioSelection: Equatable {
var scope: ScreenAudioScope = .entireDesktop
/// Drop the macOS screen-reader (VoiceOver) speech from the shared mix. Meaningful for
/// `.entireDesktop`/`.allExcept`; for `.onlyApps` the screen reader is already excluded.
var excludeScreenReader: Bool = false
static let `default` = ScreenAudioSelection()
}
// ScreenAudioCapture macOS system/desktop audio capture for the SCREEN_AUDIO stream.
//
// The macOS analog of the Windows WASAPI loopback path (docs/voice.md §9). ScreenCaptureKit
@@ -26,16 +45,27 @@ final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
private let onPcm: PcmHandler
private let channels: Int // 1 (mono) or 2 (stereo interleaved), matches the stream's mode
private let selection: ScreenAudioSelection
private let sampleQueue = DispatchQueue(label: "cat.voice.screenaudio.samples")
private var stream: SCStream?
/// Bundle IDs whose audio carries the macOS screen-reader speech. VoiceOver itself plus the
/// speech-synthesis daemon that actually renders the spoken audio the speech is usually
/// emitted by the daemon, not the VoiceOver app, so we exclude whichever are running.
static let screenReaderBundleIDs: Set<String> = [
"com.apple.VoiceOver",
"com.apple.VoiceOver4",
"com.apple.speech.speechsynthesisd",
]
/// Interleaved int16 carry-over between callbacks (ScreenCaptureKit buffers don't align to
/// 20 ms), drained in whole `frameSamplesPerChannel * channels` chunks. Only touched on
/// `sampleQueue`.
private var pending: [Int16] = []
init(channels: UInt32, onPcm: @escaping PcmHandler) {
init(channels: UInt32, selection: ScreenAudioSelection, onPcm: @escaping PcmHandler) {
self.channels = max(1, min(2, Int(channels)))
self.selection = selection
self.onPcm = onPcm
super.init()
}
@@ -46,7 +76,8 @@ final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
let content = try await SCShareableContent.current
guard let display = content.displays.first else { throw CaptureError.noDisplay }
let filter = SCContentFilter(display: display, excludingWindows: [])
let filter = Self.makeFilter(selection: selection, display: display,
apps: content.applications)
let config = SCStreamConfiguration()
config.capturesAudio = true
@@ -65,6 +96,39 @@ final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
self.stream = stream
}
/// Turn a `ScreenAudioSelection` into an `SCContentFilter` against the running apps.
/// ScreenCaptureKit filters audio per application, so we map bundle IDs SCRunningApplication.
private static func makeFilter(selection: ScreenAudioSelection, display: SCDisplay,
apps: [SCRunningApplication]) -> SCContentFilter {
func appsMatching(_ ids: Set<String>) -> [SCRunningApplication] {
apps.filter { ids.contains($0.bundleIdentifier) }
}
switch selection.scope {
case .onlyApps(let bundleIDs):
// Include-only already excludes everything else (the screen reader included), so the
// excludeScreenReader flag is moot in this mode.
return SCContentFilter(display: display,
including: appsMatching(Set(bundleIDs)),
exceptingWindows: [])
case .allExcept(let bundleIDs):
var ids = Set(bundleIDs)
if selection.excludeScreenReader { ids.formUnion(screenReaderBundleIDs) }
return SCContentFilter(display: display,
excludingApplications: appsMatching(ids),
exceptingWindows: [])
case .entireDesktop:
if selection.excludeScreenReader {
return SCContentFilter(display: display,
excludingApplications: appsMatching(screenReaderBundleIDs),
exceptingWindows: [])
}
return SCContentFilter(display: display, excludingWindows: [])
}
}
/// Stop capture and release the stream. Safe to call multiple times.
func stop() {
guard let stream else { return }