|
@@ -12,9 +12,16 @@ final class MeetingRecordingManager: NSObject {
|
|
|
let startedAt: Date
|
|
let startedAt: Date
|
|
|
let endedAt: Date
|
|
let endedAt: Date
|
|
|
let sourceURLString: String?
|
|
let sourceURLString: String?
|
|
|
|
|
+ /// Human-readable title for UI and for the saved file base name (older index entries omit this key).
|
|
|
|
|
+ let meetingDisplayName: String?
|
|
|
let audioFilename: String
|
|
let audioFilename: String
|
|
|
|
|
|
|
|
var audioFilenameURLSafe: String { audioFilename }
|
|
var audioFilenameURLSafe: String { audioFilename }
|
|
|
|
|
+
|
|
|
|
|
+ var resolvedDisplayTitle: String {
|
|
|
|
|
+ let raw = meetingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ return raw.isEmpty ? "Meeting recording" : raw
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
enum RecordingError: Error, LocalizedError {
|
|
enum RecordingError: Error, LocalizedError {
|
|
@@ -68,6 +75,7 @@ final class MeetingRecordingManager: NSObject {
|
|
|
private var recordingID: String?
|
|
private var recordingID: String?
|
|
|
private var recordingStartedAt: Date?
|
|
private var recordingStartedAt: Date?
|
|
|
private var recordingSourceURLString: String?
|
|
private var recordingSourceURLString: String?
|
|
|
|
|
+ private var pendingMeetingDisplayName: String?
|
|
|
|
|
|
|
|
// Mic capture
|
|
// Mic capture
|
|
|
private let micEngine = AVAudioEngine()
|
|
private let micEngine = AVAudioEngine()
|
|
@@ -77,8 +85,6 @@ final class MeetingRecordingManager: NSObject {
|
|
|
// Stored as `AnyObject` so we can compile with lower deployment targets.
|
|
// Stored as `AnyObject` so we can compile with lower deployment targets.
|
|
|
private var scStream: AnyObject?
|
|
private var scStream: AnyObject?
|
|
|
private var scAudioOutput: AnyObject?
|
|
private var scAudioOutput: AnyObject?
|
|
|
- fileprivate var systemAudioFile: AVAudioFile?
|
|
|
|
|
- fileprivate var systemAudioFormat: AVAudioFormat?
|
|
|
|
|
|
|
|
|
|
// Output
|
|
// Output
|
|
|
private var outputAudioURL: URL?
|
|
private var outputAudioURL: URL?
|
|
@@ -123,7 +129,7 @@ final class MeetingRecordingManager: NSObject {
|
|
|
NSWorkspace.shared.open(url)
|
|
NSWorkspace.shared.open(url)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func startRecording(sourceURL: URL?) async throws {
|
|
|
|
|
|
|
+ func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws {
|
|
|
if isRecording { throw RecordingError.alreadyRecording }
|
|
if isRecording { throw RecordingError.alreadyRecording }
|
|
|
|
|
|
|
|
let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
|
|
let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
|
|
@@ -135,25 +141,37 @@ final class MeetingRecordingManager: NSObject {
|
|
|
|
|
|
|
|
let micURL = dir.appendingPathComponent("raw_mic_\(id).caf")
|
|
let micURL = dir.appendingPathComponent("raw_mic_\(id).caf")
|
|
|
let sysURL = dir.appendingPathComponent("raw_sys_\(id).caf")
|
|
let sysURL = dir.appendingPathComponent("raw_sys_\(id).caf")
|
|
|
- let outURL = dir.appendingPathComponent("meeting_\(id).m4a")
|
|
|
|
|
|
|
+ let displayTitle = Self.resolvedMeetingTitle(explicit: meetingDisplayName, sourceURL: sourceURL)
|
|
|
|
|
+ let outBase = Self.uniqueSanitizedFileURL(directory: dir, baseName: displayTitle, idSuffix: id, extension: "m4a")
|
|
|
|
|
|
|
|
recordingID = id
|
|
recordingID = id
|
|
|
recordingStartedAt = startedAt
|
|
recordingStartedAt = startedAt
|
|
|
recordingSourceURLString = sourceURL?.absoluteString
|
|
recordingSourceURLString = sourceURL?.absoluteString
|
|
|
- outputAudioURL = outURL
|
|
|
|
|
|
|
+ pendingMeetingDisplayName = displayTitle
|
|
|
|
|
+ outputAudioURL = outBase
|
|
|
|
|
|
|
|
- try startMicCapture(to: micURL)
|
|
|
|
|
- if #available(macOS 13.0, *) {
|
|
|
|
|
- do {
|
|
|
|
|
- try await startSystemAudioCapture(to: sysURL)
|
|
|
|
|
- } catch {
|
|
|
|
|
- // Best-effort: keep mic recording even if system audio fails.
|
|
|
|
|
- systemAudioFile = nil
|
|
|
|
|
- systemAudioFormat = nil
|
|
|
|
|
|
|
+ do {
|
|
|
|
|
+ try startMicCapture(to: micURL)
|
|
|
|
|
+ if #available(macOS 13.0, *) {
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await startSystemAudioCapture(to: sysURL)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ scStream = nil
|
|
|
|
|
+ scAudioOutput = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ scStream = nil
|
|
|
|
|
+ scAudioOutput = nil
|
|
|
}
|
|
}
|
|
|
- } else {
|
|
|
|
|
- systemAudioFile = nil
|
|
|
|
|
- systemAudioFormat = nil
|
|
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ stopMicCapture()
|
|
|
|
|
+ await stopSystemAudioCapture()
|
|
|
|
|
+ recordingID = nil
|
|
|
|
|
+ recordingStartedAt = nil
|
|
|
|
|
+ recordingSourceURLString = nil
|
|
|
|
|
+ pendingMeetingDisplayName = nil
|
|
|
|
|
+ outputAudioURL = nil
|
|
|
|
|
+ throw error
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
isRecording = true
|
|
isRecording = true
|
|
@@ -174,18 +192,20 @@ final class MeetingRecordingManager: NSObject {
|
|
|
stopMicCapture()
|
|
stopMicCapture()
|
|
|
await stopSystemAudioCapture()
|
|
await stopSystemAudioCapture()
|
|
|
|
|
|
|
|
- // Mix mic + system (if available) into a single m4a.
|
|
|
|
|
|
|
+ // Export: prefer system (browser / app output) when present; otherwise mic-only (see mixAndExport).
|
|
|
let mixedURL = try await mixAndExport(micURL: micURL, systemURL: sysURL, outputURL: outURL)
|
|
let mixedURL = try await mixAndExport(micURL: micURL, systemURL: sysURL, outputURL: outURL)
|
|
|
|
|
|
|
|
// Cleanup raw files (ignore failures).
|
|
// Cleanup raw files (ignore failures).
|
|
|
try? FileManager.default.removeItem(at: micURL)
|
|
try? FileManager.default.removeItem(at: micURL)
|
|
|
try? FileManager.default.removeItem(at: sysURL)
|
|
try? FileManager.default.removeItem(at: sysURL)
|
|
|
|
|
|
|
|
|
|
+ let displayName = pendingMeetingDisplayName
|
|
|
let rec = RecordedMeeting(
|
|
let rec = RecordedMeeting(
|
|
|
id: id,
|
|
id: id,
|
|
|
startedAt: startedAt,
|
|
startedAt: startedAt,
|
|
|
endedAt: endedAt,
|
|
endedAt: endedAt,
|
|
|
sourceURLString: recordingSourceURLString,
|
|
sourceURLString: recordingSourceURLString,
|
|
|
|
|
+ meetingDisplayName: displayName,
|
|
|
audioFilename: mixedURL.lastPathComponent
|
|
audioFilename: mixedURL.lastPathComponent
|
|
|
)
|
|
)
|
|
|
recordings.insert(rec, at: 0)
|
|
recordings.insert(rec, at: 0)
|
|
@@ -194,6 +214,7 @@ final class MeetingRecordingManager: NSObject {
|
|
|
recordingID = nil
|
|
recordingID = nil
|
|
|
recordingStartedAt = nil
|
|
recordingStartedAt = nil
|
|
|
recordingSourceURLString = nil
|
|
recordingSourceURLString = nil
|
|
|
|
|
+ pendingMeetingDisplayName = nil
|
|
|
outputAudioURL = nil
|
|
outputAudioURL = nil
|
|
|
|
|
|
|
|
isRecording = false
|
|
isRecording = false
|
|
@@ -273,34 +294,26 @@ final class MeetingRecordingManager: NSObject {
|
|
|
let stream = SCStream(filter: filter, configuration: config, delegate: nil)
|
|
let stream = SCStream(filter: filter, configuration: config, delegate: nil)
|
|
|
scStream = stream
|
|
scStream = stream
|
|
|
|
|
|
|
|
- // Prepare file once we see the first audio buffer (format can vary).
|
|
|
|
|
- systemAudioFile = nil
|
|
|
|
|
- systemAudioFormat = nil
|
|
|
|
|
-
|
|
|
|
|
- let output = SystemAudioOutput(owner: self)
|
|
|
|
|
- scAudioOutput = output
|
|
|
|
|
- try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: DispatchQueue(label: "meeting.systemaudio.queue"))
|
|
|
|
|
|
|
+ let queue = DispatchQueue(label: "meeting.systemaudio.writer.queue")
|
|
|
|
|
+ let sink = SystemAudioAssetWriterSink(outputURL: url, recordingActive: { [weak self] in
|
|
|
|
|
+ self?.recordingID != nil
|
|
|
|
|
+ }, queue: queue)
|
|
|
|
|
+ scAudioOutput = sink
|
|
|
|
|
+ try stream.addStreamOutput(sink, type: .audio, sampleHandlerQueue: queue)
|
|
|
try await stream.startCapture()
|
|
try await stream.startCapture()
|
|
|
-
|
|
|
|
|
- // Pre-create an assumed format for writing (48k stereo float32).
|
|
|
|
|
- let fmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: false)
|
|
|
|
|
- systemAudioFormat = fmt
|
|
|
|
|
- if let fmt {
|
|
|
|
|
- systemAudioFile = try? AVAudioFile(forWriting: url, settings: fmt.settings)
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func stopSystemAudioCapture() async {
|
|
private func stopSystemAudioCapture() async {
|
|
|
guard #available(macOS 13.0, *) else { return }
|
|
guard #available(macOS 13.0, *) else { return }
|
|
|
guard let stream = scStream as? SCStream else { return }
|
|
guard let stream = scStream as? SCStream else { return }
|
|
|
- if let output = scAudioOutput as? SystemAudioOutput {
|
|
|
|
|
- try? stream.removeStreamOutput(output, type: .audio)
|
|
|
|
|
|
|
+ let sink = scAudioOutput as? SystemAudioAssetWriterSink
|
|
|
|
|
+ if let sink {
|
|
|
|
|
+ try? stream.removeStreamOutput(sink, type: .audio)
|
|
|
}
|
|
}
|
|
|
try? await stream.stopCapture()
|
|
try? await stream.stopCapture()
|
|
|
|
|
+ await sink?.finalizeWriting()
|
|
|
scStream = nil
|
|
scStream = nil
|
|
|
scAudioOutput = nil
|
|
scAudioOutput = nil
|
|
|
- systemAudioFile = nil
|
|
|
|
|
- systemAudioFormat = nil
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Mix/export
|
|
// MARK: - Mix/export
|
|
@@ -315,6 +328,30 @@ final class MeetingRecordingManager: NSObject {
|
|
|
let sysExists = fm.fileExists(atPath: systemURL.path)
|
|
let sysExists = fm.fileExists(atPath: systemURL.path)
|
|
|
let sysAsset = sysExists ? AVURLAsset(url: systemURL) : nil
|
|
let sysAsset = sysExists ? AVURLAsset(url: systemURL) : nil
|
|
|
|
|
|
|
|
|
|
+ // System capture carries the Zoom / browser meeting mix. Mixing it with the mic at full
|
|
|
|
|
+ // level duplicates speaker output (very harsh noise). Prefer system audio whenever it
|
|
|
|
|
+ // actually contains a track; fall back to mic-only if system capture failed or is empty.
|
|
|
|
|
+ if let sysAsset,
|
|
|
|
|
+ let sysSrc = sysAsset.tracks(withMediaType: .audio).first {
|
|
|
|
|
+ let duration = sysAsset.duration
|
|
|
|
|
+ let sysSeconds = (duration.isValid && !duration.isIndefinite) ? duration.seconds : 0
|
|
|
|
|
+ if sysSeconds >= 0.25 {
|
|
|
|
|
+ let sysOnly = AVMutableComposition()
|
|
|
|
|
+ let t = sysOnly.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
|
|
|
|
|
+ if duration.seconds > 0 {
|
|
|
|
|
+ do {
|
|
|
|
|
+ try t?.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: sysSrc, at: .zero)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Fall through to mic paths.
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if sysOnly.tracks(withMediaType: .audio).isEmpty == false {
|
|
|
|
|
+ return try await exportAssetToM4A(asset: sysOnly, outputURL: outputURL)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Mic-only composition when system audio is missing or unusable.
|
|
|
let composition = AVMutableComposition()
|
|
let composition = AVMutableComposition()
|
|
|
let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
|
|
let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
|
|
|
if let src = micAsset.tracks(withMediaType: .audio).first {
|
|
if let src = micAsset.tracks(withMediaType: .audio).first {
|
|
@@ -324,20 +361,8 @@ final class MeetingRecordingManager: NSObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if let sysAsset, let src = sysAsset.tracks(withMediaType: .audio).first {
|
|
|
|
|
- let duration = sysAsset.duration
|
|
|
|
|
- if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
|
|
|
|
|
- let sysTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
|
|
|
|
|
- try sysTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
if composition.tracks(withMediaType: .audio).isEmpty == false {
|
|
if composition.tracks(withMediaType: .audio).isEmpty == false {
|
|
|
- do {
|
|
|
|
|
- return try await exportAssetToM4A(asset: composition, outputURL: outputURL)
|
|
|
|
|
- } catch {
|
|
|
|
|
- // Fall through to mic-only export.
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return try await exportAssetToM4A(asset: composition, outputURL: outputURL)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return try await exportAssetToM4A(asset: micAsset, outputURL: outputURL)
|
|
return try await exportAssetToM4A(asset: micAsset, outputURL: outputURL)
|
|
@@ -363,42 +388,140 @@ final class MeetingRecordingManager: NSObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Meeting title + filenames
|
|
|
|
|
+
|
|
|
|
|
+ /// Trimmed explicit name from the join sheet, otherwise a stable label from the Zoom URL (meeting id, instant meeting, etc.).
|
|
|
|
|
+ static func resolvedMeetingTitle(explicit: String?, sourceURL: URL?) -> String {
|
|
|
|
|
+ if let t = explicit?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false {
|
|
|
|
|
+ return t
|
|
|
|
|
+ }
|
|
|
|
|
+ if let url = sourceURL {
|
|
|
|
|
+ return derivedDisplayTitle(from: url)
|
|
|
|
|
+ }
|
|
|
|
|
+ return "Zoom meeting"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func derivedDisplayTitle(from url: URL) -> String {
|
|
|
|
|
+ let path = url.path.lowercased()
|
|
|
|
|
+ if path.contains("/start/") || path.contains("videomeeting") {
|
|
|
|
|
+ return "Instant meeting"
|
|
|
|
|
+ }
|
|
|
|
|
+ let parts = url.path.split(separator: "/").map(String.init)
|
|
|
|
|
+ for (i, segment) in parts.enumerated() {
|
|
|
|
|
+ let s = segment.lowercased()
|
|
|
|
|
+ if (s == "j" || s == "join" || s == "wc" || s == "my") && i + 1 < parts.count {
|
|
|
|
|
+ let next = parts[i + 1].filter(\.isNumber)
|
|
|
|
|
+ if next.count >= 9 { return "Meeting \(next)" }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if let last = parts.last {
|
|
|
|
|
+ let digits = last.filter(\.isNumber)
|
|
|
|
|
+ if digits.count >= 9 { return "Meeting \(digits)" }
|
|
|
|
|
+ }
|
|
|
|
|
+ return "Zoom meeting"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Safe file base: filesystem-safe, bounded length, then made unique in `directory` if needed.
|
|
|
|
|
+ static func uniqueSanitizedFileURL(directory: URL, baseName: String, idSuffix: String, extension ext: String) -> URL {
|
|
|
|
|
+ let sanitized = sanitizeFileBaseName(baseName)
|
|
|
|
|
+ let shortID = String(idSuffix.replacingOccurrences(of: "-", with: "").prefix(8))
|
|
|
|
|
+ let fm = FileManager.default
|
|
|
|
|
+ for attempt in 0..<50 {
|
|
|
|
|
+ let suffix = attempt == 0 ? "" : "_\(attempt)"
|
|
|
|
|
+ let name = "\(sanitized)_\(shortID)\(suffix).\(ext)"
|
|
|
|
|
+ let url = directory.appendingPathComponent(name)
|
|
|
|
|
+ if fm.fileExists(atPath: url.path) == false {
|
|
|
|
|
+ return url
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return directory.appendingPathComponent("\(sanitized)_\(shortID)_\(UUID().uuidString.prefix(8)).\(ext)")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func sanitizeFileBaseName(_ raw: String) -> String {
|
|
|
|
|
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
|
|
|
+ let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
|
|
|
|
|
+ let cleaned = collapsed.unicodeScalars.map { invalid.contains($0) ? "_" : Character($0) }
|
|
|
|
|
+ let joined = String(cleaned).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
|
|
|
|
|
+ let base = joined.isEmpty ? "Zoom_meeting" : joined
|
|
|
|
|
+ let maxLen = 72
|
|
|
|
|
+ if base.count <= maxLen { return base }
|
|
|
|
|
+ let idx = base.index(base.startIndex, offsetBy: maxLen)
|
|
|
|
|
+ return String(base[..<idx]).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@available(macOS 13.0, *)
|
|
@available(macOS 13.0, *)
|
|
|
-private final class SystemAudioOutput: NSObject, SCStreamOutput {
|
|
|
|
|
- private weak var owner: MeetingRecordingManager?
|
|
|
|
|
-
|
|
|
|
|
- init(owner: MeetingRecordingManager) {
|
|
|
|
|
- self.owner = owner
|
|
|
|
|
|
|
+private final class SystemAudioAssetWriterSink: NSObject, SCStreamOutput {
|
|
|
|
|
+ private let outputURL: URL
|
|
|
|
|
+ private let recordingActive: () -> Bool
|
|
|
|
|
+ private let queue: DispatchQueue
|
|
|
|
|
+ private var writer: AVAssetWriter?
|
|
|
|
|
+ private var input: AVAssetWriterInput?
|
|
|
|
|
+ private var sessionStarted = false
|
|
|
|
|
+
|
|
|
|
|
+ init(outputURL: URL, recordingActive: @escaping () -> Bool, queue: DispatchQueue) {
|
|
|
|
|
+ self.outputURL = outputURL
|
|
|
|
|
+ self.recordingActive = recordingActive
|
|
|
|
|
+ self.queue = queue
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
|
guard type == .audio else { return }
|
|
guard type == .audio else { return }
|
|
|
- guard let owner, owner.isRecording else { return }
|
|
|
|
|
- guard let file = owner.systemAudioFile, let format = owner.systemAudioFormat else { return }
|
|
|
|
|
-
|
|
|
|
|
- guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return }
|
|
|
|
|
- var length: Int = 0
|
|
|
|
|
- var dataPointer: UnsafeMutablePointer<Int8>?
|
|
|
|
|
- guard CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
|
|
|
|
|
- let dataPointer else { return }
|
|
|
|
|
-
|
|
|
|
|
- let frameCount = AVAudioFrameCount(length) / format.streamDescription.pointee.mBytesPerFrame
|
|
|
|
|
- guard let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return }
|
|
|
|
|
- pcm.frameLength = frameCount
|
|
|
|
|
-
|
|
|
|
|
- if format.isInterleaved { return }
|
|
|
|
|
- if format.commonFormat != AVAudioCommonFormat.pcmFormatFloat32 { return }
|
|
|
|
|
-
|
|
|
|
|
- let bytesPerChannel = Int(frameCount) * MemoryLayout<Float>.size
|
|
|
|
|
- for ch in 0..<Int(format.channelCount) {
|
|
|
|
|
- guard let dst = pcm.floatChannelData?[ch] else { continue }
|
|
|
|
|
- let srcOffset = ch * bytesPerChannel
|
|
|
|
|
- memcpy(dst, dataPointer.advanced(by: srcOffset), bytesPerChannel)
|
|
|
|
|
|
|
+ guard recordingActive() else { return }
|
|
|
|
|
+ guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
|
|
|
|
|
+
|
|
|
|
|
+ if writer == nil {
|
|
|
|
|
+ guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
|
|
|
|
|
+ do {
|
|
|
|
|
+ try? FileManager.default.removeItem(at: outputURL)
|
|
|
|
|
+ let writer = try AVAssetWriter(outputURL: outputURL, fileType: .caf)
|
|
|
|
|
+ let input = AVAssetWriterInput(mediaType: .audio, outputSettings: nil, sourceFormatHint: formatDesc)
|
|
|
|
|
+ input.expectsMediaDataInRealTime = true
|
|
|
|
|
+ guard writer.canAdd(input) else { return }
|
|
|
|
|
+ writer.add(input)
|
|
|
|
|
+ self.writer = writer
|
|
|
|
|
+ self.input = input
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ guard let writer = self.writer, let input = self.input else { return }
|
|
|
|
|
+
|
|
|
|
|
+ if writer.status == .unknown {
|
|
|
|
|
+ writer.startWriting()
|
|
|
|
|
+ }
|
|
|
|
|
+ guard writer.status == .writing else { return }
|
|
|
|
|
+
|
|
|
|
|
+ if sessionStarted == false {
|
|
|
|
|
+ let t = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
|
|
|
|
+ writer.startSession(atSourceTime: t)
|
|
|
|
|
+ sessionStarted = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- try? file.write(from: pcm)
|
|
|
|
|
|
|
+ guard input.isReadyForMoreMediaData else { return }
|
|
|
|
|
+ input.append(sampleBuffer)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func finalizeWriting() async {
|
|
|
|
|
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
|
|
|
+ queue.async { [weak self] in
|
|
|
|
|
+ guard let self else {
|
|
|
|
|
+ cont.resume()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ self.input?.markAsFinished()
|
|
|
|
|
+ guard let writer = self.writer else {
|
|
|
|
|
+ cont.resume()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ writer.finishWriting {
|
|
|
|
|
+ cont.resume()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|