@hasna/recordings 0.1.11 → 0.1.13

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.
Files changed (69) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +395 -37
  3. package/dist/db/database.d.ts.map +1 -1
  4. package/dist/db/recordings.d.ts.map +1 -1
  5. package/dist/index.js +78 -11
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts.map +1 -1
  8. package/dist/lib/recorder.d.ts.map +1 -1
  9. package/dist/lib/transcriber.d.ts +8 -2
  10. package/dist/lib/transcriber.d.ts.map +1 -1
  11. package/dist/mcp/index.js +147 -30
  12. package/dist/types/index.d.ts +3 -0
  13. package/dist/types/index.d.ts.map +1 -1
  14. package/dist/version.d.ts +2 -0
  15. package/dist/version.d.ts.map +1 -0
  16. package/package.json +20 -3
  17. package/scripts/install_macos_app.sh +76 -0
  18. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +5 -1
  19. package/src/native/Recordings/Package.resolved +21 -3
  20. package/src/native/Recordings/Package.swift +16 -5
  21. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  22. package/src/native/Recordings/{Recordings → RecordingsLib}/MenuBarPopover.swift +55 -11
  23. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +36 -0
  24. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  25. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  26. package/src/native/Recordings/{Recordings → RecordingsLib}/ProjectStore.swift +11 -11
  27. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  28. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +835 -0
  29. package/src/native/Recordings/{Recordings → RecordingsLib}/SettingsView.swift +19 -5
  30. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  31. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  32. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +23 -0
  33. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  34. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  35. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  36. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  37. package/src/native/Recordings/build.sh +6 -6
  38. package/.takumi/settings.local.json +0 -7
  39. package/bun.lock +0 -250
  40. package/bunfig.toml +0 -2
  41. package/src/__tests__/agents.test.ts +0 -136
  42. package/src/__tests__/config.test.ts +0 -252
  43. package/src/__tests__/database.test.ts +0 -167
  44. package/src/__tests__/enhancer.test.ts +0 -639
  45. package/src/__tests__/preload.ts +0 -4
  46. package/src/__tests__/projects.test.ts +0 -109
  47. package/src/__tests__/recorder.test.ts +0 -278
  48. package/src/__tests__/recordings.test.ts +0 -353
  49. package/src/__tests__/transcriber.test.ts +0 -322
  50. package/src/__tests__/types.test.ts +0 -75
  51. package/src/cli/index.ts +0 -988
  52. package/src/db/agents.ts +0 -104
  53. package/src/db/database.ts +0 -163
  54. package/src/db/pg-migrations.ts +0 -82
  55. package/src/db/projects.ts +0 -71
  56. package/src/db/recordings.ts +0 -225
  57. package/src/index.ts +0 -81
  58. package/src/lib/config.ts +0 -223
  59. package/src/lib/enhancer.ts +0 -173
  60. package/src/lib/recorder.ts +0 -198
  61. package/src/lib/transcriber.ts +0 -105
  62. package/src/mcp/index.ts +0 -464
  63. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -455
  64. package/src/native/Recordings/test_fn.swift +0 -79
  65. package/src/native/Recordings/test_fn2.swift +0 -33
  66. package/src/types/index.ts +0 -144
  67. package/tsconfig.json +0 -21
  68. /package/src/native/Recordings/{Recordings → RecordingsLib}/FnKeyMonitor.swift +0 -0
  69. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
