MeetingRecordingManager.swift 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  1. import AppKit
  2. import Foundation
  3. import AVFoundation
  4. import ScreenCaptureKit
  5. import CoreMedia
  6. final class MeetingRecordingManager: NSObject {
  7. static let shared = MeetingRecordingManager()
  8. struct RecordedMeeting: Codable, Equatable, Identifiable {
  9. let id: String
  10. let startedAt: Date
  11. let endedAt: Date
  12. let sourceURLString: String?
  13. /// Human-readable title for UI and for the saved file base name (older index entries omit this key).
  14. let meetingDisplayName: String?
  15. let audioFilename: String
  16. /// Apple Speech transcript (timeline text). Omitted in older `index.json` entries.
  17. let transcriptText: String?
  18. let transcriptError: String?
  19. /// GPT-generated notes (`notRequested` / `processing` / `ready` / `failed`). Omitted in older entries.
  20. let notesStatusRaw: String?
  21. let notesText: String?
  22. let notesError: String?
  23. var audioFilenameURLSafe: String { audioFilename }
  24. var resolvedDisplayTitle: String {
  25. let raw = meetingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  26. return raw.isEmpty ? "Meeting recording" : raw
  27. }
  28. }
  29. enum RecordingError: Error, LocalizedError {
  30. case alreadyRecording
  31. case notRecording
  32. case microphonePermissionDenied
  33. case noShareableContent
  34. case systemAudioUnavailable(String)
  35. case exportFailed
  36. case micCaptureFailed(String)
  37. var errorDescription: String? {
  38. switch self {
  39. case .alreadyRecording:
  40. return "A recording is already in progress. Stop it first, then start again."
  41. case .notRecording:
  42. return "Nothing is being recorded right now."
  43. case .microphonePermissionDenied:
  44. return "Microphone access was denied. Turn it on in System Settings → Privacy & Security → Microphone for this app."
  45. case .noShareableContent:
  46. return "Could not access a display for system audio capture."
  47. case .systemAudioUnavailable(let reason):
  48. return "System audio capture failed (\(reason)). Microphone-only recording may still work."
  49. case .exportFailed:
  50. return "Could not save the recording file. Check free disk space and try again."
  51. case .micCaptureFailed(let reason):
  52. return "Microphone recording failed: \(reason)"
  53. }
  54. }
  55. }
  56. static let recordingsDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingsDidChange")
  57. static let recordingStateDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingStateDidChange")
  58. static let transcriptionDidBeginNotification = Notification.Name("MeetingRecordingManager.transcriptionDidBegin")
  59. static let transcriptionDidEndNotification = Notification.Name("MeetingRecordingManager.transcriptionDidEnd")
  60. private let encoder: JSONEncoder = {
  61. let e = JSONEncoder()
  62. e.outputFormatting = [.prettyPrinted, .sortedKeys]
  63. e.dateEncodingStrategy = .iso8601
  64. return e
  65. }()
  66. private let decoder: JSONDecoder = {
  67. let d = JSONDecoder()
  68. d.dateDecodingStrategy = .iso8601
  69. return d
  70. }()
  71. private(set) var recordings: [RecordedMeeting] = []
  72. private(set) var isRecording: Bool = false
  73. /// Elapsed time since the current recording session began (0 when not recording).
  74. var currentRecordingElapsed: TimeInterval {
  75. guard let startedAt = recordingStartedAt else { return 0 }
  76. return Date().timeIntervalSince(startedAt)
  77. }
  78. /// Grace period after recording starts before ambiguous “left meeting” signals may auto-stop.
  79. static let recordingLeaveGraceInterval: TimeInterval = automaticStopGraceInterval
  80. private var recordingID: String?
  81. private var recordingStartedAt: Date?
  82. private var recordingSourceURLString: String?
  83. private var pendingMeetingDisplayName: String?
  84. /// Microphone capture (same approach as meetings_app: `AVAudioRecorder` → AAC `.m4a`).
  85. private var micRecorder: AVAudioRecorder?
  86. /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
  87. private var systemAudioRecorder: AnyObject?
  88. // Output
  89. private var outputAudioURL: URL?
  90. // MARK: - Automatic stop (meeting left / ended)
  91. private static let automaticStopPollInterval: TimeInterval = 2
  92. /// Do not treat "no meeting UI" as leave until the user has had time to connect.
  93. private static let automaticStopGraceInterval: TimeInterval = 20
  94. /// After grace, if we never saw meeting UI (common when window titles are hidden), still allow auto-stop
  95. /// after this many seconds of consecutive "not visible" polls so recording cannot run forever.
  96. private static let automaticStopBlindPresenceDelay: TimeInterval = 120
  97. /// Consecutive polls with no call UI after a prior positive detection (avoids flapping during layout changes).
  98. private static let automaticStopMissThreshold = 2
  99. private static let zoomDesktopBundleID = "us.zoom.xos"
  100. private var automaticStopTimer: Timer?
  101. private var automaticStopWorkspaceObserver: NSObjectProtocol?
  102. private var automaticStopConsecutiveMisses: Int = 0
  103. private var automaticStopHaveSeenCallUI: Bool = false
  104. /// Set by the embedded `WKWebView` when its URL indicates an active join/in-call web client page.
  105. private var embeddedWebClientCallUIVisible: Bool = false
  106. private var stopRecordingInFlight: Bool = false
  107. private override init() {
  108. super.init()
  109. loadRecordingsFromDisk()
  110. }
  111. // MARK: - Public API
  112. func recordingsDirectoryURL() throws -> URL {
  113. let fm = FileManager.default
  114. let appSupport = try fm.url(
  115. for: .applicationSupportDirectory,
  116. in: .userDomainMask,
  117. appropriateFor: nil,
  118. create: true
  119. )
  120. // Sandboxed apps: `appSupport` is inside the app container (Finder: ~/Library/Containers/…/Data/Library/Application Support/).
  121. // Use bundle ID so the folder matches this app (not a generic name).
  122. let bundleFolder = Bundle.main.bundleIdentifier ?? "zoom_app"
  123. let dir = appSupport.appendingPathComponent(bundleFolder, isDirectory: true)
  124. .appendingPathComponent("Recordings", isDirectory: true)
  125. if fm.fileExists(atPath: dir.path) == false {
  126. try fm.createDirectory(at: dir, withIntermediateDirectories: true)
  127. }
  128. return dir
  129. }
  130. func audioURL(for recording: RecordedMeeting) throws -> URL {
  131. try recordingsDirectoryURL().appendingPathComponent(recording.audioFilename)
  132. }
  133. /// Persists AI notes fields for a recording and posts `recordingsDidChangeNotification`.
  134. func setMeetingNotes(recordingId: String, notesStatusRaw: String?, notesText: String?, notesError: String?) {
  135. guard let idx = recordings.firstIndex(where: { $0.id == recordingId }) else { return }
  136. let old = recordings[idx]
  137. let updated = RecordedMeeting(
  138. id: old.id,
  139. startedAt: old.startedAt,
  140. endedAt: old.endedAt,
  141. sourceURLString: old.sourceURLString,
  142. meetingDisplayName: old.meetingDisplayName,
  143. audioFilename: old.audioFilename,
  144. transcriptText: old.transcriptText,
  145. transcriptError: old.transcriptError,
  146. notesStatusRaw: notesStatusRaw,
  147. notesText: notesText,
  148. notesError: notesError
  149. )
  150. recordings[idx] = updated
  151. saveRecordingsToDisk()
  152. NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
  153. }
  154. /// Full path to the Recordings folder (sandboxed apps: inside `~/Library/Containers/…/Data/Library/Application Support/`).
  155. var recordingsDirectoryPath: String {
  156. (try? recordingsDirectoryURL().path) ?? ""
  157. }
  158. func revealRecordingsInFinder() {
  159. guard let url = try? recordingsDirectoryURL() else { return }
  160. NSWorkspace.shared.open(url)
  161. }
  162. func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws {
  163. if isRecording { throw RecordingError.alreadyRecording }
  164. let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
  165. if micGranted == false { throw RecordingError.microphonePermissionDenied }
  166. let id = UUID().uuidString
  167. let startedAt = Date()
  168. let dir = try recordingsDirectoryURL()
  169. let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
  170. let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
  171. let displayTitle = Self.resolvedMeetingTitle(explicit: meetingDisplayName, sourceURL: sourceURL)
  172. let outBase = Self.uniqueSanitizedFileURL(directory: dir, baseName: displayTitle, idSuffix: id, extension: "m4a")
  173. recordingID = id
  174. recordingStartedAt = startedAt
  175. recordingSourceURLString = sourceURL?.absoluteString
  176. pendingMeetingDisplayName = displayTitle
  177. outputAudioURL = outBase
  178. do {
  179. try startMicRecorder(to: micURL)
  180. if #available(macOS 13.0, *) {
  181. let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL)
  182. systemAudioRecorder = recorder
  183. do {
  184. try await recorder.start()
  185. } catch {
  186. systemAudioRecorder = nil
  187. }
  188. } else {
  189. systemAudioRecorder = nil
  190. }
  191. } catch {
  192. stopMicRecorder()
  193. if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
  194. try? await r.stop()
  195. }
  196. systemAudioRecorder = nil
  197. recordingID = nil
  198. recordingStartedAt = nil
  199. recordingSourceURLString = nil
  200. pendingMeetingDisplayName = nil
  201. outputAudioURL = nil
  202. throw error
  203. }
  204. isRecording = true
  205. NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
  206. await MainActor.run {
  207. self.startAutomaticStopMonitoringOnMain()
  208. }
  209. }
  210. /// Embedded Zoom web client reports whether the current page looks like an active join / in-call flow.
  211. @MainActor
  212. func setEmbeddedWebClientCallUIVisible(_ visible: Bool) {
  213. embeddedWebClientCallUIVisible = visible
  214. if visible {
  215. automaticStopHaveSeenCallUI = true
  216. automaticStopConsecutiveMisses = 0
  217. }
  218. }
  219. func stopRecording() async throws {
  220. await MainActor.run {
  221. self.stopAutomaticStopMonitoringOnMain()
  222. }
  223. guard stopRecordingInFlight == false else { return }
  224. guard isRecording else { throw RecordingError.notRecording }
  225. stopRecordingInFlight = true
  226. defer { stopRecordingInFlight = false }
  227. guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
  228. throw RecordingError.exportFailed
  229. }
  230. let endedAt = Date()
  231. let dir = try recordingsDirectoryURL()
  232. let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
  233. let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
  234. stopMicRecorder()
  235. if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
  236. try? await r.stop()
  237. }
  238. systemAudioRecorder = nil
  239. let mixedURL = try await finalizeMeetingAudio(systemURL: sysURL, microphoneURL: micURL, outputURL: outURL)
  240. // Cleanup intermediates (ignore failures).
  241. try? FileManager.default.removeItem(at: micURL)
  242. try? FileManager.default.removeItem(at: sysURL)
  243. let displayName = pendingMeetingDisplayName
  244. let rec = RecordedMeeting(
  245. id: id,
  246. startedAt: startedAt,
  247. endedAt: endedAt,
  248. sourceURLString: recordingSourceURLString,
  249. meetingDisplayName: displayName,
  250. audioFilename: mixedURL.lastPathComponent,
  251. transcriptText: nil,
  252. transcriptError: nil,
  253. notesStatusRaw: nil,
  254. notesText: nil,
  255. notesError: nil
  256. )
  257. recordings.insert(rec, at: 0)
  258. saveRecordingsToDisk()
  259. scheduleAppleTranscription(for: rec)
  260. recordingID = nil
  261. recordingStartedAt = nil
  262. recordingSourceURLString = nil
  263. pendingMeetingDisplayName = nil
  264. outputAudioURL = nil
  265. isRecording = false
  266. NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
  267. NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
  268. }
  269. @MainActor
  270. private func startAutomaticStopMonitoringOnMain() {
  271. assert(Thread.isMainThread)
  272. stopAutomaticStopMonitoringOnMain()
  273. automaticStopConsecutiveMisses = 0
  274. automaticStopHaveSeenCallUI = false
  275. embeddedWebClientCallUIVisible = false
  276. automaticStopWorkspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
  277. forName: NSWorkspace.didTerminateApplicationNotification,
  278. object: nil,
  279. queue: .main
  280. ) { [weak self] note in
  281. self?.handleZoomDesktopClientTerminated(note)
  282. }
  283. let timer = Timer(timeInterval: Self.automaticStopPollInterval, repeats: true) { [weak self] _ in
  284. self?.automaticStopPresenceTimerFired()
  285. }
  286. RunLoop.main.add(timer, forMode: .common)
  287. automaticStopTimer = timer
  288. }
  289. @MainActor
  290. private func stopAutomaticStopMonitoringOnMain() {
  291. assert(Thread.isMainThread)
  292. automaticStopTimer?.invalidate()
  293. automaticStopTimer = nil
  294. if let token = automaticStopWorkspaceObserver {
  295. NSWorkspace.shared.notificationCenter.removeObserver(token)
  296. automaticStopWorkspaceObserver = nil
  297. }
  298. automaticStopConsecutiveMisses = 0
  299. automaticStopHaveSeenCallUI = false
  300. embeddedWebClientCallUIVisible = false
  301. }
  302. @MainActor
  303. private func automaticStopPresenceTimerFired() {
  304. guard isRecording else {
  305. stopAutomaticStopMonitoringOnMain()
  306. return
  307. }
  308. guard let startedAt = recordingStartedAt else { return }
  309. let elapsed = Date().timeIntervalSince(startedAt)
  310. if elapsed < Self.automaticStopGraceInterval { return }
  311. let visible = embeddedWebClientCallUIVisible || ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
  312. if visible {
  313. automaticStopHaveSeenCallUI = true
  314. automaticStopConsecutiveMisses = 0
  315. return
  316. }
  317. if automaticStopHaveSeenCallUI {
  318. automaticStopConsecutiveMisses += 1
  319. } else if elapsed >= Self.automaticStopGraceInterval + Self.automaticStopBlindPresenceDelay {
  320. automaticStopConsecutiveMisses += 1
  321. } else {
  322. return
  323. }
  324. guard automaticStopConsecutiveMisses >= Self.automaticStopMissThreshold else { return }
  325. Task { [weak self] in
  326. guard let self else { return }
  327. try? await self.stopRecording()
  328. }
  329. }
  330. @MainActor
  331. private func handleZoomDesktopClientTerminated(_ note: Notification) {
  332. guard isRecording else { return }
  333. guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
  334. guard app.bundleIdentifier == Self.zoomDesktopBundleID else { return }
  335. guard let startedAt = recordingStartedAt, Date().timeIntervalSince(startedAt) >= Self.automaticStopGraceInterval else { return }
  336. guard automaticStopHaveSeenCallUI else { return }
  337. Task { [weak self] in
  338. guard let self else { return }
  339. try? await self.stopRecording()
  340. }
  341. }
  342. // MARK: - Persistence
  343. private func indexURL() throws -> URL {
  344. try recordingsDirectoryURL().appendingPathComponent("index.json")
  345. }
  346. private func loadRecordingsFromDisk() {
  347. do {
  348. let url = try indexURL()
  349. guard FileManager.default.fileExists(atPath: url.path) else { return }
  350. let data = try Data(contentsOf: url)
  351. recordings = try decoder.decode([RecordedMeeting].self, from: data)
  352. } catch {
  353. recordings = []
  354. }
  355. }
  356. /// Runs Apple Speech on the saved mixed audio; updates `recordings` and posts `recordingsDidChangeNotification` when finished.
  357. private func scheduleAppleTranscription(for recording: RecordedMeeting) {
  358. let recordingId = recording.id
  359. let filename = recording.audioFilename
  360. let locales = TranscriptionLanguagePreferences.localesForAppleTranscription()
  361. Task {
  362. await performAppleTranscription(recordingId: recordingId, audioFilename: filename, locales: locales)
  363. }
  364. }
  365. private func performAppleTranscription(recordingId: String, audioFilename: String, locales: [Locale]) async {
  366. NotificationCenter.default.post(
  367. name: Self.transcriptionDidBeginNotification,
  368. object: self,
  369. userInfo: ["recordingId": recordingId]
  370. )
  371. defer {
  372. NotificationCenter.default.post(
  373. name: Self.transcriptionDidEndNotification,
  374. object: self,
  375. userInfo: ["recordingId": recordingId]
  376. )
  377. }
  378. let service = MeetingTranscriptionService()
  379. do {
  380. let dir = try recordingsDirectoryURL()
  381. let audioURL = dir.appendingPathComponent(audioFilename)
  382. let segments = try await service.transcribeMeeting(micURL: audioURL, systemURL: nil, locales: locales)
  383. let text = segments.renderedTimelineText()
  384. await MainActor.run {
  385. applyTranscript(recordingId: recordingId, text: text.isEmpty ? nil : text, error: nil)
  386. }
  387. } catch {
  388. await MainActor.run {
  389. applyTranscript(recordingId: recordingId, text: nil, error: error.localizedDescription)
  390. }
  391. }
  392. }
  393. private func applyTranscript(recordingId: String, text: String?, error: String?) {
  394. guard let idx = recordings.firstIndex(where: { $0.id == recordingId }) else { return }
  395. let old = recordings[idx]
  396. let updated = RecordedMeeting(
  397. id: old.id,
  398. startedAt: old.startedAt,
  399. endedAt: old.endedAt,
  400. sourceURLString: old.sourceURLString,
  401. meetingDisplayName: old.meetingDisplayName,
  402. audioFilename: old.audioFilename,
  403. transcriptText: text,
  404. transcriptError: error,
  405. notesStatusRaw: old.notesStatusRaw,
  406. notesText: old.notesText,
  407. notesError: old.notesError
  408. )
  409. recordings[idx] = updated
  410. saveRecordingsToDisk()
  411. NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
  412. }
  413. private func saveRecordingsToDisk() {
  414. do {
  415. let url = try indexURL()
  416. let data = try encoder.encode(recordings)
  417. try data.write(to: url, options: [.atomic])
  418. } catch {
  419. NSLog("MeetingRecordingManager: failed to save index.json — \(error.localizedDescription)")
  420. }
  421. }
  422. // MARK: - Mic capture (meetings_app-style)
  423. private func startMicRecorder(to outputURL: URL) throws {
  424. try? FileManager.default.removeItem(at: outputURL)
  425. let settings: [String: Any] = [
  426. AVFormatIDKey: kAudioFormatMPEG4AAC,
  427. AVSampleRateKey: 48_000,
  428. AVNumberOfChannelsKey: 1,
  429. AVEncoderBitRateKey: 128_000,
  430. AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue
  431. ]
  432. let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
  433. recorder.prepareToRecord()
  434. guard recorder.record() else {
  435. throw RecordingError.micCaptureFailed("AVAudioRecorder could not start.")
  436. }
  437. micRecorder = recorder
  438. }
  439. private func stopMicRecorder() {
  440. micRecorder?.stop()
  441. micRecorder = nil
  442. }
  443. // MARK: - Finalize / mix (meetings_app-style)
  444. private func fileSize(at url: URL) -> Int64 {
  445. let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
  446. return attrs?[.size] as? Int64 ?? 0
  447. }
  448. private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
  449. guard FileManager.default.fileExists(atPath: url.path) else { return false }
  450. return fileSize(at: url) >= minBytes
  451. }
  452. private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
  453. let fm = FileManager.default
  454. if fm.fileExists(atPath: outputURL.path) {
  455. try? fm.removeItem(at: outputURL)
  456. }
  457. let hasSystem = hasAudioPayload(at: systemURL)
  458. let hasMic = hasAudioPayload(at: microphoneURL)
  459. if hasSystem && hasMic {
  460. do {
  461. try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: outputURL)
  462. return outputURL
  463. } catch {
  464. // Fall through to single-track handling.
  465. }
  466. }
  467. let chosenURL: URL?
  468. if hasSystem {
  469. chosenURL = systemURL
  470. } else if hasMic {
  471. chosenURL = microphoneURL
  472. } else {
  473. chosenURL = nil
  474. }
  475. guard let chosenURL else {
  476. throw RecordingError.exportFailed
  477. }
  478. if chosenURL.path != outputURL.path {
  479. try? fm.removeItem(at: outputURL)
  480. do {
  481. try fm.copyItem(at: chosenURL, to: outputURL)
  482. } catch {
  483. return chosenURL
  484. }
  485. }
  486. return outputURL
  487. }
  488. private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
  489. try? FileManager.default.removeItem(at: destinationURL)
  490. let composition = AVMutableComposition()
  491. guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
  492. let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
  493. throw RecordingError.exportFailed
  494. }
  495. let systemAsset = AVURLAsset(url: systemURL)
  496. let micAsset = AVURLAsset(url: microphoneURL)
  497. if let src = systemAsset.tracks(withMediaType: .audio).first {
  498. let duration = systemAsset.duration
  499. if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
  500. try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
  501. }
  502. }
  503. if let src = micAsset.tracks(withMediaType: .audio).first {
  504. let duration = micAsset.duration
  505. if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
  506. try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
  507. }
  508. }
  509. guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
  510. throw RecordingError.exportFailed
  511. }
  512. let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
  513. systemMix.setVolume(1.0, at: .zero)
  514. let micMix = AVMutableAudioMixInputParameters(track: micTrack)
  515. micMix.setVolume(1.35, at: .zero)
  516. let audioMix = AVMutableAudioMix()
  517. audioMix.inputParameters = [systemMix, micMix]
  518. export.outputURL = destinationURL
  519. export.outputFileType = .m4a
  520. export.audioMix = audioMix
  521. await withCheckedContinuation { continuation in
  522. export.exportAsynchronously {
  523. continuation.resume()
  524. }
  525. }
  526. if export.status != .completed {
  527. throw export.error ?? RecordingError.exportFailed
  528. }
  529. }
  530. // MARK: - Meeting title + filenames
  531. /// Trimmed explicit name from the join sheet, otherwise a stable label from the Zoom URL (meeting id, instant meeting, etc.).
  532. static func resolvedMeetingTitle(explicit: String?, sourceURL: URL?) -> String {
  533. if let t = explicit?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false {
  534. return t
  535. }
  536. if let url = sourceURL {
  537. return derivedDisplayTitle(from: url)
  538. }
  539. return "Zoom meeting"
  540. }
  541. static func derivedDisplayTitle(from url: URL) -> String {
  542. let path = url.path.lowercased()
  543. if path.contains("/start/") || path.contains("videomeeting") {
  544. return "Instant meeting"
  545. }
  546. let parts = url.path.split(separator: "/").map(String.init)
  547. for (i, segment) in parts.enumerated() {
  548. let s = segment.lowercased()
  549. if (s == "j" || s == "join" || s == "wc" || s == "my") && i + 1 < parts.count {
  550. let next = parts[i + 1].filter(\.isNumber)
  551. if next.count >= 9 { return "Meeting \(next)" }
  552. }
  553. }
  554. if let last = parts.last {
  555. let digits = last.filter(\.isNumber)
  556. if digits.count >= 9 { return "Meeting \(digits)" }
  557. }
  558. return "Zoom meeting"
  559. }
  560. /// Safe file base: filesystem-safe, bounded length, then made unique in `directory` if needed.
  561. static func uniqueSanitizedFileURL(directory: URL, baseName: String, idSuffix: String, extension ext: String) -> URL {
  562. let sanitized = sanitizeFileBaseName(baseName)
  563. let shortID = String(idSuffix.replacingOccurrences(of: "-", with: "").prefix(8))
  564. let fm = FileManager.default
  565. for attempt in 0..<50 {
  566. let suffix = attempt == 0 ? "" : "_\(attempt)"
  567. let name = "\(sanitized)_\(shortID)\(suffix).\(ext)"
  568. let url = directory.appendingPathComponent(name)
  569. if fm.fileExists(atPath: url.path) == false {
  570. return url
  571. }
  572. }
  573. return directory.appendingPathComponent("\(sanitized)_\(shortID)_\(UUID().uuidString.prefix(8)).\(ext)")
  574. }
  575. private static func sanitizeFileBaseName(_ raw: String) -> String {
  576. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  577. let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
  578. let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  579. let cleaned = collapsed.unicodeScalars.map { invalid.contains($0) ? "_" : Character($0) }
  580. let joined = String(cleaned).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
  581. let base = joined.isEmpty ? "Zoom_meeting" : joined
  582. let maxLen = 72
  583. if base.count <= maxLen { return base }
  584. let idx = base.index(base.startIndex, offsetBy: maxLen)
  585. return String(base[..<idx]).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
  586. }
  587. }
  588. // MARK: - System audio (meetings_app-style)
  589. @available(macOS 13.0, *)
  590. private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
  591. private let outputURL: URL
  592. private var stream: SCStream?
  593. private var writer: AVAssetWriter?
  594. private var writerInput: AVAssetWriterInput?
  595. private let outputQueue = DispatchQueue(label: "zoom_app.system.audio.capture")
  596. private var didStartSession = false
  597. init(outputURL: URL) {
  598. self.outputURL = outputURL
  599. }
  600. func start() async throws {
  601. try? FileManager.default.removeItem(at: outputURL)
  602. let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
  603. guard let display = content.displays.first else {
  604. throw MeetingRecordingManager.RecordingError.noShareableContent
  605. }
  606. let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
  607. let inputSettings: [String: Any] = [
  608. AVFormatIDKey: kAudioFormatMPEG4AAC,
  609. AVSampleRateKey: 48_000,
  610. AVNumberOfChannelsKey: 2,
  611. AVEncoderBitRateKey: 128_000
  612. ]
  613. let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
  614. input.expectsMediaDataInRealTime = true
  615. guard writer.canAdd(input) else {
  616. throw MeetingRecordingManager.RecordingError.systemAudioUnavailable("Cannot add audio writer input.")
  617. }
  618. writer.add(input)
  619. self.writer = writer
  620. self.writerInput = input
  621. didStartSession = false
  622. let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
  623. let config = SCStreamConfiguration()
  624. config.width = 2
  625. config.height = 2
  626. config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
  627. config.queueDepth = 1
  628. config.capturesAudio = true
  629. config.sampleRate = 48_000
  630. config.channelCount = 2
  631. config.excludesCurrentProcessAudio = true
  632. let stream = SCStream(filter: filter, configuration: config, delegate: self)
  633. self.stream = stream
  634. try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
  635. try await stream.startCapture()
  636. }
  637. func stop() async throws {
  638. if let stream {
  639. try? await stream.stopCapture()
  640. self.stream = nil
  641. }
  642. writerInput?.markAsFinished()
  643. guard let writer else { return }
  644. await withCheckedContinuation { continuation in
  645. writer.finishWriting {
  646. continuation.resume()
  647. }
  648. }
  649. self.writer = nil
  650. self.writerInput = nil
  651. }
  652. func stream(_ stream: SCStream, didStopWithError error: Error) {
  653. // Capture may stop on permission changes or display changes; caller handles lifecycle.
  654. }
  655. func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
  656. guard outputType == .audio else { return }
  657. guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
  658. guard let writer = writer, let input = writerInput else { return }
  659. if writer.status == .unknown {
  660. writer.startWriting()
  661. }
  662. if writer.status == .writing {
  663. let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
  664. if !didStartSession {
  665. writer.startSession(atSourceTime: pts)
  666. didStartSession = true
  667. }
  668. if input.isReadyForMoreMediaData {
  669. input.append(sampleBuffer)
  670. }
  671. }
  672. }
  673. }