@infilectai/aira 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -2
- package/bin/aira-capture +0 -0
- package/dist/attribution.d.ts +118 -0
- package/dist/attribution.d.ts.map +1 -0
- package/dist/attribution.js +626 -0
- package/dist/attribution.js.map +1 -0
- package/dist/audio.d.ts +26 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +172 -0
- package/dist/audio.js.map +1 -0
- package/dist/brain.d.ts +11 -0
- package/dist/brain.d.ts.map +1 -0
- package/dist/brain.js +70 -0
- package/dist/brain.js.map +1 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +195 -0
- package/dist/capture.js.map +1 -0
- package/dist/engine.d.ts +61 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +471 -0
- package/dist/engine.js.map +1 -0
- package/dist/eyes.d.ts +60 -0
- package/dist/eyes.d.ts.map +1 -0
- package/dist/eyes.js +173 -0
- package/dist/eyes.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lockfile.d.ts +14 -0
- package/dist/lockfile.d.ts.map +1 -0
- package/dist/lockfile.js +38 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/providers.d.ts +162 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +374 -0
- package/dist/providers.js.map +1 -0
- package/dist/transport.d.ts +14 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +44 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vectors.d.ts +10 -0
- package/dist/vectors.d.ts.map +1 -0
- package/dist/vectors.js +66 -0
- package/dist/vectors.js.map +1 -0
- package/native/AiraCapture.swift +1142 -0
- package/native/Info.plist +26 -0
- package/native/NativeMeetingScraper.swift +1277 -0
- package/package.json +84 -4
- package/scripts/build-capture.cjs +57 -0
- package/scripts/postinstall.cjs +29 -0
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AppKit
|
|
3
|
+
import ApplicationServices
|
|
4
|
+
import CoreGraphics
|
|
5
|
+
import CoreAudio
|
|
6
|
+
import AudioToolbox
|
|
7
|
+
import AVFoundation
|
|
8
|
+
import CoreMedia
|
|
9
|
+
import ScreenCaptureKit
|
|
10
|
+
|
|
11
|
+
// MARK: - Core Wire Types
|
|
12
|
+
|
|
13
|
+
public struct MeetingTarget: Codable, Sendable, Equatable {
|
|
14
|
+
public let pid: Int32
|
|
15
|
+
public let browser: String
|
|
16
|
+
public let platform: String
|
|
17
|
+
public let url: String
|
|
18
|
+
public let title: String
|
|
19
|
+
|
|
20
|
+
public init(pid: Int32, browser: String, platform: String, url: String, title: String) {
|
|
21
|
+
self.pid = pid
|
|
22
|
+
self.browser = browser
|
|
23
|
+
self.platform = platform
|
|
24
|
+
self.url = url
|
|
25
|
+
self.title = title
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public static func == (lhs: MeetingTarget, rhs: MeetingTarget) -> Bool {
|
|
29
|
+
lhs.pid == rhs.pid && lhs.url == rhs.url
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public struct Participant: Codable, Sendable, Hashable {
|
|
34
|
+
public let id: String
|
|
35
|
+
public let name: String
|
|
36
|
+
|
|
37
|
+
public init(id: String, name: String) {
|
|
38
|
+
self.id = id
|
|
39
|
+
self.name = name
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public struct ChromeParticipant: Codable, Sendable {
|
|
44
|
+
public let pid: String
|
|
45
|
+
public let name: String
|
|
46
|
+
public let muted: Bool?
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public struct ChromeEvent: Codable, Sendable {
|
|
50
|
+
public let type: String
|
|
51
|
+
public let speaker: String?
|
|
52
|
+
public let method: String?
|
|
53
|
+
public let participants: [ChromeParticipant]?
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// MARK: - Stdio Protocol Framing (`aira_capture|<json>`)
|
|
57
|
+
|
|
58
|
+
private let stdoutLock = NSLock()
|
|
59
|
+
|
|
60
|
+
private func sendProtocolJSON<T: Encodable>(_ payload: T) {
|
|
61
|
+
guard let data = try? JSONEncoder().encode(payload),
|
|
62
|
+
let str = String(data: data, encoding: .utf8) else { return }
|
|
63
|
+
stdoutLock.lock()
|
|
64
|
+
defer { stdoutLock.unlock() }
|
|
65
|
+
if let lineData = ("aira_capture|" + str + "\n").data(using: .utf8) {
|
|
66
|
+
FileHandle.standardOutput.write(lineData)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private struct RPCResponse<R: Encodable>: Encodable {
|
|
71
|
+
let type = "response"
|
|
72
|
+
let id: String
|
|
73
|
+
let status: String
|
|
74
|
+
let result: R?
|
|
75
|
+
let error: String?
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private struct RPCEvent<P: Encodable>: Encodable {
|
|
79
|
+
let type = "event"
|
|
80
|
+
let event: String
|
|
81
|
+
let payload: P
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func sendResponse<R: Encodable>(id: String, result: R) {
|
|
85
|
+
sendProtocolJSON(RPCResponse(id: id, status: "success", result: result, error: nil))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private func sendErrorResponse(id: String, message: String) {
|
|
89
|
+
sendProtocolJSON(RPCResponse<String>(id: id, status: "error", result: nil, error: message))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private func emitEvent<P: Encodable>(_ name: String, payload: P) {
|
|
93
|
+
sendProtocolJSON(RPCEvent(event: name, payload: payload))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - Permission Manager
|
|
97
|
+
|
|
98
|
+
enum EyesPermissionManager {
|
|
99
|
+
static func checkAll(prompt: Bool) async -> [String: String] {
|
|
100
|
+
let axOpts = ["AXTrustedCheckOptionPrompt": prompt] as CFDictionary
|
|
101
|
+
let axGranted = AXIsProcessTrustedWithOptions(axOpts)
|
|
102
|
+
let screenGranted = prompt ? CGRequestScreenCaptureAccess() : CGPreflightScreenCaptureAccess()
|
|
103
|
+
|
|
104
|
+
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
105
|
+
let micGranted: Bool
|
|
106
|
+
switch micStatus {
|
|
107
|
+
case .authorized:
|
|
108
|
+
micGranted = true
|
|
109
|
+
case .notDetermined where prompt:
|
|
110
|
+
micGranted = await AVCaptureDevice.requestAccess(for: .audio)
|
|
111
|
+
default:
|
|
112
|
+
micGranted = false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let sysAudioGranted: Bool
|
|
116
|
+
if #available(macOS 14.2, *) {
|
|
117
|
+
sysAudioGranted = true
|
|
118
|
+
} else {
|
|
119
|
+
sysAudioGranted = screenGranted
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
"accessibility": axGranted ? "granted" : "denied",
|
|
124
|
+
"screen-capture": screenGranted ? "granted" : "denied",
|
|
125
|
+
"microphone": micGranted ? "granted" : "denied",
|
|
126
|
+
"system-audio": sysAudioGranted ? "granted" : "denied",
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// MARK: - Meeting Detector
|
|
132
|
+
|
|
133
|
+
actor MeetingDetector {
|
|
134
|
+
static let shared = MeetingDetector()
|
|
135
|
+
|
|
136
|
+
private var isPolling = false
|
|
137
|
+
private var pollTask: Task<Void, Never>?
|
|
138
|
+
private var currentMeetings: [MeetingTarget] = []
|
|
139
|
+
|
|
140
|
+
func start() async {
|
|
141
|
+
if !isPolling {
|
|
142
|
+
isPolling = true
|
|
143
|
+
await poll(forceEmit: true)
|
|
144
|
+
pollTask = Task {
|
|
145
|
+
while !Task.isCancelled && isPolling {
|
|
146
|
+
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
|
147
|
+
guard !Task.isCancelled && isPolling else { break }
|
|
148
|
+
await poll(forceEmit: false)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
emitEvent("meetings", payload: currentMeetings)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func stop() {
|
|
157
|
+
isPolling = false
|
|
158
|
+
pollTask?.cancel()
|
|
159
|
+
pollTask = nil
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private func poll(forceEmit: Bool) async {
|
|
163
|
+
var foundMeetings: [MeetingTarget] = []
|
|
164
|
+
let targetBundleIds: Set<String> = [
|
|
165
|
+
"com.google.Chrome",
|
|
166
|
+
"company.thebrowser.Browser",
|
|
167
|
+
"com.brave.Browser",
|
|
168
|
+
"com.microsoft.edgemac",
|
|
169
|
+
"com.vivaldi.Vivaldi",
|
|
170
|
+
"com.apple.Safari",
|
|
171
|
+
"us.zoom.xos",
|
|
172
|
+
"com.microsoft.teams",
|
|
173
|
+
"com.microsoft.teams2"
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
let apps = NSWorkspace.shared.runningApplications
|
|
177
|
+
for app in apps {
|
|
178
|
+
guard app.activationPolicy == .regular,
|
|
179
|
+
let bundleId = app.bundleIdentifier,
|
|
180
|
+
targetBundleIds.contains(bundleId) else { continue }
|
|
181
|
+
|
|
182
|
+
let detected = NativeMeetingScraper.shared.detectMeetings(in: app)
|
|
183
|
+
for mt in detected {
|
|
184
|
+
if !foundMeetings.contains(where: { $0.url == mt.url && $0.pid == mt.pid }) {
|
|
185
|
+
foundMeetings.append(mt)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if let content = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) {
|
|
191
|
+
for window in content.windows {
|
|
192
|
+
guard let app = window.owningApplication else { continue }
|
|
193
|
+
let bundleId = app.bundleIdentifier
|
|
194
|
+
guard targetBundleIds.contains(bundleId) else { continue }
|
|
195
|
+
let pid = app.processID
|
|
196
|
+
let title = window.title ?? ""
|
|
197
|
+
|
|
198
|
+
if window.frame.height < 120 && (title.contains("sharing your screen") || title.contains("is sharing")) {
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if let mt = parseMeetingTarget(title: title, bundleId: bundleId, pid: pid, appName: app.applicationName) {
|
|
203
|
+
if !foundMeetings.contains(where: { $0.pid == mt.pid && ($0.url == mt.url || $0.platform == mt.platform) }) {
|
|
204
|
+
foundMeetings.append(mt)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let changed = currentMeetings != foundMeetings
|
|
211
|
+
currentMeetings = foundMeetings
|
|
212
|
+
|
|
213
|
+
if changed || forceEmit {
|
|
214
|
+
emitEvent("meetings", payload: currentMeetings)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private func parseMeetingTarget(title: String, bundleId: String, pid: pid_t, appName: String) -> MeetingTarget? {
|
|
219
|
+
let browserBundles: Set<String> = [
|
|
220
|
+
"com.google.Chrome", "company.thebrowser.Browser", "com.brave.Browser",
|
|
221
|
+
"com.microsoft.edgemac", "com.vivaldi.Vivaldi", "com.apple.Safari"
|
|
222
|
+
]
|
|
223
|
+
if browserBundles.contains(bundleId) {
|
|
224
|
+
if title.starts(with: "Meet - ") || title.starts(with: "Meet – ") {
|
|
225
|
+
let cleanTitle = NativeMeetingScraper.shared.sanitizeMeetingTitle(title, fallbackCode: nil)
|
|
226
|
+
let suffix = cleanTitle.replacingOccurrences(of: #"^Meet\s*[-–]\s*"#, with: "", options: .regularExpression)
|
|
227
|
+
let codePattern = #"([a-z]{3}-[a-z]{4}-[a-z]{3})"#
|
|
228
|
+
let range = NSRange(suffix.startIndex..<suffix.endIndex, in: suffix)
|
|
229
|
+
let slug: String
|
|
230
|
+
if let regex = try? NSRegularExpression(pattern: codePattern, options: [.caseInsensitive]),
|
|
231
|
+
let match = regex.firstMatch(in: suffix, options: [], range: range),
|
|
232
|
+
let r = Range(match.range(at: 1), in: suffix) {
|
|
233
|
+
slug = String(suffix[r]).lowercased()
|
|
234
|
+
} else {
|
|
235
|
+
slug = "active"
|
|
236
|
+
}
|
|
237
|
+
let browserSlug = NativeMeetingScraper.shared.normalizeBrowserName(bundleId: bundleId, appName: appName)
|
|
238
|
+
return MeetingTarget(pid: pid, browser: browserSlug, platform: "google-meet", url: "https://meet.google.com/\(slug)", title: cleanTitle)
|
|
239
|
+
}
|
|
240
|
+
} else if bundleId == "us.zoom.xos" {
|
|
241
|
+
if title.contains("Zoom Meeting") || title.contains("Zoom Webinar") || title.hasPrefix("Talking:") {
|
|
242
|
+
return MeetingTarget(pid: pid, browser: "Zoom", platform: "zoom", url: "zoom://meeting/\(pid)", title: title)
|
|
243
|
+
}
|
|
244
|
+
} else if bundleId == "com.microsoft.teams" || bundleId == "com.microsoft.teams2" {
|
|
245
|
+
let lower = title.lowercased()
|
|
246
|
+
let isMainChat = lower.hasPrefix("chat |") || lower.hasPrefix("activity |") || lower.hasPrefix("calendar |") || lower.hasPrefix("teams |")
|
|
247
|
+
if (lower.contains("meeting") || lower.contains("call with")) && !isMainChat {
|
|
248
|
+
return MeetingTarget(pid: pid, browser: appName, platform: "teams", url: "teams://meeting/\(pid)", title: title)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return nil
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// MARK: - Microphone Activity & 16 kHz Resampler
|
|
256
|
+
|
|
257
|
+
enum MicActivity {
|
|
258
|
+
typealias PCMHandler = @Sendable ([Float]) -> Void
|
|
259
|
+
|
|
260
|
+
private static let engine = AVAudioEngine()
|
|
261
|
+
private static var isRunning = false
|
|
262
|
+
private static var rms: Float = 0
|
|
263
|
+
private static var onResampledPCM: PCMHandler?
|
|
264
|
+
private static var converter: AVAudioConverter?
|
|
265
|
+
private static let targetFormat = AVAudioFormat(
|
|
266
|
+
commonFormat: .pcmFormatFloat32,
|
|
267
|
+
sampleRate: 16_000,
|
|
268
|
+
channels: 1,
|
|
269
|
+
interleaved: false
|
|
270
|
+
)!
|
|
271
|
+
private static let lock = NSLock()
|
|
272
|
+
private static let queue = DispatchQueue(label: "aira.mic.activity", qos: .userInitiated)
|
|
273
|
+
|
|
274
|
+
static func start(onPCM: PCMHandler? = nil) {
|
|
275
|
+
lock.lock()
|
|
276
|
+
if let onPCM {
|
|
277
|
+
onResampledPCM = onPCM
|
|
278
|
+
}
|
|
279
|
+
if isRunning {
|
|
280
|
+
lock.unlock()
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
lock.unlock()
|
|
284
|
+
|
|
285
|
+
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
286
|
+
switch status {
|
|
287
|
+
case .authorized:
|
|
288
|
+
queue.async { startEngineIfAuthorized() }
|
|
289
|
+
case .notDetermined:
|
|
290
|
+
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
291
|
+
if granted {
|
|
292
|
+
queue.async { startEngineIfAuthorized() }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
default:
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private static func startEngineIfAuthorized() {
|
|
301
|
+
lock.lock()
|
|
302
|
+
guard !isRunning else {
|
|
303
|
+
lock.unlock()
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
lock.unlock()
|
|
307
|
+
|
|
308
|
+
let input = engine.inputNode
|
|
309
|
+
let fmt = input.inputFormat(forBus: 0)
|
|
310
|
+
guard fmt.sampleRate > 0 && fmt.channelCount > 0 else { return }
|
|
311
|
+
let conv = AVAudioConverter(from: fmt, to: targetFormat)
|
|
312
|
+
lock.lock()
|
|
313
|
+
converter = conv
|
|
314
|
+
lock.unlock()
|
|
315
|
+
|
|
316
|
+
input.removeTap(onBus: 0)
|
|
317
|
+
input.installTap(onBus: 0, bufferSize: 1024, format: fmt) { buffer, _ in
|
|
318
|
+
let data = buffer.floatChannelData?[0]
|
|
319
|
+
let count = Int(buffer.frameLength)
|
|
320
|
+
guard let data, count > 0 else { return }
|
|
321
|
+
var sum: Float = 0
|
|
322
|
+
for i in 0..<count { sum += data[i] * data[i] }
|
|
323
|
+
let level = sqrt(sum / Float(count))
|
|
324
|
+
|
|
325
|
+
var callback: PCMHandler?
|
|
326
|
+
var localConv: AVAudioConverter?
|
|
327
|
+
lock.lock()
|
|
328
|
+
rms = level
|
|
329
|
+
callback = onResampledPCM
|
|
330
|
+
localConv = converter
|
|
331
|
+
lock.unlock()
|
|
332
|
+
|
|
333
|
+
if let callback, let localConv {
|
|
334
|
+
let ratio = targetFormat.sampleRate / buffer.format.sampleRate
|
|
335
|
+
let outFrames = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
|
|
336
|
+
if let outBuf = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames) {
|
|
337
|
+
var err: NSError?
|
|
338
|
+
var consumed = false
|
|
339
|
+
let status = localConv.convert(to: outBuf, error: &err) { _, statusOut in
|
|
340
|
+
if consumed {
|
|
341
|
+
statusOut.pointee = .noDataNow
|
|
342
|
+
return nil
|
|
343
|
+
}
|
|
344
|
+
consumed = true
|
|
345
|
+
statusOut.pointee = .haveData
|
|
346
|
+
return buffer
|
|
347
|
+
}
|
|
348
|
+
if status != .error, err == nil, let ch = outBuf.floatChannelData {
|
|
349
|
+
let n = Int(outBuf.frameLength)
|
|
350
|
+
if n > 0 {
|
|
351
|
+
let samples = Array(UnsafeBufferPointer(start: ch[0], count: n))
|
|
352
|
+
callback(samples)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
engine.prepare()
|
|
359
|
+
do {
|
|
360
|
+
try engine.start()
|
|
361
|
+
lock.lock()
|
|
362
|
+
isRunning = true
|
|
363
|
+
lock.unlock()
|
|
364
|
+
} catch {
|
|
365
|
+
input.removeTap(onBus: 0)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
static func currentRMS() -> Float {
|
|
370
|
+
lock.lock()
|
|
371
|
+
defer { lock.unlock() }
|
|
372
|
+
return rms
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
static func stop() {
|
|
376
|
+
lock.lock()
|
|
377
|
+
defer { lock.unlock() }
|
|
378
|
+
onResampledPCM = nil
|
|
379
|
+
guard isRunning else { return }
|
|
380
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
381
|
+
engine.stop()
|
|
382
|
+
converter = nil
|
|
383
|
+
isRunning = false
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// MARK: - CoreAudio Process Tap + ScreenCaptureKit Audio Capture
|
|
388
|
+
|
|
389
|
+
final class ProcessAudioCapture: NSObject, @unchecked Sendable {
|
|
390
|
+
typealias FrameHandler = @Sendable ([Float]) -> Void
|
|
391
|
+
|
|
392
|
+
private let pid: pid_t
|
|
393
|
+
private let onFrames: FrameHandler
|
|
394
|
+
|
|
395
|
+
private var tapID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
|
|
396
|
+
private var aggregateDeviceID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
|
|
397
|
+
private var ioProcID: AudioDeviceIOProcID?
|
|
398
|
+
private let audioTapQueue = DispatchQueue(label: "aira.audio.tap", qos: .userInitiated)
|
|
399
|
+
|
|
400
|
+
private var stream: SCStream?
|
|
401
|
+
private var streamOutput: AudioStreamOutput?
|
|
402
|
+
private var converter: AVAudioConverter?
|
|
403
|
+
private var sourceFormat: AVAudioFormat?
|
|
404
|
+
private let targetFormat = AVAudioFormat(
|
|
405
|
+
commonFormat: .pcmFormatFloat32,
|
|
406
|
+
sampleRate: 16_000,
|
|
407
|
+
channels: 1,
|
|
408
|
+
interleaved: false
|
|
409
|
+
)!
|
|
410
|
+
|
|
411
|
+
init(pid: pid_t, onFrames: @escaping FrameHandler) {
|
|
412
|
+
self.pid = pid
|
|
413
|
+
self.onFrames = onFrames
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func start() async throws {
|
|
417
|
+
if startCoreAudioProcessTap() {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
|
|
422
|
+
guard let display = content.displays.first else {
|
|
423
|
+
throw NSError(domain: "AiraCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available"])
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
|
|
427
|
+
let config = SCStreamConfiguration()
|
|
428
|
+
config.capturesAudio = true
|
|
429
|
+
config.excludesCurrentProcessAudio = true
|
|
430
|
+
config.sampleRate = 48_000
|
|
431
|
+
config.channelCount = 2
|
|
432
|
+
config.width = 2
|
|
433
|
+
config.height = 2
|
|
434
|
+
config.minimumFrameInterval = CMTime(value: 1, timescale: 1)
|
|
435
|
+
config.queueDepth = 6
|
|
436
|
+
|
|
437
|
+
let stream = SCStream(filter: filter, configuration: config, delegate: nil)
|
|
438
|
+
let output = AudioStreamOutput { [weak self] buffer in
|
|
439
|
+
self?.handle(buffer: buffer)
|
|
440
|
+
}
|
|
441
|
+
try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: .global(qos: .userInitiated))
|
|
442
|
+
try await stream.startCapture()
|
|
443
|
+
self.stream = stream
|
|
444
|
+
self.streamOutput = output
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
func stop() async {
|
|
448
|
+
stopCoreAudioProcessTap()
|
|
449
|
+
guard let stream else { return }
|
|
450
|
+
try? await stream.stopCapture()
|
|
451
|
+
self.stream = nil
|
|
452
|
+
self.streamOutput = nil
|
|
453
|
+
self.converter = nil
|
|
454
|
+
self.sourceFormat = nil
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private func startCoreAudioProcessTap() -> Bool {
|
|
458
|
+
guard #available(macOS 14.2, *) else { return false }
|
|
459
|
+
|
|
460
|
+
let currentPid = ProcessInfo.processInfo.processIdentifier
|
|
461
|
+
let excludedProcessIDs = resolveAudioObjectIDs(forPIDs: [currentPid])
|
|
462
|
+
|
|
463
|
+
let tapDesc = CATapDescription(stereoGlobalTapButExcludeProcesses: excludedProcessIDs)
|
|
464
|
+
let tapUUID = UUID()
|
|
465
|
+
tapDesc.name = "AiraSystemAudioTap-\(tapUUID.uuidString)"
|
|
466
|
+
tapDesc.uuid = tapUUID
|
|
467
|
+
tapDesc.isPrivate = true
|
|
468
|
+
tapDesc.muteBehavior = .unmuted
|
|
469
|
+
|
|
470
|
+
var createdTapID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
|
|
471
|
+
guard AudioHardwareCreateProcessTap(tapDesc, &createdTapID) == noErr,
|
|
472
|
+
createdTapID != kAudioObjectUnknown else {
|
|
473
|
+
return false
|
|
474
|
+
}
|
|
475
|
+
self.tapID = createdTapID
|
|
476
|
+
|
|
477
|
+
let aggUID = "AiraSystemAudioAggregateDevice-\(UUID().uuidString)"
|
|
478
|
+
let tapEntry: [String: Any] = [
|
|
479
|
+
kAudioSubTapUIDKey as String: tapUUID.uuidString,
|
|
480
|
+
kAudioSubTapDriftCompensationKey as String: true
|
|
481
|
+
]
|
|
482
|
+
let aggDesc: [String: Any] = [
|
|
483
|
+
kAudioAggregateDeviceNameKey as String: "AiraSystemAudioAggregateDevice",
|
|
484
|
+
kAudioAggregateDeviceUIDKey as String: aggUID,
|
|
485
|
+
kAudioAggregateDeviceIsPrivateKey as String: true,
|
|
486
|
+
kAudioAggregateDeviceIsStackedKey as String: false,
|
|
487
|
+
kAudioAggregateDeviceTapAutoStartKey as String: true,
|
|
488
|
+
kAudioAggregateDeviceTapListKey as String: [tapEntry]
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
var createdAggID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
|
|
492
|
+
guard AudioHardwareCreateAggregateDevice(aggDesc as CFDictionary, &createdAggID) == noErr,
|
|
493
|
+
createdAggID != kAudioObjectUnknown else {
|
|
494
|
+
AudioHardwareDestroyProcessTap(createdTapID)
|
|
495
|
+
self.tapID = AudioObjectID(kAudioObjectUnknown)
|
|
496
|
+
return false
|
|
497
|
+
}
|
|
498
|
+
self.aggregateDeviceID = createdAggID
|
|
499
|
+
|
|
500
|
+
let tapFormat = getFirstTapInputStreamFormat(for: createdAggID)
|
|
501
|
+
let sampleRate = (tapFormat?.mSampleRate).flatMap { $0 > 0 ? $0 : nil }
|
|
502
|
+
?? getNominalSampleRate(deviceId: createdAggID)
|
|
503
|
+
?? 48_000.0
|
|
504
|
+
let handler = self.onFrames
|
|
505
|
+
|
|
506
|
+
var createdProcID: AudioDeviceIOProcID?
|
|
507
|
+
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&createdProcID, createdAggID, audioTapQueue) { _, inInputData, _, _, _ in
|
|
508
|
+
let bufferList = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inInputData))
|
|
509
|
+
guard bufferList.count > 0 else { return }
|
|
510
|
+
|
|
511
|
+
let firstBuf = bufferList[0]
|
|
512
|
+
guard let rawData = firstBuf.mData, firstBuf.mDataByteSize > 0 else { return }
|
|
513
|
+
let channelCount = max(1, Int(firstBuf.mNumberChannels))
|
|
514
|
+
let totalFloats = Int(firstBuf.mDataByteSize) / MemoryLayout<Float>.size
|
|
515
|
+
let frameCount = totalFloats / channelCount
|
|
516
|
+
guard frameCount > 0 else { return }
|
|
517
|
+
|
|
518
|
+
let floatPtr = rawData.assumingMemoryBound(to: Float.self)
|
|
519
|
+
var mono = [Float](repeating: 0, count: frameCount)
|
|
520
|
+
|
|
521
|
+
if bufferList.count == 1 && channelCount >= 2 {
|
|
522
|
+
for i in 0..<frameCount {
|
|
523
|
+
var sum: Float = 0
|
|
524
|
+
for c in 0..<channelCount {
|
|
525
|
+
sum += floatPtr[i * channelCount + c]
|
|
526
|
+
}
|
|
527
|
+
mono[i] = sum / Float(channelCount)
|
|
528
|
+
}
|
|
529
|
+
} else if bufferList.count >= 2 {
|
|
530
|
+
let ch0 = bufferList[0].mData?.assumingMemoryBound(to: Float.self)
|
|
531
|
+
let ch1 = bufferList[1].mData?.assumingMemoryBound(to: Float.self)
|
|
532
|
+
for i in 0..<frameCount {
|
|
533
|
+
mono[i] = 0.5 * ((ch0?[i] ?? 0) + (ch1?[i] ?? 0))
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
for i in 0..<frameCount {
|
|
537
|
+
mono[i] = floatPtr[i]
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let resampled = Self.resampleLinearTo16kHz(mono, fromRate: sampleRate)
|
|
542
|
+
if !resampled.isEmpty {
|
|
543
|
+
handler(resampled)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
guard ioStatus == noErr, let procID = createdProcID else {
|
|
548
|
+
stopCoreAudioProcessTap()
|
|
549
|
+
return false
|
|
550
|
+
}
|
|
551
|
+
self.ioProcID = procID
|
|
552
|
+
|
|
553
|
+
guard AudioDeviceStart(createdAggID, procID) == noErr else {
|
|
554
|
+
stopCoreAudioProcessTap()
|
|
555
|
+
return false
|
|
556
|
+
}
|
|
557
|
+
return true
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private func stopCoreAudioProcessTap() {
|
|
561
|
+
if let procID = ioProcID, aggregateDeviceID != kAudioObjectUnknown {
|
|
562
|
+
AudioDeviceStop(aggregateDeviceID, procID)
|
|
563
|
+
AudioDeviceDestroyIOProcID(aggregateDeviceID, procID)
|
|
564
|
+
}
|
|
565
|
+
ioProcID = nil
|
|
566
|
+
if aggregateDeviceID != kAudioObjectUnknown {
|
|
567
|
+
AudioHardwareDestroyAggregateDevice(aggregateDeviceID)
|
|
568
|
+
aggregateDeviceID = AudioObjectID(kAudioObjectUnknown)
|
|
569
|
+
}
|
|
570
|
+
if tapID != kAudioObjectUnknown {
|
|
571
|
+
if #available(macOS 14.2, *) {
|
|
572
|
+
AudioHardwareDestroyProcessTap(tapID)
|
|
573
|
+
}
|
|
574
|
+
tapID = AudioObjectID(kAudioObjectUnknown)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private func getFirstTapInputStreamFormat(for aggDeviceId: AudioObjectID) -> AudioStreamBasicDescription? {
|
|
579
|
+
var streamsAddr = AudioObjectPropertyAddress(
|
|
580
|
+
mSelector: kAudioDevicePropertyStreams,
|
|
581
|
+
mScope: kAudioObjectPropertyScopeInput,
|
|
582
|
+
mElement: kAudioObjectPropertyElementMain
|
|
583
|
+
)
|
|
584
|
+
var dataSize: UInt32 = 0
|
|
585
|
+
guard AudioObjectGetPropertyDataSize(aggDeviceId, &streamsAddr, 0, nil, &dataSize) == noErr,
|
|
586
|
+
dataSize >= UInt32(MemoryLayout<AudioStreamID>.size) else {
|
|
587
|
+
return nil
|
|
588
|
+
}
|
|
589
|
+
let streamCount = Int(dataSize) / MemoryLayout<AudioStreamID>.size
|
|
590
|
+
var streams = [AudioStreamID](repeating: 0, count: streamCount)
|
|
591
|
+
guard AudioObjectGetPropertyData(aggDeviceId, &streamsAddr, 0, nil, &dataSize, &streams) == noErr,
|
|
592
|
+
let firstStream = streams.first else {
|
|
593
|
+
return nil
|
|
594
|
+
}
|
|
595
|
+
var formatAddr = AudioObjectPropertyAddress(
|
|
596
|
+
mSelector: kAudioStreamPropertyVirtualFormat,
|
|
597
|
+
mScope: kAudioObjectPropertyScopeGlobal,
|
|
598
|
+
mElement: kAudioObjectPropertyElementMain
|
|
599
|
+
)
|
|
600
|
+
var asbd = AudioStreamBasicDescription()
|
|
601
|
+
var asbdSize = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
|
602
|
+
guard AudioObjectGetPropertyData(firstStream, &formatAddr, 0, nil, &asbdSize, &asbd) == noErr else {
|
|
603
|
+
return nil
|
|
604
|
+
}
|
|
605
|
+
return asbd
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private func getNominalSampleRate(deviceId: AudioObjectID) -> Double? {
|
|
609
|
+
var addr = AudioObjectPropertyAddress(
|
|
610
|
+
mSelector: kAudioDevicePropertyNominalSampleRate,
|
|
611
|
+
mScope: kAudioObjectPropertyScopeGlobal,
|
|
612
|
+
mElement: kAudioObjectPropertyElementMain
|
|
613
|
+
)
|
|
614
|
+
var rate: Float64 = 0
|
|
615
|
+
var size = UInt32(MemoryLayout<Float64>.size)
|
|
616
|
+
guard AudioObjectGetPropertyData(deviceId, &addr, 0, nil, &size, &rate) == noErr, rate > 0 else {
|
|
617
|
+
return nil
|
|
618
|
+
}
|
|
619
|
+
return rate
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private func resolveAudioObjectIDs(forPIDs pids: [pid_t]) -> [AudioObjectID] {
|
|
623
|
+
var result: [AudioObjectID] = []
|
|
624
|
+
var addr = AudioObjectPropertyAddress(
|
|
625
|
+
mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject,
|
|
626
|
+
mScope: kAudioObjectPropertyScopeGlobal,
|
|
627
|
+
mElement: kAudioObjectPropertyElementMain
|
|
628
|
+
)
|
|
629
|
+
for var p in pids {
|
|
630
|
+
var objID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
|
|
631
|
+
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
|
632
|
+
let status = AudioObjectGetPropertyData(
|
|
633
|
+
AudioObjectID(kAudioObjectSystemObject),
|
|
634
|
+
&addr,
|
|
635
|
+
UInt32(MemoryLayout<pid_t>.size),
|
|
636
|
+
&p,
|
|
637
|
+
&size,
|
|
638
|
+
&objID
|
|
639
|
+
)
|
|
640
|
+
if status == noErr && objID != kAudioObjectUnknown {
|
|
641
|
+
result.append(objID)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return result
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private static func resampleLinearTo16kHz(_ input: [Float], fromRate: Double) -> [Float] {
|
|
648
|
+
guard !input.isEmpty, fromRate > 0 else { return [] }
|
|
649
|
+
let targetRate = 16_000.0
|
|
650
|
+
if abs(fromRate - targetRate) < 1.0 { return input }
|
|
651
|
+
let ratio = fromRate / targetRate
|
|
652
|
+
let outLength = max(1, Int(Double(input.count) / ratio))
|
|
653
|
+
var output = [Float](repeating: 0, count: outLength)
|
|
654
|
+
for i in 0..<outLength {
|
|
655
|
+
let srcPos = Double(i) * ratio
|
|
656
|
+
let idx = Int(srcPos)
|
|
657
|
+
let frac = Float(srcPos - Double(idx))
|
|
658
|
+
let s0 = input[min(idx, input.count - 1)]
|
|
659
|
+
let s1 = input[min(idx + 1, input.count - 1)]
|
|
660
|
+
output[i] = s0 + frac * (s1 - s0)
|
|
661
|
+
}
|
|
662
|
+
return output
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private func handle(buffer: CMSampleBuffer) {
|
|
666
|
+
guard CMSampleBufferDataIsReady(buffer),
|
|
667
|
+
let pcm = pcmBuffer(from: buffer),
|
|
668
|
+
let resampled = resample(pcm),
|
|
669
|
+
let chData = resampled.floatChannelData else { return }
|
|
670
|
+
let count = Int(resampled.frameLength)
|
|
671
|
+
guard count > 0 else { return }
|
|
672
|
+
let samples = Array(UnsafeBufferPointer(start: chData[0], count: count))
|
|
673
|
+
onFrames(samples)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private func pcmBuffer(from sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? {
|
|
677
|
+
guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer),
|
|
678
|
+
let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc),
|
|
679
|
+
let format = AVAudioFormat(streamDescription: asbd) else { return nil }
|
|
680
|
+
if sourceFormat != format {
|
|
681
|
+
sourceFormat = format
|
|
682
|
+
converter = AVAudioConverter(from: format, to: targetFormat)
|
|
683
|
+
}
|
|
684
|
+
let nFrames = AVAudioFrameCount(CMSampleBufferGetNumSamples(sampleBuffer))
|
|
685
|
+
guard let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: nFrames) else { return nil }
|
|
686
|
+
pcm.frameLength = nFrames
|
|
687
|
+
let status = CMSampleBufferCopyPCMDataIntoAudioBufferList(
|
|
688
|
+
sampleBuffer, at: 0, frameCount: Int32(nFrames), into: pcm.mutableAudioBufferList
|
|
689
|
+
)
|
|
690
|
+
guard status == noErr else { return nil }
|
|
691
|
+
return pcm
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private func resample(_ input: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
|
695
|
+
guard let converter else { return nil }
|
|
696
|
+
let ratio = targetFormat.sampleRate / input.format.sampleRate
|
|
697
|
+
let outFrames = AVAudioFrameCount(Double(input.frameLength) * ratio + 16)
|
|
698
|
+
guard let output = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames) else { return nil }
|
|
699
|
+
var error: NSError?
|
|
700
|
+
var consumed = false
|
|
701
|
+
let status = converter.convert(to: output, error: &error) { _, statusOut in
|
|
702
|
+
if consumed {
|
|
703
|
+
statusOut.pointee = .noDataNow
|
|
704
|
+
return nil
|
|
705
|
+
}
|
|
706
|
+
consumed = true
|
|
707
|
+
statusOut.pointee = .haveData
|
|
708
|
+
return input
|
|
709
|
+
}
|
|
710
|
+
if status == .error || error != nil { return nil }
|
|
711
|
+
return output
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private final class AudioStreamOutput: NSObject, SCStreamOutput {
|
|
716
|
+
private let handler: (CMSampleBuffer) -> Void
|
|
717
|
+
init(handler: @escaping (CMSampleBuffer) -> Void) { self.handler = handler }
|
|
718
|
+
func stream(_ stream: SCStream, didOutputSampleBuffer buffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
719
|
+
guard type == .audio else { return }
|
|
720
|
+
handler(buffer)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// MARK: - Live Meeting Capture Session (Streams Audio + Speaker Spans + Roster to SDK)
|
|
725
|
+
|
|
726
|
+
struct RosterPayload: Encodable {
|
|
727
|
+
let participants: [Participant]
|
|
728
|
+
let localUserId: String
|
|
729
|
+
let localUserName: String
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
struct SpeakerChangePayload: Encodable {
|
|
733
|
+
let participantId: String
|
|
734
|
+
let name: String
|
|
735
|
+
let t: Double
|
|
736
|
+
let source: String
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
struct AudioChunkPayload: Encodable {
|
|
740
|
+
let pcmBase64: String
|
|
741
|
+
let t0: Double
|
|
742
|
+
let t1: Double
|
|
743
|
+
let sysRMS: Float
|
|
744
|
+
let micRMS: Float
|
|
745
|
+
let micActive: Bool
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
actor CaptureSessionManager {
|
|
749
|
+
static let shared = CaptureSessionManager()
|
|
750
|
+
|
|
751
|
+
private var capture: ProcessAudioCapture?
|
|
752
|
+
private var nativeScrapeLoop: Task<Void, Never>?
|
|
753
|
+
private var roster: [Participant] = []
|
|
754
|
+
private var localUserId: String = "local"
|
|
755
|
+
private var localUserName: String = ""
|
|
756
|
+
private var currentSpeakerId: String = ""
|
|
757
|
+
private var totalAudioSamples: Int = 0
|
|
758
|
+
private var sessionStart: Double = ProcessInfo.processInfo.systemUptime
|
|
759
|
+
private var audioStartUptime: Double = 0
|
|
760
|
+
private var lastSystemAudioUptime: Double = 0
|
|
761
|
+
private var systemOnlyRMS: Float = 0
|
|
762
|
+
private var micPendingSamples: [Float] = []
|
|
763
|
+
private var pcmChunkBuffer: [Float] = []
|
|
764
|
+
private var pcmChunkStartSample: Int = 0
|
|
765
|
+
|
|
766
|
+
func start(target: MeetingTarget) async throws {
|
|
767
|
+
await stop()
|
|
768
|
+
roster = []
|
|
769
|
+
localUserId = "local"
|
|
770
|
+
localUserName = ""
|
|
771
|
+
currentSpeakerId = ""
|
|
772
|
+
totalAudioSamples = 0
|
|
773
|
+
sessionStart = ProcessInfo.processInfo.systemUptime
|
|
774
|
+
audioStartUptime = 0
|
|
775
|
+
lastSystemAudioUptime = 0
|
|
776
|
+
systemOnlyRMS = 0
|
|
777
|
+
micPendingSamples.removeAll()
|
|
778
|
+
pcmChunkBuffer.removeAll()
|
|
779
|
+
pcmChunkStartSample = 0
|
|
780
|
+
|
|
781
|
+
identifyLocalUser(target: target, explicitLocalName: nil)
|
|
782
|
+
emitEvent("roster", payload: RosterPayload(
|
|
783
|
+
participants: roster,
|
|
784
|
+
localUserId: localUserId,
|
|
785
|
+
localUserName: resolvedLocalUserName()
|
|
786
|
+
))
|
|
787
|
+
|
|
788
|
+
startNativeScraper(target: target)
|
|
789
|
+
|
|
790
|
+
MicActivity.start { micSamples in
|
|
791
|
+
Task { await CaptureSessionManager.shared.receiveLocalMic(micSamples) }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
let cap = ProcessAudioCapture(pid: target.pid) { floats in
|
|
795
|
+
Task { await CaptureSessionManager.shared.feedSystemAudio(floats) }
|
|
796
|
+
}
|
|
797
|
+
try await cap.start()
|
|
798
|
+
self.capture = cap
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
func stop() async {
|
|
802
|
+
nativeScrapeLoop?.cancel()
|
|
803
|
+
nativeScrapeLoop = nil
|
|
804
|
+
if !pcmChunkBuffer.isEmpty {
|
|
805
|
+
flushPCMChunk()
|
|
806
|
+
}
|
|
807
|
+
await capture?.stop()
|
|
808
|
+
capture = nil
|
|
809
|
+
MicActivity.stop()
|
|
810
|
+
micPendingSamples.removeAll()
|
|
811
|
+
pcmChunkBuffer.removeAll()
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
func handleChromeEvent(_ event: ChromeEvent) async {
|
|
815
|
+
if let participants = event.participants {
|
|
816
|
+
let incoming = participants.map { Participant(id: $0.pid, name: $0.name) }
|
|
817
|
+
mergeRoster(incoming, explicitLocalName: nil)
|
|
818
|
+
}
|
|
819
|
+
if let speakerName = event.speaker, !speakerName.isEmpty {
|
|
820
|
+
let audioNow = currentAudioSeconds()
|
|
821
|
+
if let match = roster.first(where: { $0.name == speakerName || $0.id == speakerName }) {
|
|
822
|
+
emitSpeakerActivity(id: match.id, name: match.name, audioTime: audioNow, source: "chrome_ext")
|
|
823
|
+
} else {
|
|
824
|
+
emitSpeakerActivity(id: speakerName, name: speakerName, audioTime: audioNow, source: "chrome_ext")
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private func startNativeScraper(target: MeetingTarget) {
|
|
830
|
+
nativeScrapeLoop?.cancel()
|
|
831
|
+
nativeScrapeLoop = Task.detached(priority: .utility) {
|
|
832
|
+
while !Task.isCancelled {
|
|
833
|
+
if let snap = NativeMeetingScraper.shared.scrape(target: target) {
|
|
834
|
+
await CaptureSessionManager.shared.applyScrapeSnapshot(snap)
|
|
835
|
+
}
|
|
836
|
+
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private func applyScrapeSnapshot(_ snap: NativeMeetingScraper.ScrapeSnapshot) {
|
|
842
|
+
if !snap.participants.isEmpty || (snap.localUserName != nil && !(snap.localUserName?.isEmpty ?? true)) {
|
|
843
|
+
mergeRoster(snap.participants, explicitLocalName: snap.localUserName)
|
|
844
|
+
}
|
|
845
|
+
if let activeName = snap.activeSpeakerName, !activeName.isEmpty {
|
|
846
|
+
let activeId = snap.activeSpeakerId ?? activeName
|
|
847
|
+
emitSpeakerActivity(id: activeId, name: activeName, audioTime: currentAudioSeconds(), source: "native_ax")
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private func mergeRoster(_ incoming: [Participant], explicitLocalName: String?) {
|
|
852
|
+
var updatedLocal = false
|
|
853
|
+
if let explicitLocalName, !explicitLocalName.isEmpty, localUserName != explicitLocalName {
|
|
854
|
+
localUserName = explicitLocalName
|
|
855
|
+
updatedLocal = true
|
|
856
|
+
}
|
|
857
|
+
var combined = roster
|
|
858
|
+
var changed = false
|
|
859
|
+
for p in incoming {
|
|
860
|
+
if let idx = combined.firstIndex(where: { $0.id == p.id || $0.name.localizedCaseInsensitiveCompare(p.name) == .orderedSame }) {
|
|
861
|
+
if combined[idx] != p {
|
|
862
|
+
combined[idx] = p
|
|
863
|
+
changed = true
|
|
864
|
+
}
|
|
865
|
+
} else {
|
|
866
|
+
combined.append(p)
|
|
867
|
+
changed = true
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if changed || updatedLocal || roster.isEmpty {
|
|
871
|
+
roster = combined
|
|
872
|
+
refreshLocalUserMapping()
|
|
873
|
+
emitEvent("roster", payload: RosterPayload(
|
|
874
|
+
participants: roster,
|
|
875
|
+
localUserId: localUserId,
|
|
876
|
+
localUserName: resolvedLocalUserName()
|
|
877
|
+
))
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private func emitSpeakerActivity(id: String, name: String, audioTime: Double, source: String) {
|
|
882
|
+
emitEvent("speaker_activity", payload: SpeakerChangePayload(
|
|
883
|
+
participantId: id,
|
|
884
|
+
name: name,
|
|
885
|
+
t: audioTime,
|
|
886
|
+
source: source
|
|
887
|
+
))
|
|
888
|
+
if currentSpeakerId != id {
|
|
889
|
+
currentSpeakerId = id
|
|
890
|
+
emitEvent("speaker_change", payload: SpeakerChangePayload(
|
|
891
|
+
participantId: id,
|
|
892
|
+
name: name,
|
|
893
|
+
t: audioTime,
|
|
894
|
+
source: source
|
|
895
|
+
))
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private func currentAudioSeconds() -> Double {
|
|
900
|
+
if totalAudioSamples > 0 {
|
|
901
|
+
return Double(totalAudioSamples) / 16000.0
|
|
902
|
+
}
|
|
903
|
+
let ref = audioStartUptime > 0 ? audioStartUptime : sessionStart
|
|
904
|
+
return max(0, ProcessInfo.processInfo.systemUptime - ref)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private func feedSystemAudio(_ sysFloats: [Float]) {
|
|
908
|
+
guard !sysFloats.isEmpty else { return }
|
|
909
|
+
let now = ProcessInfo.processInfo.systemUptime
|
|
910
|
+
lastSystemAudioUptime = now
|
|
911
|
+
|
|
912
|
+
var sum: Float = 0
|
|
913
|
+
for s in sysFloats { sum += s * s }
|
|
914
|
+
let rms = sqrt(sum / Float(sysFloats.count))
|
|
915
|
+
systemOnlyRMS = rms
|
|
916
|
+
|
|
917
|
+
var mixed = sysFloats
|
|
918
|
+
if !micPendingSamples.isEmpty {
|
|
919
|
+
let mixCount = min(mixed.count, micPendingSamples.count)
|
|
920
|
+
if rms < 0.018 && MicActivity.currentRMS() >= 0.022 {
|
|
921
|
+
for i in 0..<mixCount {
|
|
922
|
+
let v = mixed[i] + micPendingSamples[i]
|
|
923
|
+
mixed[i] = max(-1.0, min(1.0, v))
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
micPendingSamples.removeFirst(mixCount)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
appendSamples(mixed)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
private func receiveLocalMic(_ micFloats: [Float]) {
|
|
933
|
+
guard !micFloats.isEmpty else { return }
|
|
934
|
+
var sum: Float = 0
|
|
935
|
+
for s in micFloats { sum += s * s }
|
|
936
|
+
let micRMS = sqrt(sum / Float(micFloats.count))
|
|
937
|
+
|
|
938
|
+
let now = ProcessInfo.processInfo.systemUptime
|
|
939
|
+
let sysIdle = lastSystemAudioUptime == 0 || (now - lastSystemAudioUptime) > 0.20
|
|
940
|
+
|
|
941
|
+
if sysIdle {
|
|
942
|
+
if !micPendingSamples.isEmpty {
|
|
943
|
+
micPendingSamples.removeAll(keepingCapacity: true)
|
|
944
|
+
}
|
|
945
|
+
if micRMS >= 0.022 {
|
|
946
|
+
appendSamples(micFloats)
|
|
947
|
+
}
|
|
948
|
+
} else if micRMS >= 0.022 {
|
|
949
|
+
micPendingSamples.append(contentsOf: micFloats)
|
|
950
|
+
if micPendingSamples.count > 16_000 {
|
|
951
|
+
micPendingSamples.removeFirst(micPendingSamples.count - 16_000)
|
|
952
|
+
}
|
|
953
|
+
} else {
|
|
954
|
+
micPendingSamples.removeAll(keepingCapacity: true)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if micRMS > 0.022 && (systemOnlyRMS < 0.012 || micRMS > systemOnlyRMS * 1.8) {
|
|
958
|
+
let id = localUserId.isEmpty ? "local" : localUserId
|
|
959
|
+
emitSpeakerActivity(id: id, name: resolvedLocalUserName(), audioTime: currentAudioSeconds(), source: "mic")
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private func appendSamples(_ floats: [Float]) {
|
|
964
|
+
guard !floats.isEmpty else { return }
|
|
965
|
+
if audioStartUptime == 0 {
|
|
966
|
+
audioStartUptime = ProcessInfo.processInfo.systemUptime
|
|
967
|
+
}
|
|
968
|
+
if pcmChunkBuffer.isEmpty {
|
|
969
|
+
pcmChunkStartSample = totalAudioSamples
|
|
970
|
+
}
|
|
971
|
+
pcmChunkBuffer.append(contentsOf: floats)
|
|
972
|
+
totalAudioSamples += floats.count
|
|
973
|
+
|
|
974
|
+
// Emit in ~100ms chunks (1600 samples @ 16 kHz)
|
|
975
|
+
if pcmChunkBuffer.count >= 1600 {
|
|
976
|
+
flushPCMChunk()
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private func flushPCMChunk() {
|
|
981
|
+
guard !pcmChunkBuffer.isEmpty else { return }
|
|
982
|
+
let count = pcmChunkBuffer.count
|
|
983
|
+
let t0 = Double(pcmChunkStartSample) / 16000.0
|
|
984
|
+
let t1 = Double(pcmChunkStartSample + count) / 16000.0
|
|
985
|
+
|
|
986
|
+
var int16Samples = [Int16](repeating: 0, count: count)
|
|
987
|
+
for i in 0..<count {
|
|
988
|
+
let clamped = max(-1.0, min(1.0, pcmChunkBuffer[i]))
|
|
989
|
+
int16Samples[i] = Int16(clamped * 32767.0)
|
|
990
|
+
}
|
|
991
|
+
let data = int16Samples.withUnsafeBufferPointer { Data(buffer: $0) }
|
|
992
|
+
let b64 = data.base64EncodedString()
|
|
993
|
+
let micRMS = MicActivity.currentRMS()
|
|
994
|
+
let micActive = micRMS > 0.022 && (systemOnlyRMS < 0.015 || micRMS > systemOnlyRMS * 1.8)
|
|
995
|
+
|
|
996
|
+
pcmChunkBuffer.removeAll(keepingCapacity: true)
|
|
997
|
+
pcmChunkStartSample = totalAudioSamples
|
|
998
|
+
|
|
999
|
+
emitEvent("audio", payload: AudioChunkPayload(
|
|
1000
|
+
pcmBase64: b64,
|
|
1001
|
+
t0: t0,
|
|
1002
|
+
t1: t1,
|
|
1003
|
+
sysRMS: systemOnlyRMS,
|
|
1004
|
+
micRMS: micRMS,
|
|
1005
|
+
micActive: micActive
|
|
1006
|
+
))
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private func identifyLocalUser(target: MeetingTarget, explicitLocalName: String?) {
|
|
1010
|
+
let extracted = explicitLocalName ?? extractLocalUserName(from: target.title)
|
|
1011
|
+
if !extracted.isEmpty {
|
|
1012
|
+
localUserName = extracted
|
|
1013
|
+
}
|
|
1014
|
+
refreshLocalUserMapping()
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
private func refreshLocalUserMapping() {
|
|
1018
|
+
if !localUserName.isEmpty {
|
|
1019
|
+
if let match = roster.first(where: {
|
|
1020
|
+
$0.name.localizedCaseInsensitiveCompare(localUserName) == .orderedSame ||
|
|
1021
|
+
$0.name.lowercased().hasPrefix(localUserName.lowercased() + " ")
|
|
1022
|
+
}) {
|
|
1023
|
+
localUserId = match.id
|
|
1024
|
+
localUserName = match.name
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if localUserId.isEmpty {
|
|
1028
|
+
localUserId = "local"
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private func resolvedLocalUserName() -> String {
|
|
1033
|
+
if let match = roster.first(where: { $0.id == localUserId }) {
|
|
1034
|
+
return match.name
|
|
1035
|
+
}
|
|
1036
|
+
return localUserName.isEmpty ? "You" : localUserName
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private func extractLocalUserName(from title: String) -> String {
|
|
1040
|
+
let cleaned = NativeMeetingScraper.shared.sanitizeMeetingTitle(title, fallbackCode: nil)
|
|
1041
|
+
let parts = cleaned.components(separatedBy: " - ").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
|
1042
|
+
guard parts.count >= 3, let candidate = parts.last else { return "" }
|
|
1043
|
+
let ignored: Set<String> = [
|
|
1044
|
+
"google chrome", "chrome", "brave", "brave browser", "microsoft edge", "edge",
|
|
1045
|
+
"arc", "vivaldi", "safari", "zoom", "zoom meeting", "microsoft teams", "teams",
|
|
1046
|
+
"microphone recording", "camera recording", "camera and microphone recording",
|
|
1047
|
+
"tab content shared", "audio playing"
|
|
1048
|
+
]
|
|
1049
|
+
if ignored.contains(candidate.lowercased()) { return "" }
|
|
1050
|
+
return candidate
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// MARK: - Main Entrypoint & Stdio Command Loop
|
|
1055
|
+
|
|
1056
|
+
private struct IncomingCommand: Decodable {
|
|
1057
|
+
let id: String
|
|
1058
|
+
let command: String
|
|
1059
|
+
let prompt: Bool?
|
|
1060
|
+
let target: MeetingTarget?
|
|
1061
|
+
let event: ChromeEvent?
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
@main
|
|
1065
|
+
struct AiraCaptureMain {
|
|
1066
|
+
static func main() {
|
|
1067
|
+
// Announce ready on stdout
|
|
1068
|
+
emitEvent("ready", payload: ["status": "ok"])
|
|
1069
|
+
|
|
1070
|
+
// Read newline-delimited JSON commands on background thread
|
|
1071
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
1072
|
+
while let line = readLine(strippingNewline: true) {
|
|
1073
|
+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1074
|
+
if trimmed.isEmpty { continue }
|
|
1075
|
+
guard let data = trimmed.data(using: .utf8),
|
|
1076
|
+
let cmd = try? JSONDecoder().decode(IncomingCommand.self, from: data) else {
|
|
1077
|
+
continue
|
|
1078
|
+
}
|
|
1079
|
+
Task {
|
|
1080
|
+
await handleCommand(cmd)
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// EOF on stdin -> clean shutdown
|
|
1084
|
+
Task {
|
|
1085
|
+
await CaptureSessionManager.shared.stop()
|
|
1086
|
+
await MeetingDetector.shared.stop()
|
|
1087
|
+
_exit(0)
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
RunLoop.main.run()
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
private static func handleCommand(_ cmd: IncomingCommand) async {
|
|
1095
|
+
switch cmd.command {
|
|
1096
|
+
case "checkPermissions":
|
|
1097
|
+
let perms = await EyesPermissionManager.checkAll(prompt: cmd.prompt ?? false)
|
|
1098
|
+
sendResponse(id: cmd.id, result: perms)
|
|
1099
|
+
|
|
1100
|
+
case "watchMeetings":
|
|
1101
|
+
await MeetingDetector.shared.start()
|
|
1102
|
+
sendResponse(id: cmd.id, result: ["watching": true])
|
|
1103
|
+
|
|
1104
|
+
case "stopWatchingMeetings":
|
|
1105
|
+
await MeetingDetector.shared.stop()
|
|
1106
|
+
sendResponse(id: cmd.id, result: ["watching": false])
|
|
1107
|
+
|
|
1108
|
+
case "startCapture":
|
|
1109
|
+
guard let target = cmd.target else {
|
|
1110
|
+
sendErrorResponse(id: cmd.id, message: "missing target")
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
do {
|
|
1114
|
+
try await CaptureSessionManager.shared.start(target: target)
|
|
1115
|
+
sendResponse(id: cmd.id, result: ["started": true])
|
|
1116
|
+
} catch {
|
|
1117
|
+
sendErrorResponse(id: cmd.id, message: error.localizedDescription)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
case "stopCapture":
|
|
1121
|
+
await CaptureSessionManager.shared.stop()
|
|
1122
|
+
sendResponse(id: cmd.id, result: ["stopped": true])
|
|
1123
|
+
|
|
1124
|
+
case "pushChromeEvent":
|
|
1125
|
+
guard let ev = cmd.event else {
|
|
1126
|
+
sendErrorResponse(id: cmd.id, message: "missing event")
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
await CaptureSessionManager.shared.handleChromeEvent(ev)
|
|
1130
|
+
sendResponse(id: cmd.id, result: ["ok": true])
|
|
1131
|
+
|
|
1132
|
+
case "shutdown":
|
|
1133
|
+
await CaptureSessionManager.shared.stop()
|
|
1134
|
+
await MeetingDetector.shared.stop()
|
|
1135
|
+
sendResponse(id: cmd.id, result: ["shutdown": true])
|
|
1136
|
+
_exit(0)
|
|
1137
|
+
|
|
1138
|
+
default:
|
|
1139
|
+
sendErrorResponse(id: cmd.id, message: "unknown command: \(cmd.command)")
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|