@@ -0,0 +1,835 @@
1
+ import AVFoundation
2
+ @preconcurrency import ApplicationServices
3
+ import SwiftUI
4
+ @preconcurrency import KeyboardShortcuts
5
+
6
+ // MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
7
+
8
+ extension KeyboardShortcuts.Name {
9
+ @MainActor static let toggleRecording = Self("toggleRecording", default: .init(.f5))
10
+ }
11
+
12
+ // MARK: - Recording Mode
13
+
14
+ public enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
15
+ case pushToTalk = "Push to Talk"
16
+ case dictation = "Dictation"
17
+ case command = "Command"
18
+
19
+ public var id: String { rawValue }
20
+ public var icon: String {
21
+ switch self {
22
+ case .pushToTalk: return "hand.tap.fill"
23
+ case .dictation: return "text.bubble.fill"
24
+ case .command: return "wand.and.stars"
25
+ }
26
+ }
27
+ public var hint: String {
28
+ switch self {
29
+ case .pushToTalk: return "Hold F5 or your chosen shortcut to record, then release to paste"
30
+ case .dictation: return "Hold F5 or your chosen shortcut to dictate, then release to paste"
31
+ case .command: return "Select text, then hold F5 or your chosen shortcut and release to rewrite"
32
+ }
33
+ }
34
+ }
35
+
36
+ // MARK: - Transcription Result
37
+
38
+ public struct TranscriptionResult: Sendable {
39
+ let rawText: String
40
+ let processedText: String?
41
+ let timestamp: Date
42
+ let projectId: String?
43
+ let projectName: String?
44
+ var displayText: String { processedText ?? rawText }
45
+ }
46
+
47
+ public enum RecordingTrigger {
48
+ case manual
49
+ case fnKey
50
+ case keyboardShortcut
51
+ }
52
+
53
+ private actor PCMStreamState {
54
+ private var recordedPCM = Data()
55
+ private var pendingChunk = Data()
56
+
57
+ func append(_ data: Data, chunkSize: Int) -> [Data] {
58
+ guard !data.isEmpty else { return [] }
59
+
60
+ recordedPCM.append(data)
61
+ pendingChunk.append(data)
62
+
63
+ var chunks: [Data] = []
64
+ while pendingChunk.count >= chunkSize {
65
+ chunks.append(pendingChunk.prefixData(count: chunkSize))
66
+ pendingChunk.removeFirst(chunkSize)
67
+ }
68
+ return chunks
69
+ }
70
+
71
+ func flushPendingChunk() -> Data? {
72
+ guard !pendingChunk.isEmpty else { return nil }
73
+ let chunk = pendingChunk
74
+ pendingChunk.removeAll(keepingCapacity: true)
75
+ return chunk
76
+ }
77
+
78
+ func capturedPCM() -> Data {
79
+ recordedPCM
80
+ }
81
+ }
82
+
83
+ private extension Data {
84
+ func prefixData(count: Int) -> Data {
85
+ Data(prefix(count))
86
+ }
87
+ }
88
+
89
+ // MARK: - Recording Engine
90
+
91
+ @MainActor
92
+ public final class RecordingEngine: ObservableObject {
93
+ @Published public var isRecording = false
94
+ @Published public var mode: RecordingMode = .pushToTalk {
95
+ didSet {
96
+ UserDefaults.standard.set(mode.rawValue, forKey: "recordingMode")
97
+ updateStatus()
98
+ }
99
+ }
100
+ @Published public var useFnKey: Bool = false {
101
+ didSet {
102
+ UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
103
+ updateFnMonitor()
104
+ updateStatus()
105
+ }
106
+ }
107
+ @Published public var isWhisperMode = false
108
+ @Published public var recentTranscriptions: [TranscriptionResult] = []
109
+ @Published public var statusMessage = "Starting..."
110
+ @Published public var isTranscribing = false
111
+ @Published public var recordingDuration: TimeInterval = 0
112
+ @Published public var liveTranscriptionText = ""
113
+
114
+ private var nativeRecorder: NativePCMRecorder?
115
+ private var recordingTimer: Timer?
116
+ private var activeTrigger: RecordingTrigger?
117
+ private var keyboardShortcutIsDown = false
118
+ private var targetAppBundleIdentifier: String?
119
+ private var targetAppPid: pid_t?
120
+ public var projectStore: ProjectStore?
121
+ public var voiceShortcuts: VoiceShortcuts?
122
+
123
+ // Real-time streaming
124
+ private var realtimeClient: RealtimeTranscriptionClient?
125
+ private var streamingTask: Task<Void, Never>?
126
+ private var pcmStreamState: PCMStreamState?
127
+ private var streamingText = ""
128
+ private var recordedPCM = Data()
129
+ private var activeAudioPath: String?
130
+
131
+ // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
132
+ private let fnMonitor = FnKeyMonitor()
133
+
134
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
135
+ private var audioDir: String { "\(home)/.hasna/recordings/audio" }
136
+
137
+ // MARK: - OpenAI API Key
138
+
139
+ private var openAIAPIKey: String {
140
+ OpenAIAPIKeyStore.load(homePath: home)
141
+ }
142
+
143
+ public init() {
144
+ try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
145
+ log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
146
+
147
+ // Load preferences
148
+ if let savedMode = UserDefaults.standard.string(forKey: "recordingMode"),
149
+ let parsedMode = RecordingMode(rawValue: savedMode) {
150
+ mode = parsedMode
151
+ }
152
+ useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
153
+ if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
154
+ KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
155
+ }
156
+
157
+ // Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
158
+ fnMonitor.onFnKeyDown = { [weak self] in
159
+ Task { @MainActor [weak self] in
160
+ guard let self, self.useFnKey, !self.isRecording else { return }
161
+ self.startRecording(trigger: .fnKey)
162
+ }
163
+ }
164
+ fnMonitor.onFnKeyUp = { [weak self] in
165
+ Task { @MainActor [weak self] in
166
+ guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
167
+ self.stopAndTranscribe()
168
+ }
169
+ }
170
+ updateFnMonitor()
171
+
172
+ KeyboardShortcuts.onKeyDown(for: .toggleRecording) { [weak self] in
173
+ Task { @MainActor [weak self] in
174
+ guard let self, !self.keyboardShortcutIsDown else { return }
175
+ self.keyboardShortcutIsDown = true
176
+ guard !self.isRecording else { return }
177
+ self.startRecording(trigger: .keyboardShortcut)
178
+ }
179
+ }
180
+ KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
181
+ Task { @MainActor [weak self] in
182
+ guard let self, self.keyboardShortcutIsDown else { return }
183
+ self.keyboardShortcutIsDown = false
184
+ guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
185
+ self.stopAndTranscribe()
186
+ }
187
+ }
188
+
189
+ updateStatus()
190
+ }
191
+
192
+ public var microphonePermissionLabel: String {
193
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
194
+ case .authorized:
195
+ return "Microphone allowed"
196
+ case .notDetermined:
197
+ return "Microphone not requested"
198
+ case .denied:
199
+ return "Microphone denied"
200
+ case .restricted:
201
+ return "Microphone restricted"
202
+ @unknown default:
203
+ return "Microphone unknown"
204
+ }
205
+ }
206
+
207
+ public var accessibilityPermissionLabel: String {
208
+ AXIsProcessTrusted() ? "Accessibility allowed" : "Accessibility needed"
209
+ }
210
+
211
+ public func requestMicrophonePermission() {
212
+ log("requestMicrophonePermission status=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue)")
213
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
214
+ Task { @MainActor [weak self] in
215
+ guard let self else { return }
216
+ self.log("requestMicrophonePermission result granted=\(granted)")
217
+ self.statusMessage = granted
218
+ ? "Microphone allowed"
219
+ : "Enable Microphone permission for Recordings in System Settings"
220
+ self.objectWillChange.send()
221
+ }
222
+ }
223
+ }
224
+
225
+ public func requestAccessibilityPermission() {
226
+ let trusted = ensureAccessibilityPermission(prompt: true)
227
+ log("requestAccessibilityPermission trusted=\(trusted)")
228
+ statusMessage = trusted
229
+ ? "Accessibility allowed"
230
+ : "Enable Accessibility permission for Recordings to paste"
231
+ objectWillChange.send()
232
+ }
233
+
234
+ public func openMicrophoneSettings() {
235
+ openPrivacySettings("Privacy_Microphone")
236
+ }
237
+
238
+ public func openAccessibilitySettings() {
239
+ openPrivacySettings("Privacy_Accessibility")
240
+ }
241
+
242
+ private func openPrivacySettings(_ pane: String) {
243
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
244
+ NSWorkspace.shared.open(url)
245
+ }
246
+ }
247
+
248
+ private func updateFnMonitor() {
249
+ if useFnKey {
250
+ let ok = fnMonitor.start()
251
+ log("fn monitor start ok=\(ok)")
252
+ if !ok {
253
+ statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
254
+ }
255
+ } else {
256
+ fnMonitor.stop()
257
+ }
258
+ }
259
+
260
+ public func updateStatus() {
261
+ if isRecording || isTranscribing { return }
262
+ statusMessage = "Ready"
263
+ }
264
+
265
+ // MARK: - Toggle
266
+
267
+ public func toggleRecording() {
268
+ if isRecording { stopAndTranscribe() } else { startRecording(trigger: .manual) }
269
+ }
270
+
271
+ // MARK: - Start Recording (Streaming)
272
+
273
+ public func startRecording(trigger: RecordingTrigger = .manual) {
274
+ guard !isRecording else { return }
275
+ log("startRecording trigger=\(trigger) microphoneStatus=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue) accessibility=\(AXIsProcessTrusted())")
276
+ activeTrigger = trigger
277
+ keyboardShortcutIsDown = trigger == .keyboardShortcut
278
+
279
+ let myPID = ProcessInfo.processInfo.processIdentifier
280
+ let frontmostApp = NSWorkspace.shared.frontmostApplication
281
+ let isOwnApp = frontmostApp?.processIdentifier == myPID
282
+ targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
283
+ targetAppPid = isOwnApp ? nil : frontmostApp?.processIdentifier
284
+
285
+ if let store = projectStore {
286
+ let windowTitle = Self.focusedWindowTitle(pid: frontmostApp?.processIdentifier)
287
+ let projects = store.settings.projects
288
+ let detected = ProjectStore.matchProject(windowTitle: windowTitle, bundleId: targetAppBundleIdentifier, projects: projects)
289
+ if let detected {
290
+ store.setActive(detected.id)
291
+ }
292
+ }
293
+
294
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
295
+ case .authorized:
296
+ startNativeRecording()
297
+ case .notDetermined:
298
+ statusMessage = "Allow microphone access to record"
299
+ log("requesting microphone access before recording")
300
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
301
+ Task { @MainActor [weak self] in
302
+ guard let self else { return }
303
+ self.log("microphone access response granted=\(granted)")
304
+ if granted {
305
+ self.startNativeRecording()
306
+ } else {
307
+ self.resetRecordingIntent()
308
+ self.statusMessage = "Microphone permission denied"
309
+ }
310
+ }
311
+ }
312
+ case .denied, .restricted:
313
+ resetRecordingIntent()
314
+ log("microphone permission blocked status=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue)")
315
+ statusMessage = "Enable Microphone permission for Recordings in System Settings"
316
+ @unknown default:
317
+ resetRecordingIntent()
318
+ statusMessage = "Microphone permission unavailable"
319
+ }
320
+ }
321
+
322
+ private func startNativeRecording() {
323
+ let streamState = PCMStreamState()
324
+ pcmStreamState = streamState
325
+
326
+ let apiKey = openAIAPIKey
327
+ log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
328
+ if !apiKey.isEmpty {
329
+ startRealtimeStreaming(apiKey: apiKey)
330
+ }
331
+
332
+ let client = realtimeClient
333
+ let homePath = home
334
+ let firstChunkLogged = LockedFlag()
335
+ let recorder = NativePCMRecorder { [weak client] data in
336
+ if firstChunkLogged.take() {
337
+ NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
338
+ }
339
+ Task {
340
+ let chunks = await streamState.append(data, chunkSize: 4_800)
341
+ for chunk in chunks {
342
+ await client?.sendAudio(chunk)
343
+ }
344
+ }
345
+ }
346
+
347
+ do {
348
+ try recorder.start()
349
+ log("native recorder started")
350
+ nativeRecorder = recorder
351
+ isRecording = true
352
+ recordingDuration = 0
353
+ streamingText = ""
354
+ liveTranscriptionText = ""
355
+ recordedPCM.removeAll(keepingCapacity: true)
356
+ activeAudioPath = "\(audioDir)/recording-\(Self.timestampForFilename()).wav"
357
+ let trigger = activeTrigger ?? .manual
358
+ statusMessage = switch (mode, trigger) {
359
+ case (.command, _): "Speak your instruction..."
360
+ case (_, .manual): "Recording — click Stop when finished"
361
+ case (_, .fnKey), (_, .keyboardShortcut): "Recording — release to stop"
362
+ }
363
+
364
+ recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
365
+ Task { @MainActor [weak self] in
366
+ self?.recordingDuration += 0.1
367
+ }
368
+ }
369
+ } catch {
370
+ log("native recorder failed error=\(error.localizedDescription)")
371
+ realtimeClient?.stop()
372
+ realtimeClient = nil
373
+ streamingTask?.cancel()
374
+ streamingTask = nil
375
+ pcmStreamState = nil
376
+ resetRecordingIntent()
377
+ statusMessage = "Failed: \(error.localizedDescription)"
378
+ }
379
+ }
380
+
381
+ // MARK: - Real-time Streaming
382
+
383
+ private func startRealtimeStreaming(apiKey: String) {
384
+ let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
385
+ let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
386
+ realtimeClient = client
387
+ log("realtime streaming task starting")
388
+
389
+ streamingTask = Task {
390
+ await client.startStreaming(systemPrompt: systemPrompt)
391
+ self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
392
+
393
+ // Receive deltas
394
+ while client.isStreaming {
395
+ try? await Task.sleep(for: .milliseconds(100))
396
+ let text = client.accumulatedText
397
+ if text != streamingText {
398
+ await MainActor.run {
399
+ self.streamingText = text
400
+ self.liveTranscriptionText = text
401
+ }
402
+ }
403
+ }
404
+
405
+ if let message = client.error, !message.isEmpty {
406
+ await MainActor.run {
407
+ self.log("realtime unavailable message=\(message)")
408
+ self.statusMessage = "Realtime unavailable — will transcribe after recording"
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ // MARK: - Stop & Transcribe
415
+
416
+ public func stopAndTranscribe() {
417
+ guard isRecording else { return }
418
+ log("stopAndTranscribe")
419
+
420
+ recordingTimer?.invalidate()
421
+ recordingTimer = nil
422
+
423
+ let recorder = nativeRecorder
424
+ nativeRecorder = nil
425
+ recorder?.stop()
426
+
427
+ isRecording = false
428
+ isTranscribing = true
429
+
430
+ let curMode = mode
431
+ let targetAppBundleIdentifier = targetAppBundleIdentifier
432
+ let activeProjectId = projectStore?.settings.activeProjectId
433
+ let activeProjectName = projectStore?.activeProject?.name
434
+ let audioPath = activeAudioPath
435
+ let pcmStreamState = pcmStreamState
436
+ let client = realtimeClient
437
+ resetRecordingIntent()
438
+ self.pcmStreamState = nil
439
+
440
+ Task {
441
+ if let pcmStreamState {
442
+ if let finalChunk = await pcmStreamState.flushPendingChunk() {
443
+ client?.sendAudio(finalChunk)
444
+ }
445
+ self.recordedPCM = await pcmStreamState.capturedPCM()
446
+ }
447
+ self.log("captured pcm bytes=\(self.recordedPCM.count)")
448
+
449
+ let streamingResult = await client?.finish() ?? ""
450
+
451
+ self.realtimeClient = nil
452
+ self.streamingTask?.cancel()
453
+ self.streamingTask = nil
454
+
455
+ let text = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
456
+
457
+ self.liveTranscriptionText = ""
458
+
459
+ if let text {
460
+ self.log("finish using realtime text chars=\(text.count)")
461
+ self.isTranscribing = false
462
+ self.finishWithText(
463
+ text,
464
+ curMode: curMode,
465
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
466
+ activeProjectId: activeProjectId,
467
+ activeProjectName: activeProjectName
468
+ )
469
+ } else if let audioPath, self.writeCapturedWAV(to: audioPath) {
470
+ self.log("falling back to CLI transcription audioPath=\(audioPath)")
471
+ self.fallbackTranscribe(
472
+ audioPath: audioPath,
473
+ curMode: curMode,
474
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
475
+ activeProjectId: activeProjectId,
476
+ activeProjectName: activeProjectName
477
+ )
478
+ } else {
479
+ self.log("no audio captured")
480
+ self.finish("No audio captured")
481
+ }
482
+
483
+ self.activeAudioPath = nil
484
+ self.recordedPCM.removeAll(keepingCapacity: true)
485
+ }
486
+ }
487
+
488
+ private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
489
+ log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
490
+ if curMode == .command {
491
+ runCommandMode(instruction: text)
492
+ return
493
+ }
494
+
495
+ let shortcutText = voiceShortcuts?.match(text)
496
+ let output = shortcutText ?? text
497
+ pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier)
498
+ recentTranscriptions.insert(
499
+ TranscriptionResult(
500
+ rawText: text,
501
+ processedText: shortcutText,
502
+ timestamp: Date(),
503
+ projectId: activeProjectId,
504
+ projectName: activeProjectName
505
+ ),
506
+ at: 0
507
+ )
508
+ if recentTranscriptions.count > 20 { recentTranscriptions.removeLast() }
509
+ }
510
+
511
+ private func writeCapturedWAV(to path: String) -> Bool {
512
+ guard !recordedPCM.isEmpty else { return false }
513
+ do {
514
+ try Self.writeWAV(
515
+ pcmData: recordedPCM,
516
+ sampleRate: 24_000,
517
+ channelCount: 1,
518
+ bitsPerSample: 16,
519
+ to: URL(fileURLWithPath: path)
520
+ )
521
+ log("wrote wav path=\(path) pcmBytes=\(recordedPCM.count)")
522
+ return true
523
+ } catch {
524
+ log("failed to save wav error=\(error.localizedDescription)")
525
+ statusMessage = "Failed to save audio"
526
+ return false
527
+ }
528
+ }
529
+
530
+ private static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
531
+ let byteRate = sampleRate * UInt32(channelCount) * UInt32(bitsPerSample / 8)
532
+ let blockAlign = channelCount * (bitsPerSample / 8)
533
+ let dataSize = UInt32(pcmData.count)
534
+ let fileSize = UInt32(36) + dataSize
535
+
536
+ var wav = Data()
537
+ func appendASCII(_ string: String) {
538
+ wav.append(contentsOf: string.utf8)
539
+ }
540
+ func appendUInt16LE(_ value: UInt16) {
541
+ wav.append(UInt8(value & 0xff))
542
+ wav.append(UInt8((value >> 8) & 0xff))
543
+ }
544
+ func appendUInt32LE(_ value: UInt32) {
545
+ wav.append(UInt8(value & 0xff))
546
+ wav.append(UInt8((value >> 8) & 0xff))
547
+ wav.append(UInt8((value >> 16) & 0xff))
548
+ wav.append(UInt8((value >> 24) & 0xff))
549
+ }
550
+
551
+ appendASCII("RIFF")
552
+ appendUInt32LE(fileSize)
553
+ appendASCII("WAVE")
554
+ appendASCII("fmt ")
555
+ appendUInt32LE(16)
556
+ appendUInt16LE(1)
557
+ appendUInt16LE(channelCount)
558
+ appendUInt32LE(sampleRate)
559
+ appendUInt32LE(byteRate)
560
+ appendUInt16LE(blockAlign)
561
+ appendUInt16LE(bitsPerSample)
562
+ appendASCII("data")
563
+ appendUInt32LE(dataSize)
564
+ wav.append(pcmData)
565
+
566
+ let dir = url.deletingLastPathComponent()
567
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
568
+ try wav.write(to: url, options: .atomic)
569
+ }
570
+
571
+ private static func timestampForFilename() -> String {
572
+ let formatter = DateFormatter()
573
+ formatter.dateFormat = "yyyyMMdd-HHmmss-SSS"
574
+ return formatter.string(from: Date())
575
+ }
576
+
577
+ // MARK: - Fallback Transcription
578
+
579
+ private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
580
+ let homePath = home
581
+
582
+ isTranscribing = true
583
+ statusMessage = "Transcribing..."
584
+
585
+ Task.detached {
586
+ let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
587
+ if let error = CLIRunner.parseError(output) {
588
+ await MainActor.run {
589
+ self.log("fallback transcription failed error=\(error)")
590
+ self.finish(error)
591
+ }
592
+ return
593
+ }
594
+
595
+ let text = CLIRunner.parseJSON(output)
596
+ guard let text, !text.isEmpty else {
597
+ await MainActor.run {
598
+ self.log("fallback transcription empty output=\(output.prefix(160))")
599
+ self.finish("Empty transcription")
600
+ }
601
+ return
602
+ }
603
+
604
+ await MainActor.run {
605
+ self.log("fallback transcription succeeded chars=\(text.count)")
606
+ self.isTranscribing = false
607
+ self.finishWithText(
608
+ text,
609
+ curMode: curMode,
610
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
611
+ activeProjectId: activeProjectId,
612
+ activeProjectName: activeProjectName
613
+ )
614
+ }
615
+ }
616
+ }
617
+
618
+ private func mostRecentAudioFile() -> String? {
619
+ let files = (try? FileManager.default.contentsOfDirectory(atPath: audioDir)) ?? []
620
+ let wavFiles = files.filter { $0.hasSuffix(".wav") }.sorted().reversed()
621
+ return wavFiles.first.map { "\(audioDir)/\($0)" }
622
+ }
623
+
624
+ private func finish(_ msg: String) {
625
+ log("finish status=\(msg)")
626
+ isTranscribing = false
627
+ liveTranscriptionText = ""
628
+ statusMessage = msg
629
+ }
630
+
631
+ private func resetRecordingIntent() {
632
+ activeTrigger = nil
633
+ keyboardShortcutIsDown = false
634
+ targetAppBundleIdentifier = nil
635
+ targetAppPid = nil
636
+ }
637
+
638
+ // MARK: - Command Mode
639
+
640
+ private func runCommandMode(instruction: String) {
641
+ guard ensureAccessibilityPermission(prompt: true) else {
642
+ log("command mode blocked by accessibility permission")
643
+ statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
644
+ return
645
+ }
646
+ postKey(0x08, flags: .maskCommand) // Cmd+C
647
+ let homePath = home
648
+
649
+ Task {
650
+ try? await Task.sleep(for: .milliseconds(250))
651
+ let selected = NSPasteboard.general.string(forType: .string) ?? ""
652
+ guard !selected.isEmpty else {
653
+ statusMessage = "No text selected"
654
+ return
655
+ }
656
+ statusMessage = "Rewriting..."
657
+ isTranscribing = true
658
+
659
+ Task.detached {
660
+ let result = CLIRunner.run(["rewrite", selected, "--instruction", instruction], home: homePath)
661
+ await MainActor.run {
662
+ self.isTranscribing = false
663
+ self.liveTranscriptionText = ""
664
+ if CLIRunner.parseError(result) == nil, !result.isEmpty {
665
+ self.pasteIntoFrontApp(result)
666
+ self.statusMessage = "Rewritten"
667
+ } else {
668
+ self.statusMessage = CLIRunner.parseError(result) ?? "Rewrite failed"
669
+ }
670
+ }
671
+ }
672
+ }
673
+ }
674
+
675
+ private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
676
+ let src = CGEventSource(stateID: .hidSystemState)
677
+ guard let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true),
678
+ let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) else { return }
679
+ down.flags = flags
680
+ up.flags = flags
681
+ down.post(tap: .cgSessionEventTap)
682
+ up.post(tap: .cgSessionEventTap)
683
+ }
684
+
685
+ // MARK: - Window Title (Accessibility API)
686
+
687
+ private static func focusedWindowTitle(pid: pid_t?) -> String? {
688
+ guard let pid else { return nil }
689
+ let app = AXUIElementCreateApplication(pid)
690
+ var windowRef: CFTypeRef?
691
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
692
+ let window = windowRef else { return nil }
693
+ var titleRef: CFTypeRef?
694
+ guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleRef) == .success,
695
+ let title = titleRef as? String else { return nil }
696
+ return title
697
+ }
698
+
699
+ // MARK: - Paste
700
+
701
+ func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
702
+ log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") accessibility=\(AXIsProcessTrusted())")
703
+ let pb = NSPasteboard.general
704
+ pb.clearContents()
705
+ pb.setString(text, forType: .string)
706
+
707
+ guard ensureAccessibilityPermission(prompt: true) else {
708
+ log("paste blocked by accessibility permission; copied to clipboard")
709
+ self.statusMessage = "Copied — enable Accessibility permission for Recordings to paste"
710
+ return
711
+ }
712
+
713
+ let myPID = ProcessInfo.processInfo.processIdentifier
714
+ let runningApps = NSWorkspace.shared.runningApplications
715
+ let targetApp = runningApps.first(where: {
716
+ guard $0.processIdentifier != myPID, $0.activationPolicy == .regular else { return false }
717
+ guard let bundleIdentifier = targetAppBundleIdentifier else { return false }
718
+ return $0.bundleIdentifier == bundleIdentifier
719
+ }) ?? runningApps.first(where: {
720
+ $0.activationPolicy == .regular && $0.processIdentifier != myPID
721
+ })
722
+
723
+ guard let app = targetApp else {
724
+ log("paste target app not found")
725
+ self.statusMessage = "No target app found"
726
+ return
727
+ }
728
+
729
+ // Activate target app and wait for focus to stabilize
730
+ app.activate()
731
+
732
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
733
+ let src = CGEventSource(stateID: .hidSystemState)
734
+ if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
735
+ let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
736
+ down.flags = .maskCommand
737
+ up.flags = .maskCommand
738
+ down.post(tap: .cgSessionEventTap)
739
+ up.post(tap: .cgSessionEventTap)
740
+ }
741
+ self.log("paste event posted")
742
+ self.statusMessage = "Pasted (\(text.count) chars)"
743
+ }
744
+ }
745
+
746
+ private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
747
+ if !prompt {
748
+ return AXIsProcessTrusted()
749
+ }
750
+ return AXIsProcessTrustedWithOptions(
751
+ ["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
752
+ )
753
+ }
754
+
755
+ private func log(_ message: String) {
756
+ NativeAppLog.write(message, homePath: home)
757
+ }
758
+ }
759
+
760
+ private final class LockedFlag: @unchecked Sendable {
761
+ private let lock = NSLock()
762
+ private var value = true
763
+
764
+ func take() -> Bool {
765
+ lock.lock()
766
+ defer { lock.unlock() }
767
+ guard value else { return false }
768
+ value = false
769
+ return true
770
+ }
771
+ }
772
+
773
+ // MARK: - CLI Runner
774
+
775
+ enum CLIRunner: Sendable {
776
+ static func run(_ args: [String], home: String) -> String {
777
+ let bin = "\(home)/.bun/bin/recordings"
778
+ let proc = Process()
779
+ let outPipe = Pipe()
780
+ let errPipe = Pipe()
781
+ if FileManager.default.fileExists(atPath: bin) {
782
+ proc.executableURL = URL(fileURLWithPath: bin)
783
+ proc.arguments = args
784
+ } else {
785
+ proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
786
+ proc.arguments = ["recordings"] + args
787
+ }
788
+ proc.environment = ProcessInfo.processInfo.environment.merging([
789
+ "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
790
+ ]) { _, new in new }
791
+ proc.standardOutput = outPipe
792
+ proc.standardError = errPipe
793
+ do {
794
+ try proc.run()
795
+ proc.waitUntilExit()
796
+ let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
797
+ let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
798
+ if proc.terminationStatus != 0 {
799
+ let details = stderr.isEmpty ? stdout : stderr
800
+ return "ERROR: \(details.trimmingCharacters(in: .whitespacesAndNewlines))"
801
+ }
802
+ return stdout.isEmpty ? stderr : stdout
803
+ } catch {
804
+ return "ERROR: \(error.localizedDescription)"
805
+ }
806
+ }
807
+
808
+ static func parseError(_ output: String) -> String? {
809
+ let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
810
+ guard trimmed.hasPrefix("ERROR:") else { return nil }
811
+ let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
812
+ if message.contains("OpenAI API key not configured") {
813
+ return "OpenAI API key not configured on this Mac"
814
+ }
815
+ if message.isEmpty {
816
+ return "Transcription failed"
817
+ }
818
+ return String(message.prefix(120))
819
+ }
820
+
821
+ static func parseJSON(_ output: String) -> String? {
822
+ if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards),
823
+ s.lowerBound < e.upperBound {
824
+ let json = String(output[s.lowerBound..<e.upperBound])
825
+ if let data = json.data(using: .utf8),
826
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
827
+ if let t = obj["processed_text"] as? String, !t.isEmpty { return t }
828
+ if let t = obj["raw_text"] as? String, !t.isEmpty { return t }
829
+ }
830
+ }
831
+ return output.components(separatedBy: "\n")
832
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
833
+ .first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") && !$0.hasPrefix("ERROR:") }
834
+ }
835
+ }