Skip to content

Commit 6c29f1e

Browse files
wobondarIvo Bellin Salarin
authored andcommitted
feat: Add System-Wide audio tap recording functionality
1 parent 89f6a67 commit 6c29f1e

10 files changed

Lines changed: 510 additions & 43 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Foundation
2+
import AudioToolbox
3+
import AVFoundation
4+
5+
protocol AudioTapType: ObservableObject {
6+
var activated: Bool { get }
7+
var audioLevel: Float { get }
8+
var errorMessage: String? { get }
9+
var tapStreamDescription: AudioStreamBasicDescription? { get }
10+
11+
@MainActor func activate()
12+
func invalidate()
13+
func run(on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock,
14+
invalidationHandler: @escaping (Self) -> Void) throws
15+
}
16+
17+
protocol AudioTapRecorderType: ObservableObject {
18+
var fileURL: URL { get }
19+
var isRecording: Bool { get }
20+
21+
@MainActor func start() throws
22+
func stop()
23+
}

Recap/Audio/Capture/Tap/ProcessTap.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ extension String: @retroactive LocalizedError {
77
public var errorDescription: String? { self }
88
}
99

10-
final class ProcessTap: ObservableObject {
10+
final class ProcessTap: ObservableObject, AudioTapType {
1111
typealias InvalidationHandler = (ProcessTap) -> Void
1212

1313
let process: AudioProcess
@@ -192,7 +192,7 @@ final class ProcessTap: ObservableObject {
192192
}
193193
}
194194

195-
final class ProcessTapRecorder: ObservableObject {
195+
final class ProcessTapRecorder: ObservableObject, AudioTapRecorderType {
196196
let fileURL: URL
197197
let process: AudioProcess
198198
private let queue = DispatchQueue(label: "ProcessTapRecorder", qos: .userInitiated)
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import SwiftUI
2+
import AudioToolbox
3+
import OSLog
4+
import AVFoundation
5+
6+
final class SystemWideTap: ObservableObject, AudioTapType {
7+
typealias InvalidationHandler = (SystemWideTap) -> Void
8+
9+
let muteWhenRunning: Bool
10+
private let logger: Logger
11+
12+
private(set) var errorMessage: String?
13+
@Published private(set) var audioLevel: Float = 0.0
14+
15+
fileprivate func setAudioLevel(_ level: Float) {
16+
audioLevel = level
17+
}
18+
19+
init(muteWhenRunning: Bool = false) {
20+
self.muteWhenRunning = muteWhenRunning
21+
self.logger = Logger(subsystem: AppConstants.Logging.subsystem, category:
22+
"\(String(describing: SystemWideTap.self))")
23+
}
24+
25+
@ObservationIgnored
26+
private var processTapID: AudioObjectID = .unknown
27+
@ObservationIgnored
28+
private var aggregateDeviceID = AudioObjectID.unknown
29+
@ObservationIgnored
30+
private var deviceProcID: AudioDeviceIOProcID?
31+
@ObservationIgnored
32+
private(set) var tapStreamDescription: AudioStreamBasicDescription?
33+
@ObservationIgnored
34+
private var invalidationHandler: InvalidationHandler?
35+
36+
@ObservationIgnored
37+
private(set) var activated = false
38+
39+
@MainActor
40+
func activate() {
41+
guard !activated else { return }
42+
activated = true
43+
44+
logger.debug(#function)
45+
46+
self.errorMessage = nil
47+
48+
do {
49+
try prepareSystemWideTap()
50+
} catch {
51+
logger.error("\(error, privacy: .public)")
52+
self.errorMessage = error.localizedDescription
53+
}
54+
}
55+
56+
func invalidate() {
57+
guard activated else { return }
58+
defer { activated = false }
59+
60+
logger.debug(#function)
61+
62+
invalidationHandler?(self)
63+
self.invalidationHandler = nil
64+
65+
if aggregateDeviceID.isValid {
66+
var err = AudioDeviceStop(aggregateDeviceID, deviceProcID)
67+
if err != noErr { logger.warning("Failed to stop aggregate device: \(err, privacy: .public)") }
68+
69+
if let deviceProcID = deviceProcID {
70+
err = AudioDeviceDestroyIOProcID(aggregateDeviceID, deviceProcID)
71+
if err != noErr { logger.warning("Failed to destroy device I/O proc: \(err, privacy: .public)") }
72+
self.deviceProcID = nil
73+
}
74+
75+
err = AudioHardwareDestroyAggregateDevice(aggregateDeviceID)
76+
if err != noErr {
77+
logger.warning("Failed to destroy aggregate device: \(err, privacy: .public)")
78+
}
79+
aggregateDeviceID = .unknown
80+
}
81+
82+
if processTapID.isValid {
83+
let err = AudioHardwareDestroyProcessTap(processTapID)
84+
if err != noErr {
85+
logger.warning("Failed to destroy audio tap: \(err, privacy: .public)")
86+
}
87+
self.processTapID = .unknown
88+
}
89+
}
90+
91+
private func prepareSystemWideTap() throws {
92+
errorMessage = nil
93+
94+
let tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
95+
tapDescription.uuid = UUID()
96+
tapDescription.muteBehavior = muteWhenRunning ? .mutedWhenTapped : .unmuted
97+
tapDescription.name = "SystemWideAudioTap"
98+
tapDescription.isPrivate = true
99+
tapDescription.isExclusive = true
100+
101+
var tapID: AUAudioObjectID = .unknown
102+
var err = AudioHardwareCreateProcessTap(tapDescription, &tapID)
103+
104+
guard err == noErr else {
105+
errorMessage = "System-wide process tap creation failed with error \(err)"
106+
return
107+
}
108+
109+
logger.debug("Created system-wide process tap #\(tapID, privacy: .public)")
110+
111+
self.processTapID = tapID
112+
113+
let systemOutputID = try AudioDeviceID.readDefaultSystemOutputDevice()
114+
let outputUID = try systemOutputID.readDeviceUID()
115+
let aggregateUID = UUID().uuidString
116+
117+
let description: [String: Any] = [
118+
kAudioAggregateDeviceNameKey: "SystemWide-Tap",
119+
kAudioAggregateDeviceUIDKey: aggregateUID,
120+
kAudioAggregateDeviceMainSubDeviceKey: outputUID,
121+
kAudioAggregateDeviceIsPrivateKey: true,
122+
kAudioAggregateDeviceIsStackedKey: false,
123+
kAudioAggregateDeviceTapAutoStartKey: true,
124+
kAudioAggregateDeviceSubDeviceListKey: [
125+
[
126+
kAudioSubDeviceUIDKey: outputUID
127+
]
128+
],
129+
kAudioAggregateDeviceTapListKey: [
130+
[
131+
kAudioSubTapDriftCompensationKey: true,
132+
kAudioSubTapUIDKey: tapDescription.uuid.uuidString
133+
]
134+
]
135+
]
136+
137+
self.tapStreamDescription = try tapID.readAudioTapStreamBasicDescription()
138+
139+
aggregateDeviceID = AudioObjectID.unknown
140+
err = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateDeviceID)
141+
guard err == noErr else {
142+
throw "Failed to create aggregate device: \(err)"
143+
}
144+
145+
logger.debug("Created system-wide aggregate device #\(self.aggregateDeviceID, privacy: .public)")
146+
}
147+
148+
func run(on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock,
149+
invalidationHandler: @escaping InvalidationHandler) throws {
150+
assert(activated, "\(#function) called with inactive tap!")
151+
assert(self.invalidationHandler == nil, "\(#function) called with tap already active!")
152+
153+
errorMessage = nil
154+
155+
logger.debug("Run system-wide tap!")
156+
157+
self.invalidationHandler = invalidationHandler
158+
159+
var err = AudioDeviceCreateIOProcIDWithBlock(&deviceProcID, aggregateDeviceID, queue, ioBlock)
160+
guard err == noErr else { throw "Failed to create device I/O proc: \(err)" }
161+
162+
err = AudioDeviceStart(aggregateDeviceID, deviceProcID)
163+
guard err == noErr else { throw "Failed to start audio device: \(err)" }
164+
}
165+
166+
deinit {
167+
invalidate()
168+
}
169+
}
170+
171+
final class SystemWideTapRecorder: ObservableObject, AudioTapRecorderType {
172+
let fileURL: URL
173+
private let queue = DispatchQueue(label: "SystemWideTapRecorder", qos: .userInitiated)
174+
private let logger: Logger
175+
176+
@ObservationIgnored
177+
private weak var _tap: SystemWideTap?
178+
179+
private(set) var isRecording = false
180+
181+
init(fileURL: URL, tap: SystemWideTap) {
182+
self.fileURL = fileURL
183+
self._tap = tap
184+
self.logger = Logger(subsystem: AppConstants.Logging.subsystem,
185+
category: "\(String(describing: SystemWideTapRecorder.self))(\(fileURL.lastPathComponent))"
186+
)
187+
}
188+
189+
private var tap: SystemWideTap {
190+
get throws {
191+
guard let tap = _tap else {
192+
throw AudioCaptureError.coreAudioError("System-wide tap unavailable")
193+
}
194+
return tap
195+
}
196+
}
197+
198+
@ObservationIgnored
199+
private var currentFile: AVAudioFile?
200+
201+
@MainActor
202+
func start() throws {
203+
logger.debug(#function)
204+
205+
guard !isRecording else {
206+
logger.warning("\(#function, privacy: .public) while already recording")
207+
return
208+
}
209+
210+
let tap = try tap
211+
212+
if !tap.activated {
213+
tap.activate()
214+
}
215+
216+
guard var streamDescription = tap.tapStreamDescription else {
217+
throw AudioCaptureError.coreAudioError("Tap stream description not available")
218+
}
219+
220+
guard let format = AVAudioFormat(streamDescription: &streamDescription) else {
221+
throw AudioCaptureError.coreAudioError("Failed to create AVAudioFormat")
222+
}
223+
224+
logger.info("Using system-wide audio format: \(format, privacy: .public)")
225+
226+
let settings: [String: Any] = [
227+
AVFormatIDKey: streamDescription.mFormatID,
228+
AVSampleRateKey: format.sampleRate,
229+
AVNumberOfChannelsKey: format.channelCount
230+
]
231+
232+
let file = try AVAudioFile(forWriting: fileURL, settings: settings, commonFormat: .pcmFormatFloat32,
233+
interleaved: format.isInterleaved)
234+
235+
self.currentFile = file
236+
237+
try tap.run(on: queue) { [weak self] _, inInputData, _, _, _ in
238+
guard let self, let currentFile = self.currentFile else { return }
239+
do {
240+
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: inInputData,
241+
deallocator: nil) else {
242+
throw "Failed to create PCM buffer"
243+
}
244+
245+
try currentFile.write(from: buffer)
246+
247+
self.updateAudioLevel(from: buffer)
248+
} catch {
249+
logger.error("\(error, privacy: .public)")
250+
}
251+
} invalidationHandler: { [weak self] _ in
252+
guard let self else { return }
253+
handleInvalidation()
254+
}
255+
256+
isRecording = true
257+
}
258+
259+
func stop() {
260+
do {
261+
logger.debug(#function)
262+
263+
guard isRecording else { return }
264+
265+
currentFile = nil
266+
isRecording = false
267+
268+
try tap.invalidate()
269+
} catch {
270+
logger.error("Stop failed: \(error, privacy: .public)")
271+
}
272+
}
273+
274+
private func handleInvalidation() {
275+
guard isRecording else { return }
276+
logger.debug(#function)
277+
}
278+
279+
private func updateAudioLevel(from buffer: AVAudioPCMBuffer) {
280+
guard let floatData = buffer.floatChannelData else { return }
281+
282+
let channelCount = Int(buffer.format.channelCount)
283+
let frameLength = Int(buffer.frameLength)
284+
285+
var maxLevel: Float = 0.0
286+
287+
for channel in 0..<channelCount {
288+
let channelData = floatData[channel]
289+
290+
var channelLevel: Float = 0.0
291+
for frame in 0..<frameLength {
292+
let sample = abs(channelData[frame])
293+
channelLevel = max(channelLevel, sample)
294+
}
295+
296+
maxLevel = max(maxLevel, channelLevel)
297+
}
298+
299+
let decibels = 20 * log10(max(maxLevel, 0.00001))
300+
let normalizedLevel = (decibels + 60) / 60
301+
302+
Task { @MainActor in
303+
self._tap?.setAudioLevel(min(max(normalizedLevel, 0), 1))
304+
}
305+
}
306+
}

Recap/Audio/Models/AudioProcess.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ struct AudioProcess: Identifiable, Hashable, Sendable {
66
enum Kind: String, Sendable {
77
case process
88
case app
9+
// case system
910
}
1011

1112
var id: pid_t
@@ -51,13 +52,15 @@ extension AudioProcess.Kind {
5152
switch self {
5253
case .process: NSWorkspace.shared.icon(for: .unixExecutable)
5354
case .app: NSWorkspace.shared.icon(for: .applicationBundle)
55+
// case .system: NSWorkspace.shared.icon(for: .systemPreferencesPane)
5456
}
5557
}
5658

5759
var groupTitle: String {
5860
switch self {
5961
case .process: "Processes"
6062
case .app: "Apps"
63+
// case .system: "System"
6164
}
6265
}
6366
}

0 commit comments

Comments
 (0)