|
@@ -91,6 +91,8 @@ final class MeetingRecordingManager: NSObject {
|
|
|
private var micRecorder: AVAudioRecorder?
|
|
private var micRecorder: AVAudioRecorder?
|
|
|
/// True while `AVAudioRecorder` is actively encoding (unmuted in the meeting).
|
|
/// True while `AVAudioRecorder` is actively encoding (unmuted in the meeting).
|
|
|
private var micRecordingActive: Bool = false
|
|
private var micRecordingActive: Bool = false
|
|
|
|
|
+ /// True only after the user was unmuted in Zoom at least once this session (controls export mix).
|
|
|
|
|
+ private var localMicEverActivated: Bool = false
|
|
|
|
|
|
|
|
/// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
|
|
/// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
|
|
|
private var systemAudioRecorder: AnyObject?
|
|
private var systemAudioRecorder: AnyObject?
|
|
@@ -207,42 +209,53 @@ final class MeetingRecordingManager: NSObject {
|
|
|
pendingMeetingDisplayName = displayTitle
|
|
pendingMeetingDisplayName = displayTitle
|
|
|
outputAudioURL = outBase
|
|
outputAudioURL = outBase
|
|
|
|
|
|
|
|
- do {
|
|
|
|
|
- if micGranted {
|
|
|
|
|
- try? prepareMicRecorder(to: micURL)
|
|
|
|
|
- }
|
|
|
|
|
- if #available(macOS 13.0, *) {
|
|
|
|
|
|
|
+ if micGranted {
|
|
|
|
|
+ try? prepareMicRecorder(to: micURL)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var systemCaptureStarted = false
|
|
|
|
|
+ if #available(macOS 13.0, *) {
|
|
|
|
|
+ do {
|
|
|
try await ensureScreenCaptureAccess()
|
|
try await ensureScreenCaptureAccess()
|
|
|
let target = resolveSystemAudioCaptureTarget()
|
|
let target = resolveSystemAudioCaptureTarget()
|
|
|
- let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL, captureTarget: target)
|
|
|
|
|
|
|
+ let recorder = try await startSystemAudioRecorder(outputURL: sysURL, primaryTarget: target)
|
|
|
systemAudioRecorder = recorder
|
|
systemAudioRecorder = recorder
|
|
|
- do {
|
|
|
|
|
- try await recorder.start()
|
|
|
|
|
- } catch {
|
|
|
|
|
- systemAudioRecorder = nil
|
|
|
|
|
- NSLog("MeetingRecordingManager: system audio start failed — \(error.localizedDescription)")
|
|
|
|
|
- throw RecordingError.systemAudioUnavailable(error.localizedDescription)
|
|
|
|
|
- }
|
|
|
|
|
- } else if captureEmbeddedZoomWebClient {
|
|
|
|
|
- throw RecordingError.systemAudioUnavailable("System audio capture requires macOS 13 or later.")
|
|
|
|
|
- } else {
|
|
|
|
|
|
|
+ systemCaptureStarted = true
|
|
|
|
|
+ } catch {
|
|
|
systemAudioRecorder = nil
|
|
systemAudioRecorder = nil
|
|
|
|
|
+ NSLog("MeetingRecordingManager: system audio unavailable — \(error.localizedDescription)")
|
|
|
}
|
|
}
|
|
|
- } catch {
|
|
|
|
|
- stopMicRecorder(discardFile: true)
|
|
|
|
|
- if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
|
|
|
|
|
- try? await r.stop()
|
|
|
|
|
|
|
+ } else if captureEmbeddedZoomWebClient {
|
|
|
|
|
+ if micGranted == false {
|
|
|
|
|
+ recordingID = nil
|
|
|
|
|
+ recordingStartedAt = nil
|
|
|
|
|
+ recordingSourceURLString = nil
|
|
|
|
|
+ pendingMeetingDisplayName = nil
|
|
|
|
|
+ outputAudioURL = nil
|
|
|
|
|
+ throw RecordingError.systemAudioUnavailable("System audio capture requires macOS 13 or later.")
|
|
|
}
|
|
}
|
|
|
- systemAudioRecorder = nil
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Meeting recording is driven by system/meeting audio (all participants). Local mic is optional.
|
|
|
|
|
+ if systemCaptureStarted == false {
|
|
|
|
|
+ stopMicRecorder(discardFile: true)
|
|
|
recordingID = nil
|
|
recordingID = nil
|
|
|
recordingStartedAt = nil
|
|
recordingStartedAt = nil
|
|
|
recordingSourceURLString = nil
|
|
recordingSourceURLString = nil
|
|
|
pendingMeetingDisplayName = nil
|
|
pendingMeetingDisplayName = nil
|
|
|
outputAudioURL = nil
|
|
outputAudioURL = nil
|
|
|
- throw error
|
|
|
|
|
|
|
+ if micGranted == false {
|
|
|
|
|
+ throw RecordingError.microphonePermissionDenied
|
|
|
|
|
+ }
|
|
|
|
|
+ throw RecordingError.systemAudioUnavailable(
|
|
|
|
|
+ "Meeting audio could not be captured. Enable Screen Recording for this app in System Settings, then try again."
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ localMicEverActivated = false
|
|
|
isRecording = true
|
|
isRecording = true
|
|
|
|
|
+ // Keep the Mac mic paused until Zoom reports the user is unmuted.
|
|
|
|
|
+ pauseMicRecording()
|
|
|
NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
|
|
NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
|
self.startAutomaticStopMonitoringOnMain()
|
|
self.startAutomaticStopMonitoringOnMain()
|
|
@@ -308,6 +321,9 @@ final class MeetingRecordingManager: NSObject {
|
|
|
|
|
|
|
|
// Let ScreenCaptureKit / AVAssetWriter finish flushing to disk.
|
|
// Let ScreenCaptureKit / AVAssetWriter finish flushing to disk.
|
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
|
|
|
+ if systemSampleCount > 0, hasUsableAudio(at: sysURL) == false {
|
|
|
|
|
+ try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
if systemSampleCount == 0 {
|
|
if systemSampleCount == 0 {
|
|
|
NSLog("MeetingRecordingManager: warning — system audio capture received 0 samples.")
|
|
NSLog("MeetingRecordingManager: warning — system audio capture received 0 samples.")
|
|
@@ -316,7 +332,8 @@ final class MeetingRecordingManager: NSObject {
|
|
|
let exportResult = await exportMeetingAudio(
|
|
let exportResult = await exportMeetingAudio(
|
|
|
systemURL: sysURL,
|
|
systemURL: sysURL,
|
|
|
microphoneURL: micURL,
|
|
microphoneURL: micURL,
|
|
|
- outputURL: outURL
|
|
|
|
|
|
|
+ outputURL: outURL,
|
|
|
|
|
+ systemSamplesCaptured: systemSampleCount
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
// Cleanup intermediates (ignore failures).
|
|
// Cleanup intermediates (ignore failures).
|
|
@@ -324,20 +341,38 @@ final class MeetingRecordingManager: NSObject {
|
|
|
try? FileManager.default.removeItem(at: sysURL)
|
|
try? FileManager.default.removeItem(at: sysURL)
|
|
|
|
|
|
|
|
guard let savedURL = exportResult.url else {
|
|
guard let savedURL = exportResult.url else {
|
|
|
|
|
+ let sysBytes = fileSize(at: sysURL)
|
|
|
|
|
+ let micBytes = fileSize(at: micURL)
|
|
|
NSLog(
|
|
NSLog(
|
|
|
"MeetingRecordingManager: export failed — system %lld bytes, mic %lld bytes, out %lld bytes",
|
|
"MeetingRecordingManager: export failed — system %lld bytes, mic %lld bytes, out %lld bytes",
|
|
|
- fileSize(at: sysURL),
|
|
|
|
|
- fileSize(at: micURL),
|
|
|
|
|
- fileSize(at: outURL)
|
|
|
|
|
|
|
+ sysBytes, micBytes, fileSize(at: outURL)
|
|
|
)
|
|
)
|
|
|
- throw RecordingError.exportFailed
|
|
|
|
|
|
|
+ if sysBytes < 128, micBytes < 128 {
|
|
|
|
|
+ throw RecordingError.exportFailed
|
|
|
|
|
+ }
|
|
|
|
|
+ // Keep a minimal entry so the session is not silently lost.
|
|
|
|
|
+ let rec = RecordedMeeting(
|
|
|
|
|
+ id: id,
|
|
|
|
|
+ startedAt: startedAt,
|
|
|
|
|
+ endedAt: endedAt,
|
|
|
|
|
+ sourceURLString: sourceURLString,
|
|
|
|
|
+ meetingDisplayName: displayName,
|
|
|
|
|
+ audioFilename: outURL.lastPathComponent,
|
|
|
|
|
+ transcriptText: nil,
|
|
|
|
|
+ transcriptError: "Recording could not be exported. Check Screen Recording and Microphone permissions, then try again.",
|
|
|
|
|
+ notesStatusRaw: nil,
|
|
|
|
|
+ notesText: nil,
|
|
|
|
|
+ notesError: nil
|
|
|
|
|
+ )
|
|
|
|
|
+ recordings.insert(rec, at: 0)
|
|
|
|
|
+ saveRecordingsToDisk()
|
|
|
|
|
+ NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var transcriptWarning = exportResult.transcriptWarning
|
|
var transcriptWarning = exportResult.transcriptWarning
|
|
|
- let hasSystem = hasUsableAudio(at: sysURL)
|
|
|
|
|
- let hasMic = hasUsableAudio(at: micURL)
|
|
|
|
|
- if hasSystem == false, hasMic {
|
|
|
|
|
- transcriptWarning = "Meeting audio (other participants) was not captured. Turn on Screen Recording for this app in System Settings, then record again."
|
|
|
|
|
|
|
+ if systemSampleCount == 0 {
|
|
|
|
|
+ transcriptWarning = "Meeting audio may be incomplete. Keep Screen Recording enabled and stay in the meeting until it ends."
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
let rec = RecordedMeeting(
|
|
let rec = RecordedMeeting(
|
|
@@ -364,9 +399,19 @@ final class MeetingRecordingManager: NSObject {
|
|
|
let transcriptWarning: String?
|
|
let transcriptWarning: String?
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func exportMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async -> MeetingAudioExportResult {
|
|
|
|
|
|
|
+ private func exportMeetingAudio(
|
|
|
|
|
+ systemURL: URL,
|
|
|
|
|
+ microphoneURL: URL,
|
|
|
|
|
+ outputURL: URL,
|
|
|
|
|
+ systemSamplesCaptured: Int = 0
|
|
|
|
|
+ ) async -> MeetingAudioExportResult {
|
|
|
do {
|
|
do {
|
|
|
- let url = try await finalizeMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL)
|
|
|
|
|
|
|
+ let url = try await finalizeMeetingAudio(
|
|
|
|
|
+ systemURL: systemURL,
|
|
|
|
|
+ microphoneURL: microphoneURL,
|
|
|
|
|
+ outputURL: outputURL,
|
|
|
|
|
+ systemSamplesCaptured: systemSamplesCaptured
|
|
|
|
|
+ )
|
|
|
return MeetingAudioExportResult(url: url, transcriptWarning: nil)
|
|
return MeetingAudioExportResult(url: url, transcriptWarning: nil)
|
|
|
} catch {
|
|
} catch {
|
|
|
if let salvaged = salvageMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL) {
|
|
if let salvaged = salvageMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL) {
|
|
@@ -408,6 +453,7 @@ final class MeetingRecordingManager: NSObject {
|
|
|
outputAudioURL = nil
|
|
outputAudioURL = nil
|
|
|
micRecorder = nil
|
|
micRecorder = nil
|
|
|
micRecordingActive = false
|
|
micRecordingActive = false
|
|
|
|
|
+ localMicEverActivated = false
|
|
|
systemAudioRecorder = nil
|
|
systemAudioRecorder = nil
|
|
|
captureEmbeddedZoomWebClient = false
|
|
captureEmbeddedZoomWebClient = false
|
|
|
let wasRecording = isRecording
|
|
let wasRecording = isRecording
|
|
@@ -423,7 +469,6 @@ final class MeetingRecordingManager: NSObject {
|
|
|
stopAutomaticStopMonitoringOnMain()
|
|
stopAutomaticStopMonitoringOnMain()
|
|
|
automaticStopConsecutiveMisses = 0
|
|
automaticStopConsecutiveMisses = 0
|
|
|
automaticStopHaveSeenCallUI = false
|
|
automaticStopHaveSeenCallUI = false
|
|
|
- embeddedWebClientCallUIVisible = false
|
|
|
|
|
|
|
|
|
|
automaticStopWorkspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
automaticStopWorkspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
|
forName: NSWorkspace.didTerminateApplicationNotification,
|
|
forName: NSWorkspace.didTerminateApplicationNotification,
|
|
@@ -592,17 +637,122 @@ final class MeetingRecordingManager: NSObject {
|
|
|
|
|
|
|
|
@available(macOS 13.0, *)
|
|
@available(macOS 13.0, *)
|
|
|
private func resolveSystemAudioCaptureTarget() -> ZoomMeetingSystemAudioRecorder.CaptureTarget {
|
|
private func resolveSystemAudioCaptureTarget() -> ZoomMeetingSystemAudioRecorder.CaptureTarget {
|
|
|
- let zoomRunning = NSWorkspace.shared.runningApplications.contains {
|
|
|
|
|
- $0.bundleIdentifier == Self.zoomDesktopBundleID
|
|
|
|
|
- }
|
|
|
|
|
- // Embedded web client: capture the full display audio mix (all participants from Zoom output).
|
|
|
|
|
if captureEmbeddedZoomWebClient {
|
|
if captureEmbeddedZoomWebClient {
|
|
|
|
|
+ if #available(macOS 14.0, *) {
|
|
|
|
|
+ return .hostMeetingWindow
|
|
|
|
|
+ }
|
|
|
|
|
+ return .hostApplication
|
|
|
|
|
+ }
|
|
|
|
|
+ return .display(excludesCurrentProcessAudio: true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(macOS 13.0, *)
|
|
|
|
|
+ private func embeddedSystemAudioCaptureTargets() -> [ZoomMeetingSystemAudioRecorder.CaptureTarget] {
|
|
|
|
|
+ var targets: [ZoomMeetingSystemAudioRecorder.CaptureTarget] = [
|
|
|
|
|
+ .hostApplication,
|
|
|
|
|
+ .display(excludesCurrentProcessAudio: false),
|
|
|
|
|
+ .display(excludesCurrentProcessAudio: true)
|
|
|
|
|
+ ]
|
|
|
|
|
+ if #available(macOS 14.0, *) {
|
|
|
|
|
+ targets.insert(.hostMeetingWindow, at: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ return targets
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(macOS 13.0, *)
|
|
|
|
|
+ private func fallbackSystemAudioCaptureTarget(after primary: ZoomMeetingSystemAudioRecorder.CaptureTarget)
|
|
|
|
|
+ -> ZoomMeetingSystemAudioRecorder.CaptureTarget?
|
|
|
|
|
+ {
|
|
|
|
|
+ switch primary {
|
|
|
|
|
+ case .hostMeetingWindow:
|
|
|
|
|
+ return .hostApplication
|
|
|
|
|
+ case .hostApplication:
|
|
|
return .display(excludesCurrentProcessAudio: false)
|
|
return .display(excludesCurrentProcessAudio: false)
|
|
|
|
|
+ case .display(excludesCurrentProcessAudio: false):
|
|
|
|
|
+ return .display(excludesCurrentProcessAudio: true)
|
|
|
|
|
+ case .display(excludesCurrentProcessAudio: true), .zoomDesktop:
|
|
|
|
|
+ return nil
|
|
|
}
|
|
}
|
|
|
- if zoomRunning {
|
|
|
|
|
- return .zoomDesktop
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Briefly tries each capture mode and picks the one that receives the most meeting audio samples.
|
|
|
|
|
+ @available(macOS 13.0, *)
|
|
|
|
|
+ private func probeBestSystemAudioCaptureTarget(
|
|
|
|
|
+ in directory: URL,
|
|
|
|
|
+ targets: [ZoomMeetingSystemAudioRecorder.CaptureTarget]
|
|
|
|
|
+ ) async -> ZoomMeetingSystemAudioRecorder.CaptureTarget? {
|
|
|
|
|
+ var bestTarget: ZoomMeetingSystemAudioRecorder.CaptureTarget?
|
|
|
|
|
+ var bestSamples = 0
|
|
|
|
|
+
|
|
|
|
|
+ for target in targets {
|
|
|
|
|
+ let probeURL = directory.appendingPathComponent("probe-\(UUID().uuidString).m4a")
|
|
|
|
|
+ let recorder = ZoomMeetingSystemAudioRecorder(outputURL: probeURL, captureTarget: target)
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await recorder.start()
|
|
|
|
|
+ try await Task.sleep(nanoseconds: 2_500_000_000)
|
|
|
|
|
+ let samples = recorder.capturedSampleCount
|
|
|
|
|
+ try await recorder.stop()
|
|
|
|
|
+ try? FileManager.default.removeItem(at: probeURL)
|
|
|
|
|
+ if samples > bestSamples {
|
|
|
|
|
+ bestSamples = samples
|
|
|
|
|
+ bestTarget = target
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ try? FileManager.default.removeItem(at: probeURL)
|
|
|
|
|
+ NSLog("MeetingRecordingManager: probe failed for \(target) — \(error.localizedDescription)")
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- return .display(excludesCurrentProcessAudio: false)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if let bestTarget {
|
|
|
|
|
+ NSLog("MeetingRecordingManager: probed meeting audio — \(bestSamples) samples with \(bestTarget).")
|
|
|
|
|
+ }
|
|
|
|
|
+ return bestTarget
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(macOS 13.0, *)
|
|
|
|
|
+ private func startSystemAudioRecorder(
|
|
|
|
|
+ outputURL: URL,
|
|
|
|
|
+ primaryTarget: ZoomMeetingSystemAudioRecorder.CaptureTarget
|
|
|
|
|
+ ) async throws -> ZoomMeetingSystemAudioRecorder {
|
|
|
|
|
+ let targets: [ZoomMeetingSystemAudioRecorder.CaptureTarget]
|
|
|
|
|
+ if captureEmbeddedZoomWebClient {
|
|
|
|
|
+ targets = embeddedSystemAudioCaptureTargets()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ var chain: [ZoomMeetingSystemAudioRecorder.CaptureTarget] = [primaryTarget]
|
|
|
|
|
+ var cursor = primaryTarget
|
|
|
|
|
+ while let next = fallbackSystemAudioCaptureTarget(after: cursor) {
|
|
|
|
|
+ chain.append(next)
|
|
|
|
|
+ cursor = next
|
|
|
|
|
+ }
|
|
|
|
|
+ targets = chain
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let chosenTarget: ZoomMeetingSystemAudioRecorder.CaptureTarget
|
|
|
|
|
+ if captureEmbeddedZoomWebClient, targets.count > 1 {
|
|
|
|
|
+ let directory = outputURL.deletingLastPathComponent()
|
|
|
|
|
+ chosenTarget = await probeBestSystemAudioCaptureTarget(in: directory, targets: targets)
|
|
|
|
|
+ ?? targets[0]
|
|
|
|
|
+ } else {
|
|
|
|
|
+ chosenTarget = targets[0]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var target = chosenTarget
|
|
|
|
|
+ var lastError: Error?
|
|
|
|
|
+ while true {
|
|
|
|
|
+ let recorder = ZoomMeetingSystemAudioRecorder(outputURL: outputURL, captureTarget: target)
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await recorder.start()
|
|
|
|
|
+ return recorder
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ lastError = error
|
|
|
|
|
+ guard let next = fallbackSystemAudioCaptureTarget(after: target) else { break }
|
|
|
|
|
+ NSLog("MeetingRecordingManager: system audio start failed for \(target); trying \(next).")
|
|
|
|
|
+ target = next
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw lastError ?? RecordingError.systemAudioUnavailable(
|
|
|
|
|
+ "Could not start system audio capture. Allow Screen Recording for this app in System Settings."
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@available(macOS 13.0, *)
|
|
@available(macOS 13.0, *)
|
|
@@ -611,6 +761,11 @@ final class MeetingRecordingManager: NSObject {
|
|
|
CGRequestScreenCaptureAccess()
|
|
CGRequestScreenCaptureAccess()
|
|
|
try? await Task.sleep(nanoseconds: 800_000_000)
|
|
try? await Task.sleep(nanoseconds: 800_000_000)
|
|
|
}
|
|
}
|
|
|
|
|
+ guard CGPreflightScreenCaptureAccess() else {
|
|
|
|
|
+ throw RecordingError.systemAudioUnavailable(
|
|
|
|
|
+ "Allow Screen Recording for this app in System Settings → Privacy & Security → Screen Recording."
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
do {
|
|
do {
|
|
|
_ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
|
|
_ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
|
|
|
} catch {
|
|
} catch {
|
|
@@ -645,6 +800,7 @@ final class MeetingRecordingManager: NSObject {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
micRecordingActive = true
|
|
micRecordingActive = true
|
|
|
|
|
+ localMicEverActivated = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func pauseMicRecording() {
|
|
private func pauseMicRecording() {
|
|
@@ -661,7 +817,8 @@ final class MeetingRecordingManager: NSObject {
|
|
|
if shouldFinalize {
|
|
if shouldFinalize {
|
|
|
recorder.stop()
|
|
recorder.stop()
|
|
|
}
|
|
}
|
|
|
- if discardFile || fileSize(at: url) < 256 {
|
|
|
|
|
|
|
+ let dropMicTrack = discardFile || localMicEverActivated == false || fileSize(at: url) < 256
|
|
|
|
|
+ if dropMicTrack {
|
|
|
try? FileManager.default.removeItem(at: url)
|
|
try? FileManager.default.removeItem(at: url)
|
|
|
}
|
|
}
|
|
|
micRecorder = nil
|
|
micRecorder = nil
|
|
@@ -687,21 +844,30 @@ final class MeetingRecordingManager: NSObject {
|
|
|
return size >= 512
|
|
return size >= 512
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
|
|
|
|
|
|
|
+ private func finalizeMeetingAudio(
|
|
|
|
|
+ systemURL: URL,
|
|
|
|
|
+ microphoneURL: URL,
|
|
|
|
|
+ outputURL: URL,
|
|
|
|
|
+ systemSamplesCaptured: Int = 0
|
|
|
|
|
+ ) async throws -> URL {
|
|
|
let fm = FileManager.default
|
|
let fm = FileManager.default
|
|
|
if fm.fileExists(atPath: outputURL.path) {
|
|
if fm.fileExists(atPath: outputURL.path) {
|
|
|
try? fm.removeItem(at: outputURL)
|
|
try? fm.removeItem(at: outputURL)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let hasSystem = hasUsableAudio(at: systemURL)
|
|
|
|
|
- let hasMic = hasUsableAudio(at: microphoneURL)
|
|
|
|
|
|
|
+ var hasSystem = hasUsableAudio(at: systemURL)
|
|
|
|
|
+ if hasSystem == false, systemSamplesCaptured > 0, fileSize(at: systemURL) >= 64 {
|
|
|
|
|
+ hasSystem = true
|
|
|
|
|
+ }
|
|
|
|
|
+ // Meeting mix is primary; local mic is merged only if the user spoke while unmuted in Zoom.
|
|
|
|
|
+ let hasMic = localMicEverActivated && hasUsableAudio(at: microphoneURL)
|
|
|
|
|
|
|
|
if hasSystem && hasMic {
|
|
if hasSystem && hasMic {
|
|
|
do {
|
|
do {
|
|
|
try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: outputURL)
|
|
try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: outputURL)
|
|
|
return outputURL
|
|
return outputURL
|
|
|
} catch {
|
|
} catch {
|
|
|
- // Fall through to single-track handling.
|
|
|
|
|
|
|
+ NSLog("MeetingRecordingManager: mix failed — \(error.localizedDescription); exporting meeting audio only.")
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -732,38 +898,47 @@ final class MeetingRecordingManager: NSObject {
|
|
|
private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
|
|
private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
|
|
|
try? FileManager.default.removeItem(at: destinationURL)
|
|
try? FileManager.default.removeItem(at: destinationURL)
|
|
|
|
|
|
|
|
- let composition = AVMutableComposition()
|
|
|
|
|
- guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
|
|
|
|
|
- let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
|
|
|
|
- throw RecordingError.exportFailed
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
let systemAsset = AVURLAsset(url: systemURL)
|
|
let systemAsset = AVURLAsset(url: systemURL)
|
|
|
let micAsset = AVURLAsset(url: microphoneURL)
|
|
let micAsset = AVURLAsset(url: microphoneURL)
|
|
|
|
|
|
|
|
- if let src = systemAsset.tracks(withMediaType: .audio).first {
|
|
|
|
|
- let duration = systemAsset.duration
|
|
|
|
|
- if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
|
|
|
|
|
- try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ let systemSourceTracks = try await systemAsset.loadTracks(withMediaType: .audio)
|
|
|
|
|
+ guard let systemSource = systemSourceTracks.first else {
|
|
|
|
|
+ throw RecordingError.exportFailed
|
|
|
}
|
|
}
|
|
|
- if let src = micAsset.tracks(withMediaType: .audio).first {
|
|
|
|
|
- let duration = micAsset.duration
|
|
|
|
|
- if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
|
|
|
|
|
- try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ let systemDuration = try await systemAsset.load(.duration)
|
|
|
|
|
+ guard systemDuration.isValid, !systemDuration.isIndefinite, systemDuration.seconds > 0 else {
|
|
|
|
|
+ throw RecordingError.exportFailed
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
|
|
|
|
|
|
|
+ let composition = AVMutableComposition()
|
|
|
|
|
+ guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
|
|
throw RecordingError.exportFailed
|
|
throw RecordingError.exportFailed
|
|
|
}
|
|
}
|
|
|
|
|
+ try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: systemDuration), of: systemSource, at: .zero)
|
|
|
|
|
|
|
|
|
|
+ var mixParameters: [AVMutableAudioMixInputParameters] = []
|
|
|
let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
|
|
let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
|
|
|
systemMix.setVolume(1.0, at: .zero)
|
|
systemMix.setVolume(1.0, at: .zero)
|
|
|
- let micMix = AVMutableAudioMixInputParameters(track: micTrack)
|
|
|
|
|
- micMix.setVolume(0.9, at: .zero)
|
|
|
|
|
|
|
+ mixParameters.append(systemMix)
|
|
|
|
|
+
|
|
|
|
|
+ let micSourceTracks = try await micAsset.loadTracks(withMediaType: .audio)
|
|
|
|
|
+ if let micSource = micSourceTracks.first {
|
|
|
|
|
+ let micDuration = try await micAsset.load(.duration)
|
|
|
|
|
+ if micDuration.isValid, !micDuration.isIndefinite, micDuration.seconds > 0,
|
|
|
|
|
+ let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
|
|
|
|
|
+ try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: micDuration), of: micSource, at: .zero)
|
|
|
|
|
+ let micMix = AVMutableAudioMixInputParameters(track: micTrack)
|
|
|
|
|
+ micMix.setVolume(0.55, at: .zero)
|
|
|
|
|
+ mixParameters.append(micMix)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
|
|
|
|
|
+ throw RecordingError.exportFailed
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
let audioMix = AVMutableAudioMix()
|
|
let audioMix = AVMutableAudioMix()
|
|
|
- audioMix.inputParameters = [systemMix, micMix]
|
|
|
|
|
|
|
+ audioMix.inputParameters = mixParameters
|
|
|
|
|
|
|
|
export.outputURL = destinationURL
|
|
export.outputURL = destinationURL
|
|
|
export.outputFileType = .m4a
|
|
export.outputFileType = .m4a
|
|
@@ -846,6 +1021,8 @@ final class MeetingRecordingManager: NSObject {
|
|
|
@available(macOS 13.0, *)
|
|
@available(macOS 13.0, *)
|
|
|
private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
|
enum CaptureTarget {
|
|
enum CaptureTarget {
|
|
|
|
|
+ /// Embedded Zoom `WKWebView` window (macOS 14+).
|
|
|
|
|
+ case hostMeetingWindow
|
|
|
case hostApplication
|
|
case hostApplication
|
|
|
case zoomDesktop
|
|
case zoomDesktop
|
|
|
case display(excludesCurrentProcessAudio: Bool)
|
|
case display(excludesCurrentProcessAudio: Bool)
|
|
@@ -887,41 +1064,113 @@ private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SC
|
|
|
config.capturesAudio = true
|
|
config.capturesAudio = true
|
|
|
config.sampleRate = 48_000
|
|
config.sampleRate = 48_000
|
|
|
config.channelCount = 2
|
|
config.channelCount = 2
|
|
|
|
|
+ config.queueDepth = 16
|
|
|
switch captureTarget {
|
|
switch captureTarget {
|
|
|
- case .hostApplication, .display(excludesCurrentProcessAudio: false):
|
|
|
|
|
|
|
+ case .hostMeetingWindow, .hostApplication, .display(excludesCurrentProcessAudio: false):
|
|
|
config.excludesCurrentProcessAudio = false
|
|
config.excludesCurrentProcessAudio = false
|
|
|
case .zoomDesktop:
|
|
case .zoomDesktop:
|
|
|
config.excludesCurrentProcessAudio = true
|
|
config.excludesCurrentProcessAudio = true
|
|
|
case .display(excludesCurrentProcessAudio: true):
|
|
case .display(excludesCurrentProcessAudio: true):
|
|
|
config.excludesCurrentProcessAudio = true
|
|
config.excludesCurrentProcessAudio = true
|
|
|
}
|
|
}
|
|
|
|
|
+ if #available(macOS 15.0, *) {
|
|
|
|
|
+ config.captureMicrophone = false
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
|
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
|
|
self.stream = stream
|
|
self.stream = stream
|
|
|
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
|
|
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
|
|
|
|
|
+ // Some macOS versions only deliver meeting audio when a screen output is also attached.
|
|
|
|
|
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: outputQueue)
|
|
|
try await stream.startCapture()
|
|
try await stream.startCapture()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func makeContentFilter(from content: SCShareableContent) throws -> SCContentFilter {
|
|
private func makeContentFilter(from content: SCShareableContent) throws -> SCContentFilter {
|
|
|
- guard let display = content.displays.first else {
|
|
|
|
|
|
|
+ guard let display = Self.preferredDisplay(from: content, captureTarget: captureTarget) else {
|
|
|
throw MeetingRecordingManager.RecordingError.noShareableContent
|
|
throw MeetingRecordingManager.RecordingError.noShareableContent
|
|
|
}
|
|
}
|
|
|
let emptyWindows: [SCWindow] = []
|
|
let emptyWindows: [SCWindow] = []
|
|
|
|
|
+ let hostBundleID = Bundle.main.bundleIdentifier ?? ""
|
|
|
switch captureTarget {
|
|
switch captureTarget {
|
|
|
|
|
+ case .hostMeetingWindow:
|
|
|
|
|
+ if #available(macOS 14.0, *), let window = Self.largestHostWindow(from: content) {
|
|
|
|
|
+ return SCContentFilter(desktopIndependentWindow: window)
|
|
|
|
|
+ }
|
|
|
|
|
+ NSLog("ZoomMeetingSystemAudioRecorder: no host meeting window; falling back to app audio.")
|
|
|
|
|
+ fallthrough
|
|
|
case .hostApplication:
|
|
case .hostApplication:
|
|
|
- if let app = content.applications.first(where: { $0.bundleIdentifier == Bundle.main.bundleIdentifier }) {
|
|
|
|
|
|
|
+ if let app = content.applications.first(where: { $0.bundleIdentifier == hostBundleID }) {
|
|
|
return SCContentFilter(display: display, including: [app], exceptingWindows: emptyWindows)
|
|
return SCContentFilter(display: display, including: [app], exceptingWindows: emptyWindows)
|
|
|
}
|
|
}
|
|
|
|
|
+ NSLog("ZoomMeetingSystemAudioRecorder: host app not listed in shareable content; using full display audio.")
|
|
|
case .zoomDesktop:
|
|
case .zoomDesktop:
|
|
|
if let zoom = content.applications.first(where: { $0.bundleIdentifier == Self.zoomDesktopBundleID }) {
|
|
if let zoom = content.applications.first(where: { $0.bundleIdentifier == Self.zoomDesktopBundleID }) {
|
|
|
return SCContentFilter(display: display, including: [zoom], exceptingWindows: emptyWindows)
|
|
return SCContentFilter(display: display, including: [zoom], exceptingWindows: emptyWindows)
|
|
|
}
|
|
}
|
|
|
|
|
+ NSLog("ZoomMeetingSystemAudioRecorder: Zoom desktop not listed; using full display audio.")
|
|
|
case .display:
|
|
case .display:
|
|
|
break
|
|
break
|
|
|
}
|
|
}
|
|
|
return SCContentFilter(display: display, excludingApplications: [], exceptingWindows: emptyWindows)
|
|
return SCContentFilter(display: display, excludingApplications: [], exceptingWindows: emptyWindows)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Prefer the display showing Zoom or this app instead of an arbitrary `displays.first` (often wrong on multi-monitor setups).
|
|
|
|
|
+ private static func preferredDisplay(
|
|
|
|
|
+ from content: SCShareableContent,
|
|
|
|
|
+ captureTarget: CaptureTarget
|
|
|
|
|
+ ) -> SCDisplay? {
|
|
|
|
|
+ let displays = content.displays
|
|
|
|
|
+ guard displays.isEmpty == false else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ let mainDisplayID = CGMainDisplayID()
|
|
|
|
|
+ if let main = displays.first(where: { $0.displayID == mainDisplayID }) {
|
|
|
|
|
+ return main
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let bundleIDsToMatch: Set<String>
|
|
|
|
|
+ switch captureTarget {
|
|
|
|
|
+ case .hostMeetingWindow, .hostApplication:
|
|
|
|
|
+ bundleIDsToMatch = [Bundle.main.bundleIdentifier ?? ""]
|
|
|
|
|
+ case .zoomDesktop:
|
|
|
|
|
+ bundleIDsToMatch = [zoomDesktopBundleID]
|
|
|
|
|
+ case .display:
|
|
|
|
|
+ bundleIDsToMatch = [zoomDesktopBundleID, Bundle.main.bundleIdentifier ?? ""]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for window in content.windows where window.isOnScreen {
|
|
|
|
|
+ guard let ownerBundleID = window.owningApplication?.bundleIdentifier,
|
|
|
|
|
+ bundleIDsToMatch.contains(ownerBundleID) else { continue }
|
|
|
|
|
+ let center = CGPoint(x: window.frame.midX, y: window.frame.midY)
|
|
|
|
|
+ if let match = displayContaining(point: center, in: displays) {
|
|
|
|
|
+ return match
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return displays.first
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func displayContaining(point: CGPoint, in displays: [SCDisplay]) -> SCDisplay? {
|
|
|
|
|
+ for display in displays {
|
|
|
|
|
+ let bounds = CGDisplayBounds(display.displayID)
|
|
|
|
|
+ if bounds.contains(point) {
|
|
|
|
|
+ return display
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Largest on-screen window owned by this app (embedded Zoom web client).
|
|
|
|
|
+ private static func largestHostWindow(from content: SCShareableContent) -> SCWindow? {
|
|
|
|
|
+ let hostBundleID = Bundle.main.bundleIdentifier ?? ""
|
|
|
|
|
+ return content.windows
|
|
|
|
|
+ .filter { $0.isOnScreen && $0.owningApplication?.bundleIdentifier == hostBundleID }
|
|
|
|
|
+ .max { lhs, rhs in
|
|
|
|
|
+ let lArea = lhs.frame.width * lhs.frame.height
|
|
|
|
|
+ let rArea = rhs.frame.width * rhs.frame.height
|
|
|
|
|
+ return lArea < rArea
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
func stop() async throws {
|
|
func stop() async throws {
|
|
|
if let stream {
|
|
if let stream {
|
|
|
try? await stream.stopCapture()
|
|
try? await stream.stopCapture()
|