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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user