MeetingRecordingManager.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  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. var audioFilenameURLSafe: String { audioFilename }
  20. var resolvedDisplayTitle: String {
  21. let raw = meetingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  22. return raw.isEmpty ? "Meeting recording" : raw
  23. }
  24. }
  25. enum RecordingError: Error, LocalizedError {
  26. case alreadyRecording
  27. case notRecording
  28. case microphonePermissionDenied
  29. case noShareableContent
  30. case systemAudioUnavailable(String)
  31. case exportFailed
  32. case micCaptureFailed(String)
  33. var errorDescription: String? {
  34. switch self {
  35. case .alreadyRecording:
  36. return "A recording is already in progress. Stop it first, then start again."
  37. case .notRecording:
  38. return "Nothing is being recorded right now."
  39. case .microphonePermissionDenied:
  40. return "Microphone access was denied. Turn it on in System Settings → Privacy & Security → Microphone for this app."
  41. case .noShareableContent:
  42. return "Could not access a display for system audio capture."
  43. case .systemAudioUnavailable(let reason):
  44. return "System audio capture failed (\(reason)). Microphone-only recording may still work."
  45. case .exportFailed:
  46. return "Could not save the recording file. Check free disk space and try again."
  47. case .micCaptureFailed(let reason):
  48. return "Microphone recording failed: \(reason)"
  49. }
  50. }
  51. }
  52. static let recordingsDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingsDidChange")
  53. static let recordingStateDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingStateDidChange")
  54. static let transcriptionDidBeginNotification = Notification.Name("MeetingRecordingManager.transcriptionDidBegin")
  55. static let transcriptionDidEndNotification = Notification.Name("MeetingRecordingManager.transcriptionDidEnd")
  56. private let encoder: JSONEncoder = {
  57. let e = JSONEncoder()
  58. e.outputFormatting = [.prettyPrinted, .sortedKeys]
  59. e.dateEncodingStrategy = .iso8601
  60. return e
  61. }()
  62. private let decoder: JSONDecoder = {
  63. let d = JSONDecoder()
  64. d.dateDecodingStrategy = .iso8601
  65. return d
  66. }()
  67. private(set) var recordings: [RecordedMeeting] = []
  68. private(set) var isRecording: Bool = false
  69. private var recordingID: String?
  70. private var recordingStartedAt: Date?
  71. private var recordingSourceURLString: String?
  72. private var pendingMeetingDisplayName: String?
  73. /// Microphone capture (same approach as meetings_app: `AVAudioRecorder` → AAC `.m4a`).
  74. private var micRecorder: AVAudioRecorder?
  75. /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
  76. private var systemAudioRecorder: AnyObject?
  77. // Output
  78. private var outputAudioURL: URL?
  79. private override init() {
  80. super.init()
  81. loadRecordingsFromDisk()
  82. }
  83. // MARK: - Public API
  84. func recordingsDirectoryURL() throws -> URL {
  85. let fm = FileManager.default
  86. let appSupport = try fm.url(
  87. for: .applicationSupportDirectory,
  88. in: .userDomainMask,
  89. appropriateFor: nil,
  90. create: true
  91. )
  92. // Sandboxed apps: `appSupport` is inside the app container (Finder: ~/Library/Containers/…/Data/Library/Application Support/).
  93. // Use bundle ID so the folder matches this app (not a generic name).
  94. let bundleFolder = Bundle.main.bundleIdentifier ?? "zoom_app"
  95. let dir = appSupport.appendingPathComponent(bundleFolder, isDirectory: true)
  96. .appendingPathComponent("Recordings", isDirectory: true)
  97. if fm.fileExists(atPath: dir.path) == false {
  98. try fm.createDirectory(at: dir, withIntermediateDirectories: true)
  99. }
  100. return dir
  101. }
  102. func audioURL(for recording: RecordedMeeting) throws -> URL {
  103. try recordingsDirectoryURL().appendingPathComponent(recording.audioFilename)
  104. }
  105. /// Full path to the Recordings folder (sandboxed apps: inside `~/Library/Containers/…/Data/Library/Application Support/`).
  106. var recordingsDirectoryPath: String {
  107. (try? recordingsDirectoryURL().path) ?? ""
  108. }
  109. func revealRecordingsInFinder() {
  110. guard let url = try? recordingsDirectoryURL() else { return }
  111. NSWorkspace.shared.open(url)
  112. }
  113. func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws {
  114. if isRecording { throw RecordingError.alreadyRecording }
  115. let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
  116. if micGranted == false { throw RecordingError.microphonePermissionDenied }
  117. let id = UUID().uuidString
  118. let startedAt = Date()
  119. let dir = try recordingsDirectoryURL()
  120. let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
  121. let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
  122. let displayTitle = Self.resolvedMeetingTitle(explicit: meetingDisplayName, sourceURL: sourceURL)
  123. let outBase = Self.uniqueSanitizedFileURL(directory: dir, baseName: displayTitle, idSuffix: id, extension: "m4a")
  124. recordingID = id
  125. recordingStartedAt = startedAt
  126. recordingSourceURLString = sourceURL?.absoluteString
  127. pendingMeetingDisplayName = displayTitle
  128. outputAudioURL = outBase
  129. do {
  130. try startMicRecorder(to: micURL)
  131. if #available(macOS 13.0, *) {
  132. let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL)
  133. systemAudioRecorder = recorder
  134. do {
  135. try await recorder.start()
  136. } catch {
  137. systemAudioRecorder = nil
  138. }
  139. } else {
  140. systemAudioRecorder = nil
  141. }
  142. } catch {
  143. stopMicRecorder()
  144. if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
  145. try? await r.stop()
  146. }
  147. systemAudioRecorder = nil
  148. recordingID = nil
  149. recordingStartedAt = nil
  150. recordingSourceURLString = nil
  151. pendingMeetingDisplayName = nil
  152. outputAudioURL = nil
  153. throw error
  154. }
  155. isRecording = true
  156. NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
  157. }
  158. func stopRecording() async throws {
  159. guard isRecording else { throw RecordingError.notRecording }
  160. guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
  161. throw RecordingError.exportFailed
  162. }
  163. let endedAt = Date()
  164. let dir = try recordingsDirectoryURL()
  165. let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
  166. let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
  167. stopMicRecorder()
  168. if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
  169. try? await r.stop()
  170. }
  171. systemAudioRecorder = nil
  172. let mixedURL = try await finalizeMeetingAudio(systemURL: sysURL, microphoneURL: micURL, outputURL: outURL)
  173. // Cleanup intermediates (ignore failures).
  174. try? FileManager.default.removeItem(at: micURL)
  175. try? FileManager.default.removeItem(at: sysURL)
  176. let displayName = pendingMeetingDisplayName
  177. let rec = RecordedMeeting(
  178. id: id,
  179. startedAt: startedAt,
  180. endedAt: endedAt,
  181. sourceURLString: recordingSourceURLString,
  182. meetingDisplayName: displayName,
  183. audioFilename: mixedURL.lastPathComponent,
  184. transcriptText: nil,
  185. transcriptError: nil
  186. )
  187. recordings.insert(rec, at: 0)
  188. saveRecordingsToDisk()
  189. scheduleAppleTranscription(for: rec)
  190. recordingID = nil
  191. recordingStartedAt = nil
  192. recordingSourceURLString = nil
  193. pendingMeetingDisplayName = nil
  194. outputAudioURL = nil
  195. isRecording = false
  196. NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
  197. NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
  198. }
  199. // MARK: - Persistence
  200. private func indexURL() throws -> URL {
  201. try recordingsDirectoryURL().appendingPathComponent("index.json")
  202. }
  203. private func loadRecordingsFromDisk() {
  204. do {
  205. let url = try indexURL()
  206. guard FileManager.default.fileExists(atPath: url.path) else { return }
  207. let data = try Data(contentsOf: url)
  208. recordings = try decoder.decode([RecordedMeeting].self, from: data)
  209. } catch {
  210. recordings = []
  211. }
  212. }
  213. /// Runs Apple Speech on the saved mixed audio; updates `recordings` and posts `recordingsDidChangeNotification` when finished.
  214. private func scheduleAppleTranscription(for recording: RecordedMeeting) {
  215. let recordingId = recording.id
  216. let filename = recording.audioFilename
  217. let locales = TranscriptionLanguagePreferences.localesForAppleTranscription()
  218. Task {
  219. await performAppleTranscription(recordingId: recordingId, audioFilename: filename, locales: locales)
  220. }
  221. }
  222. private func performAppleTranscription(recordingId: String, audioFilename: String, locales: [Locale]) async {
  223. NotificationCenter.default.post(
  224. name: Self.transcriptionDidBeginNotification,
  225. object: self,
  226. userInfo: ["recordingId": recordingId]
  227. )
  228. defer {
  229. NotificationCenter.default.post(
  230. name: Self.transcriptionDidEndNotification,
  231. object: self,
  232. userInfo: ["recordingId": recordingId]
  233. )
  234. }
  235. let service = MeetingTranscriptionService()
  236. do {
  237. let dir = try recordingsDirectoryURL()
  238. let audioURL = dir.appendingPathComponent(audioFilename)
  239. let segments = try await service.transcribeMeeting(micURL: audioURL, systemURL: nil, locales: locales)
  240. let text = segments.renderedTimelineText()
  241. await MainActor.run {
  242. applyTranscript(recordingId: recordingId, text: text.isEmpty ? nil : text, error: nil)
  243. }
  244. } catch {
  245. await MainActor.run {
  246. applyTranscript(recordingId: recordingId, text: nil, error: error.localizedDescription)
  247. }
  248. }
  249. }
  250. private func applyTranscript(recordingId: String, text: String?, error: String?) {
  251. guard let idx = recordings.firstIndex(where: { $0.id == recordingId }) else { return }
  252. let old = recordings[idx]
  253. let updated = RecordedMeeting(
  254. id: old.id,
  255. startedAt: old.startedAt,
  256. endedAt: old.endedAt,
  257. sourceURLString: old.sourceURLString,
  258. meetingDisplayName: old.meetingDisplayName,
  259. audioFilename: old.audioFilename,
  260. transcriptText: text,
  261. transcriptError: error
  262. )
  263. recordings[idx] = updated
  264. saveRecordingsToDisk()
  265. NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
  266. }
  267. private func saveRecordingsToDisk() {
  268. do {
  269. let url = try indexURL()
  270. let data = try encoder.encode(recordings)
  271. try data.write(to: url, options: [.atomic])
  272. } catch {
  273. NSLog("MeetingRecordingManager: failed to save index.json — \(error.localizedDescription)")
  274. }
  275. }
  276. // MARK: - Mic capture (meetings_app-style)
  277. private func startMicRecorder(to outputURL: URL) throws {
  278. try? FileManager.default.removeItem(at: outputURL)
  279. let settings: [String: Any] = [
  280. AVFormatIDKey: kAudioFormatMPEG4AAC,
  281. AVSampleRateKey: 48_000,
  282. AVNumberOfChannelsKey: 1,
  283. AVEncoderBitRateKey: 128_000,
  284. AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue
  285. ]
  286. let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
  287. recorder.prepareToRecord()
  288. guard recorder.record() else {
  289. throw RecordingError.micCaptureFailed("AVAudioRecorder could not start.")
  290. }
  291. micRecorder = recorder
  292. }
  293. private func stopMicRecorder() {
  294. micRecorder?.stop()
  295. micRecorder = nil
  296. }
  297. // MARK: - Finalize / mix (meetings_app-style)
  298. private func fileSize(at url: URL) -> Int64 {
  299. let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
  300. return attrs?[.size] as? Int64 ?? 0
  301. }
  302. private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
  303. guard FileManager.default.fileExists(atPath: url.path) else { return false }
  304. return fileSize(at: url) >= minBytes
  305. }
  306. private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
  307. let fm = FileManager.default
  308. if fm.fileExists(atPath: outputURL.path) {
  309. try? fm.removeItem(at: outputURL)
  310. }
  311. let hasSystem = hasAudioPayload(at: systemURL)
  312. let hasMic = hasAudioPayload(at: microphoneURL)
  313. if hasSystem && hasMic {
  314. do {
  315. try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: outputURL)
  316. return outputURL
  317. } catch {
  318. // Fall through to single-track handling.
  319. }
  320. }
  321. let chosenURL: URL?
  322. if hasSystem {
  323. chosenURL = systemURL
  324. } else if hasMic {
  325. chosenURL = microphoneURL
  326. } else {
  327. chosenURL = nil
  328. }
  329. guard let chosenURL else {
  330. throw RecordingError.exportFailed
  331. }
  332. if chosenURL.path != outputURL.path {
  333. try? fm.removeItem(at: outputURL)
  334. do {
  335. try fm.copyItem(at: chosenURL, to: outputURL)
  336. } catch {
  337. return chosenURL
  338. }
  339. }
  340. return outputURL
  341. }
  342. private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
  343. try? FileManager.default.removeItem(at: destinationURL)
  344. let composition = AVMutableComposition()
  345. guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
  346. let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
  347. throw RecordingError.exportFailed
  348. }
  349. let systemAsset = AVURLAsset(url: systemURL)
  350. let micAsset = AVURLAsset(url: microphoneURL)
  351. if let src = systemAsset.tracks(withMediaType: .audio).first {
  352. let duration = systemAsset.duration
  353. if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
  354. try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
  355. }
  356. }
  357. if let src = micAsset.tracks(withMediaType: .audio).first {
  358. let duration = micAsset.duration
  359. if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
  360. try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
  361. }
  362. }
  363. guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
  364. throw RecordingError.exportFailed
  365. }
  366. let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
  367. systemMix.setVolume(1.0, at: .zero)
  368. let micMix = AVMutableAudioMixInputParameters(track: micTrack)
  369. micMix.setVolume(1.35, at: .zero)
  370. let audioMix = AVMutableAudioMix()
  371. audioMix.inputParameters = [systemMix, micMix]
  372. export.outputURL = destinationURL
  373. export.outputFileType = .m4a
  374. export.audioMix = audioMix
  375. await withCheckedContinuation { continuation in
  376. export.exportAsynchronously {
  377. continuation.resume()
  378. }
  379. }
  380. if export.status != .completed {
  381. throw export.error ?? RecordingError.exportFailed
  382. }
  383. }
  384. // MARK: - Meeting title + filenames
  385. /// Trimmed explicit name from the join sheet, otherwise a stable label from the Zoom URL (meeting id, instant meeting, etc.).
  386. static func resolvedMeetingTitle(explicit: String?, sourceURL: URL?) -> String {
  387. if let t = explicit?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false {
  388. return t
  389. }
  390. if let url = sourceURL {
  391. return derivedDisplayTitle(from: url)
  392. }
  393. return "Zoom meeting"
  394. }
  395. static func derivedDisplayTitle(from url: URL) -> String {
  396. let path = url.path.lowercased()
  397. if path.contains("/start/") || path.contains("videomeeting") {
  398. return "Instant meeting"
  399. }
  400. let parts = url.path.split(separator: "/").map(String.init)
  401. for (i, segment) in parts.enumerated() {
  402. let s = segment.lowercased()
  403. if (s == "j" || s == "join" || s == "wc" || s == "my") && i + 1 < parts.count {
  404. let next = parts[i + 1].filter(\.isNumber)
  405. if next.count >= 9 { return "Meeting \(next)" }
  406. }
  407. }
  408. if let last = parts.last {
  409. let digits = last.filter(\.isNumber)
  410. if digits.count >= 9 { return "Meeting \(digits)" }
  411. }
  412. return "Zoom meeting"
  413. }
  414. /// Safe file base: filesystem-safe, bounded length, then made unique in `directory` if needed.
  415. static func uniqueSanitizedFileURL(directory: URL, baseName: String, idSuffix: String, extension ext: String) -> URL {
  416. let sanitized = sanitizeFileBaseName(baseName)
  417. let shortID = String(idSuffix.replacingOccurrences(of: "-", with: "").prefix(8))
  418. let fm = FileManager.default
  419. for attempt in 0..<50 {
  420. let suffix = attempt == 0 ? "" : "_\(attempt)"
  421. let name = "\(sanitized)_\(shortID)\(suffix).\(ext)"
  422. let url = directory.appendingPathComponent(name)
  423. if fm.fileExists(atPath: url.path) == false {
  424. return url
  425. }
  426. }
  427. return directory.appendingPathComponent("\(sanitized)_\(shortID)_\(UUID().uuidString.prefix(8)).\(ext)")
  428. }
  429. private static func sanitizeFileBaseName(_ raw: String) -> String {
  430. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  431. let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
  432. let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  433. let cleaned = collapsed.unicodeScalars.map { invalid.contains($0) ? "_" : Character($0) }
  434. let joined = String(cleaned).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
  435. let base = joined.isEmpty ? "Zoom_meeting" : joined
  436. let maxLen = 72
  437. if base.count <= maxLen { return base }
  438. let idx = base.index(base.startIndex, offsetBy: maxLen)
  439. return String(base[..<idx]).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
  440. }
  441. }
  442. // MARK: - System audio (meetings_app-style)
  443. @available(macOS 13.0, *)
  444. private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
  445. private let outputURL: URL
  446. private var stream: SCStream?
  447. private var writer: AVAssetWriter?
  448. private var writerInput: AVAssetWriterInput?
  449. private let outputQueue = DispatchQueue(label: "zoom_app.system.audio.capture")
  450. private var didStartSession = false
  451. init(outputURL: URL) {
  452. self.outputURL = outputURL
  453. }
  454. func start() async throws {
  455. try? FileManager.default.removeItem(at: outputURL)
  456. let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
  457. guard let display = content.displays.first else {
  458. throw MeetingRecordingManager.RecordingError.noShareableContent
  459. }
  460. let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
  461. let inputSettings: [String: Any] = [
  462. AVFormatIDKey: kAudioFormatMPEG4AAC,
  463. AVSampleRateKey: 48_000,
  464. AVNumberOfChannelsKey: 2,
  465. AVEncoderBitRateKey: 128_000
  466. ]
  467. let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
  468. input.expectsMediaDataInRealTime = true
  469. guard writer.canAdd(input) else {
  470. throw MeetingRecordingManager.RecordingError.systemAudioUnavailable("Cannot add audio writer input.")
  471. }
  472. writer.add(input)
  473. self.writer = writer
  474. self.writerInput = input
  475. didStartSession = false
  476. let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
  477. let config = SCStreamConfiguration()
  478. config.width = 2
  479. config.height = 2
  480. config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
  481. config.queueDepth = 1
  482. config.capturesAudio = true
  483. config.sampleRate = 48_000
  484. config.channelCount = 2
  485. config.excludesCurrentProcessAudio = true
  486. let stream = SCStream(filter: filter, configuration: config, delegate: self)
  487. self.stream = stream
  488. try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
  489. try await stream.startCapture()
  490. }
  491. func stop() async throws {
  492. if let stream {
  493. try? await stream.stopCapture()
  494. self.stream = nil
  495. }
  496. writerInput?.markAsFinished()
  497. guard let writer else { return }
  498. await withCheckedContinuation { continuation in
  499. writer.finishWriting {
  500. continuation.resume()
  501. }
  502. }
  503. self.writer = nil
  504. self.writerInput = nil
  505. }
  506. func stream(_ stream: SCStream, didStopWithError error: Error) {
  507. // Capture may stop on permission changes or display changes; caller handles lifecycle.
  508. }
  509. func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
  510. guard outputType == .audio else { return }
  511. guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
  512. guard let writer = writer, let input = writerInput else { return }
  513. if writer.status == .unknown {
  514. writer.startWriting()
  515. }
  516. if writer.status == .writing {
  517. let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
  518. if !didStartSession {
  519. writer.startSession(atSourceTime: pts)
  520. didStartSession = true
  521. }
  522. if input.isReadyForMoreMediaData {
  523. input.append(sampleBuffer)
  524. }
  525. }
  526. }
  527. }