diff --git a/Recap/Audio/Capture/Tap/AudioTapType.swift b/Recap/Audio/Capture/Tap/AudioTapType.swift new file mode 100644 index 0000000..b9c92b1 --- /dev/null +++ b/Recap/Audio/Capture/Tap/AudioTapType.swift @@ -0,0 +1,23 @@ +import Foundation +import AudioToolbox +import AVFoundation + +protocol AudioTapType: ObservableObject { + var activated: Bool { get } + var audioLevel: Float { get } + var errorMessage: String? { get } + var tapStreamDescription: AudioStreamBasicDescription? { get } + + @MainActor func activate() + func invalidate() + func run(on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock, + invalidationHandler: @escaping (Self) -> Void) throws +} + +protocol AudioTapRecorderType: ObservableObject { + var fileURL: URL { get } + var isRecording: Bool { get } + + @MainActor func start() throws + func stop() +} diff --git a/Recap/Audio/Capture/Tap/ProcessTap.swift b/Recap/Audio/Capture/Tap/ProcessTap.swift index c3df345..5634d9c 100644 --- a/Recap/Audio/Capture/Tap/ProcessTap.swift +++ b/Recap/Audio/Capture/Tap/ProcessTap.swift @@ -7,7 +7,7 @@ extension String: @retroactive LocalizedError { public var errorDescription: String? { self } } -final class ProcessTap: ObservableObject { +final class ProcessTap: ObservableObject, AudioTapType { typealias InvalidationHandler = (ProcessTap) -> Void let process: AudioProcess @@ -169,7 +169,7 @@ final class ProcessTap: ObservableObject { } } -final class ProcessTapRecorder: ObservableObject { +final class ProcessTapRecorder: ObservableObject, AudioTapRecorderType { let fileURL: URL let process: AudioProcess private let queue = DispatchQueue(label: "ProcessTapRecorder", qos: .userInitiated) diff --git a/Recap/Audio/Capture/Tap/SystemWideTap.swift b/Recap/Audio/Capture/Tap/SystemWideTap.swift new file mode 100644 index 0000000..1346558 --- /dev/null +++ b/Recap/Audio/Capture/Tap/SystemWideTap.swift @@ -0,0 +1,306 @@ +import SwiftUI +import AudioToolbox +import OSLog +import AVFoundation + +final class SystemWideTap: ObservableObject, AudioTapType { + typealias InvalidationHandler = (SystemWideTap) -> Void + + let muteWhenRunning: Bool + private let logger: Logger + + private(set) var errorMessage: String? + @Published private(set) var audioLevel: Float = 0.0 + + fileprivate func setAudioLevel(_ level: Float) { + audioLevel = level + } + + init(muteWhenRunning: Bool = false) { + self.muteWhenRunning = muteWhenRunning + self.logger = Logger(subsystem: AppConstants.Logging.subsystem, category: + "\(String(describing: SystemWideTap.self))") + } + + @ObservationIgnored + private var processTapID: AudioObjectID = .unknown + @ObservationIgnored + private var aggregateDeviceID = AudioObjectID.unknown + @ObservationIgnored + private var deviceProcID: AudioDeviceIOProcID? + @ObservationIgnored + private(set) var tapStreamDescription: AudioStreamBasicDescription? + @ObservationIgnored + private var invalidationHandler: InvalidationHandler? + + @ObservationIgnored + private(set) var activated = false + + @MainActor + func activate() { + guard !activated else { return } + activated = true + + logger.debug(#function) + + self.errorMessage = nil + + do { + try prepareSystemWideTap() + } catch { + logger.error("\(error, privacy: .public)") + self.errorMessage = error.localizedDescription + } + } + + func invalidate() { + guard activated else { return } + defer { activated = false } + + logger.debug(#function) + + invalidationHandler?(self) + self.invalidationHandler = nil + + if aggregateDeviceID.isValid { + var err = AudioDeviceStop(aggregateDeviceID, deviceProcID) + if err != noErr { logger.warning("Failed to stop aggregate device: \(err, privacy: .public)") } + + if let deviceProcID = deviceProcID { + err = AudioDeviceDestroyIOProcID(aggregateDeviceID, deviceProcID) + if err != noErr { logger.warning("Failed to destroy device I/O proc: \(err, privacy: .public)") } + self.deviceProcID = nil + } + + err = AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + if err != noErr { + logger.warning("Failed to destroy aggregate device: \(err, privacy: .public)") + } + aggregateDeviceID = .unknown + } + + if processTapID.isValid { + let err = AudioHardwareDestroyProcessTap(processTapID) + if err != noErr { + logger.warning("Failed to destroy audio tap: \(err, privacy: .public)") + } + self.processTapID = .unknown + } + } + + private func prepareSystemWideTap() throws { + errorMessage = nil + + let tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: []) + tapDescription.uuid = UUID() + tapDescription.muteBehavior = muteWhenRunning ? .mutedWhenTapped : .unmuted + tapDescription.name = "SystemWideAudioTap" + tapDescription.isPrivate = true + tapDescription.isExclusive = true + + var tapID: AUAudioObjectID = .unknown + var err = AudioHardwareCreateProcessTap(tapDescription, &tapID) + + guard err == noErr else { + errorMessage = "System-wide process tap creation failed with error \(err)" + return + } + + logger.debug("Created system-wide process tap #\(tapID, privacy: .public)") + + self.processTapID = tapID + + let systemOutputID = try AudioDeviceID.readDefaultSystemOutputDevice() + let outputUID = try systemOutputID.readDeviceUID() + let aggregateUID = UUID().uuidString + + let description: [String: Any] = [ + kAudioAggregateDeviceNameKey: "SystemWide-Tap", + kAudioAggregateDeviceUIDKey: aggregateUID, + kAudioAggregateDeviceMainSubDeviceKey: outputUID, + kAudioAggregateDeviceIsPrivateKey: true, + kAudioAggregateDeviceIsStackedKey: false, + kAudioAggregateDeviceTapAutoStartKey: true, + kAudioAggregateDeviceSubDeviceListKey: [ + [ + kAudioSubDeviceUIDKey: outputUID + ] + ], + kAudioAggregateDeviceTapListKey: [ + [ + kAudioSubTapDriftCompensationKey: true, + kAudioSubTapUIDKey: tapDescription.uuid.uuidString + ] + ] + ] + + self.tapStreamDescription = try tapID.readAudioTapStreamBasicDescription() + + aggregateDeviceID = AudioObjectID.unknown + err = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateDeviceID) + guard err == noErr else { + throw "Failed to create aggregate device: \(err)" + } + + logger.debug("Created system-wide aggregate device #\(self.aggregateDeviceID, privacy: .public)") + } + + func run(on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock, + invalidationHandler: @escaping InvalidationHandler) throws { + assert(activated, "\(#function) called with inactive tap!") + assert(self.invalidationHandler == nil, "\(#function) called with tap already active!") + + errorMessage = nil + + logger.debug("Run system-wide tap!") + + self.invalidationHandler = invalidationHandler + + var err = AudioDeviceCreateIOProcIDWithBlock(&deviceProcID, aggregateDeviceID, queue, ioBlock) + guard err == noErr else { throw "Failed to create device I/O proc: \(err)" } + + err = AudioDeviceStart(aggregateDeviceID, deviceProcID) + guard err == noErr else { throw "Failed to start audio device: \(err)" } + } + + deinit { + invalidate() + } +} + +final class SystemWideTapRecorder: ObservableObject, AudioTapRecorderType { + let fileURL: URL + private let queue = DispatchQueue(label: "SystemWideTapRecorder", qos: .userInitiated) + private let logger: Logger + + @ObservationIgnored + private weak var _tap: SystemWideTap? + + private(set) var isRecording = false + + init(fileURL: URL, tap: SystemWideTap) { + self.fileURL = fileURL + self._tap = tap + self.logger = Logger(subsystem: AppConstants.Logging.subsystem, + category: "\(String(describing: SystemWideTapRecorder.self))(\(fileURL.lastPathComponent))" + ) + } + + private var tap: SystemWideTap { + get throws { + guard let tap = _tap else { + throw AudioCaptureError.coreAudioError("System-wide tap unavailable") + } + return tap + } + } + + @ObservationIgnored + private var currentFile: AVAudioFile? + + @MainActor + func start() throws { + logger.debug(#function) + + guard !isRecording else { + logger.warning("\(#function, privacy: .public) while already recording") + return + } + + let tap = try tap + + if !tap.activated { + tap.activate() + } + + guard var streamDescription = tap.tapStreamDescription else { + throw AudioCaptureError.coreAudioError("Tap stream description not available") + } + + guard let format = AVAudioFormat(streamDescription: &streamDescription) else { + throw AudioCaptureError.coreAudioError("Failed to create AVAudioFormat") + } + + logger.info("Using system-wide audio format: \(format, privacy: .public)") + + let settings: [String: Any] = [ + AVFormatIDKey: streamDescription.mFormatID, + AVSampleRateKey: format.sampleRate, + AVNumberOfChannelsKey: format.channelCount + ] + + let file = try AVAudioFile(forWriting: fileURL, settings: settings, commonFormat: .pcmFormatFloat32, + interleaved: format.isInterleaved) + + self.currentFile = file + + try tap.run(on: queue) { [weak self] _, inInputData, _, _, _ in + guard let self, let currentFile = self.currentFile else { return } + do { + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: inInputData, + deallocator: nil) else { + throw "Failed to create PCM buffer" + } + + try currentFile.write(from: buffer) + + self.updateAudioLevel(from: buffer) + } catch { + logger.error("\(error, privacy: .public)") + } + } invalidationHandler: { [weak self] _ in + guard let self else { return } + handleInvalidation() + } + + isRecording = true + } + + func stop() { + do { + logger.debug(#function) + + guard isRecording else { return } + + currentFile = nil + isRecording = false + + try tap.invalidate() + } catch { + logger.error("Stop failed: \(error, privacy: .public)") + } + } + + private func handleInvalidation() { + guard isRecording else { return } + logger.debug(#function) + } + + private func updateAudioLevel(from buffer: AVAudioPCMBuffer) { + guard let floatData = buffer.floatChannelData else { return } + + let channelCount = Int(buffer.format.channelCount) + let frameLength = Int(buffer.frameLength) + + var maxLevel: Float = 0.0 + + for channel in 0.. AudioRecordingCoordinatorType { - let processTap = ProcessTap(process: configuration.audioProcess) - await MainActor.run { - processTap.activate() - } - - if let errorMessage = processTap.errorMessage { - logger.error("Process tap failed: \(errorMessage)") - throw AudioCaptureError.coreAudioError("Failed to tap system audio: \(errorMessage)") - } - let microphoneCaptureToUse = configuration.enableMicrophone ? microphoneCapture : nil if configuration.enableMicrophone { @@ -35,15 +25,51 @@ final class RecordingSessionManager: RecordingSessionManaging { } } - let coordinator = AudioRecordingCoordinator( - configuration: configuration, - microphoneCapture: microphoneCaptureToUse, - processTap: processTap - ) + let coordinator: AudioRecordingCoordinator + + if configuration.audioProcess.id == -1 { + let systemWideTap = SystemWideTap() + await MainActor.run { + systemWideTap.activate() + } + + if let errorMessage = systemWideTap.errorMessage { + logger.error("System-wide tap failed: \(errorMessage)") + throw AudioCaptureError.coreAudioError("Failed to tap system audio: \(errorMessage)") + } + + coordinator = AudioRecordingCoordinator( + configuration: configuration, + microphoneCapture: microphoneCaptureToUse, + systemWideTap: systemWideTap + ) + + logger.info( + "Recording session started for system-wide audio with microphone: \(configuration.enableMicrophone)") + } else { + let processTap = ProcessTap(process: configuration.audioProcess) + await MainActor.run { + processTap.activate() + } + + if let errorMessage = processTap.errorMessage { + logger.error("Process tap failed: \(errorMessage)") + throw AudioCaptureError.coreAudioError("Failed to tap system audio: \(errorMessage)") + } + + coordinator = AudioRecordingCoordinator( + configuration: configuration, + microphoneCapture: microphoneCaptureToUse, + processTap: processTap + ) + + logger.info(""" + Recording session started for \(configuration.audioProcess.name) + with microphone: \(configuration.enableMicrophone) + """) + } try await coordinator.start() - - logger.info("Recording session started for \(configuration.audioProcess.name) with microphone: \(configuration.enableMicrophone)") return coordinator } } diff --git a/Recap/Audio/Processing/Types/RecordingConfiguration.swift b/Recap/Audio/Processing/Types/RecordingConfiguration.swift index ded7326..8eda533 100644 --- a/Recap/Audio/Processing/Types/RecordingConfiguration.swift +++ b/Recap/Audio/Processing/Types/RecordingConfiguration.swift @@ -7,18 +7,20 @@ struct RecordingConfiguration { let baseURL: URL var expectedFiles: RecordedFiles { + let applicationName = audioProcess.id == -1 ? "All Apps" : audioProcess.name + if enableMicrophone { return RecordedFiles( microphoneURL: baseURL.appendingPathExtension("microphone.wav"), systemAudioURL: baseURL.appendingPathExtension("system.wav"), - applicationName: audioProcess.name + applicationName: applicationName ) } else { return RecordedFiles( microphoneURL: nil, systemAudioURL: baseURL.appendingPathExtension("system.wav"), - applicationName: audioProcess.name + applicationName: applicationName ) } } -} \ No newline at end of file +} diff --git a/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift b/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift index 40a5d19..fc4cb60 100644 --- a/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift +++ b/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift @@ -35,6 +35,12 @@ struct AppSelectionDropdown: View { VStack(alignment: .leading, spacing: 0) { dropdownHeader + systemWideRow + + if !viewModel.meetingApps.isEmpty || !viewModel.otherApps.isEmpty { + sectionDivider + } + if !viewModel.meetingApps.isEmpty { sectionHeader("Meeting Apps") ForEach(viewModel.meetingApps) { app in @@ -154,6 +160,45 @@ struct AppSelectionDropdown: View { .padding(.vertical, UIConstants.Spacing.gridSpacing) } + private var systemWideRow: some View { + Button { + onAppSelected(SelectableApp.allApps) + } label: { + HStack(spacing: 8) { + Image(nsImage: SelectableApp.allApps.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + + Text("All Apps") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + + Spacer(minLength: 0) + + Circle() + .fill(UIConstants.Colors.audioGreen) + .frame(width: 5, height: 5) + } + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) + .fill(Color.clear) + .onHover { isHovered in + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + ) + } + private var clearSelectionRow: some View { Button { onClearSelection() diff --git a/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift index d3a4872..d7ec093 100644 --- a/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift +++ b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift @@ -81,7 +81,7 @@ final class AppSelectionViewModel: AppSelectionViewModelType { return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } - availableApps = sortedApps + availableApps = [SelectableApp.allApps] + sortedApps meetingApps = sortedApps.filter(\.isMeetingApp) otherApps = sortedApps.filter { !$0.isMeetingApp } }