@hasna/recordings 0.1.9 → 0.1.11

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.
@@ -1,395 +0,0 @@
1
- import Cocoa
2
- import Carbon
3
-
4
- // ── RecordingsHelper ────────────────────────────────────────────────────────
5
- // Lightweight menu bar app that toggles speech recording with a global hotkey.
6
- // Press the hotkey to start recording, press again to stop + transcribe + paste.
7
- // Works like Wispr Flow — no UI chrome, just a menu bar icon and a hotkey.
8
-
9
- class AppDelegate: NSObject, NSApplicationDelegate {
10
- var statusItem: NSStatusItem!
11
- var isRecording = false
12
- var recordProcess: Process?
13
- var currentAudioPath: String?
14
- var hotkeyRef: EventHotKeyRef?
15
- var spaceDownTime: Date?
16
- var longPressTimer: Timer?
17
- let longPressDuration: TimeInterval = 1.0 // Hold space for 1 second to activate
18
-
19
- let recordingsDir: String = {
20
- let home = FileManager.default.homeDirectoryForCurrentUser.path
21
- let newDir = "\(home)/.hasna/recordings"
22
- let oldDir = "\(home)/.recordings"
23
- // Auto-migrate from old location
24
- if !FileManager.default.fileExists(atPath: newDir) && FileManager.default.fileExists(atPath: oldDir) {
25
- try? FileManager.default.createDirectory(atPath: "\(home)/.hasna", withIntermediateDirectories: true)
26
- try? FileManager.default.copyItem(atPath: oldDir, toPath: newDir)
27
- }
28
- return newDir
29
- }()
30
-
31
- let audioDir: String = {
32
- let home = FileManager.default.homeDirectoryForCurrentUser.path
33
- return "\(home)/.hasna/recordings/audio"
34
- }()
35
-
36
- let recordingsBin: String = {
37
- let home = FileManager.default.homeDirectoryForCurrentUser.path
38
- return "\(home)/.bun/bin/recordings"
39
- }()
40
-
41
- func applicationDidFinishLaunching(_ notification: Notification) {
42
- // Create audio dir
43
- try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
44
-
45
- // Menu bar icon
46
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
47
- updateIcon()
48
-
49
- // Menu
50
- let menu = NSMenu()
51
- menu.addItem(NSMenuItem(title: "Hold Space (1s) to Record", action: #selector(toggleRecording), keyEquivalent: ""))
52
- menu.addItem(NSMenuItem.separator())
53
- menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
54
- statusItem.menu = menu
55
-
56
- // Register global hotkey: F5
57
- registerHotkey()
58
-
59
- showNotification(title: "Recordings", body: "Ready — hold Space for 1s to record")
60
- }
61
-
62
- func updateIcon() {
63
- if let button = statusItem.button {
64
- button.title = isRecording ? "⏺" : "🎙"
65
- }
66
- }
67
-
68
- // ── Global Hotkey ───────────────────────────────────────────────────────
69
-
70
- /// Check if Accessibility permission is granted. If not, show a dialog and open System Settings.
71
- func checkAccessibilityPermission() -> Bool {
72
- // First check without prompting
73
- let trusted = AXIsProcessTrustedWithOptions(
74
- [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false] as CFDictionary
75
- )
76
- if trusted { return true }
77
-
78
- // Not trusted — show dialog explaining why we need it
79
- let alert = NSAlert()
80
- alert.messageText = "Accessibility Permission Required"
81
- alert.informativeText = "RecordingsHelper needs Accessibility access to detect the global hotkey (hold Space to record).\n\nClick 'Open Settings' to grant permission, then restart the app."
82
- alert.alertStyle = .warning
83
- alert.addButton(withTitle: "Open Settings")
84
- alert.addButton(withTitle: "Quit")
85
-
86
- let response = alert.runModal()
87
- if response == .alertFirstButtonReturn {
88
- // Prompt the system dialog AND open System Settings > Accessibility
89
- AXIsProcessTrustedWithOptions(
90
- [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
91
- )
92
- if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
93
- NSWorkspace.shared.open(url)
94
- }
95
- } else {
96
- NSApplication.shared.terminate(nil)
97
- }
98
- return false
99
- }
100
-
101
- func registerHotkey() {
102
- // Hold Space for 1+ second to start recording, release to stop.
103
- // Uses CGEventTap to monitor all keyboard events globally.
104
- // Normal space presses (< 1 second) pass through untouched.
105
-
106
- // Check Accessibility permission BEFORE attempting event tap
107
- if !checkAccessibilityPermission() {
108
- return
109
- }
110
-
111
- let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue)
112
-
113
- guard let tap = CGEvent.tapCreate(
114
- tap: .cgSessionEventTap,
115
- place: .headInsertEventTap,
116
- options: .defaultTap,
117
- eventsOfInterest: eventMask,
118
- callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
119
- let appDelegate = Unmanaged<AppDelegate>.fromOpaque(refcon!).takeUnretainedValue()
120
- let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
121
-
122
- // Space bar = keycode 49
123
- guard keyCode == 49 else {
124
- return Unmanaged.passRetained(event)
125
- }
126
-
127
- if type == .keyDown {
128
- // Ignore key repeat events (auto-repeat while held)
129
- let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat)
130
- if isRepeat != 0 {
131
- // Already tracking this press — suppress the repeat
132
- if appDelegate.spaceDownTime != nil {
133
- return nil // Swallow repeat while we're tracking
134
- }
135
- return Unmanaged.passRetained(event)
136
- }
137
-
138
- if appDelegate.spaceDownTime == nil {
139
- appDelegate.spaceDownTime = Date()
140
- // Start a timer — if space is still held after 1s, begin recording
141
- DispatchQueue.main.async {
142
- appDelegate.longPressTimer?.invalidate()
143
- appDelegate.longPressTimer = Timer.scheduledTimer(withTimeInterval: appDelegate.longPressDuration, repeats: false) { _ in
144
- if appDelegate.spaceDownTime != nil && !appDelegate.isRecording {
145
- appDelegate.startRecording()
146
- }
147
- }
148
- }
149
- }
150
- // Let the keyDown through for now (normal typing)
151
- return Unmanaged.passRetained(event)
152
-
153
- } else if type == .keyUp {
154
- let wasLongPress = appDelegate.isRecording
155
-
156
- if appDelegate.isRecording {
157
- // Space released after recording — stop and transcribe
158
- DispatchQueue.main.async {
159
- appDelegate.stopAndTranscribe()
160
- }
161
- }
162
-
163
- // Cancel timer
164
- DispatchQueue.main.async {
165
- appDelegate.longPressTimer?.invalidate()
166
- appDelegate.longPressTimer = nil
167
- }
168
- appDelegate.spaceDownTime = nil
169
-
170
- if wasLongPress {
171
- // Swallow the keyUp so it doesn't type a space
172
- return nil
173
- }
174
- return Unmanaged.passRetained(event)
175
- }
176
-
177
- return Unmanaged.passRetained(event)
178
- },
179
- userInfo: Unmanaged.passUnretained(self).toOpaque()
180
- ) else {
181
- showNotification(title: "Error", body: "Could not create event tap. Grant Accessibility permission in System Settings > Privacy > Accessibility")
182
- return
183
- }
184
-
185
- let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
186
- CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
187
- CGEvent.tapEnable(tap: tap, enable: true)
188
- }
189
-
190
- // ── Recording ────────────────────────────────────────────────────────────
191
-
192
- @objc func toggleRecording() {
193
- if isRecording { stopAndTranscribe() } else { startRecording() }
194
- }
195
-
196
- func startRecording() {
197
- let formatter = DateFormatter()
198
- formatter.dateFormat = "yyyyMMdd'T'HHmmss"
199
- let filename = "recording-\(formatter.string(from: Date())).wav"
200
- let filepath = "\(audioDir)/\(filename)"
201
- currentAudioPath = filepath
202
-
203
- // Run ffmpeg via bash — pipe stdin so we can send 'q' to stop gracefully
204
- let process = Process()
205
- let stdinPipe = Pipe()
206
- process.executableURL = URL(fileURLWithPath: "/bin/bash")
207
- process.arguments = ["-c", """
208
- export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
209
- if command -v ffmpeg &>/dev/null; then
210
- ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t 300 "\(filepath)" -y 2>/dev/null
211
- elif command -v rec &>/dev/null; then
212
- rec -r 16000 -c 1 -b 16 "\(filepath)" trim 0 300
213
- else
214
- exit 1
215
- fi
216
- """]
217
- process.standardInput = stdinPipe
218
- process.standardOutput = FileHandle.nullDevice
219
- process.standardError = FileHandle.nullDevice
220
-
221
- do {
222
- try process.run()
223
- recordProcess = process
224
- stdinPipeRef = stdinPipe
225
- isRecording = true
226
- updateIcon()
227
- showNotification(title: "Recording...", body: "Press F5 to stop")
228
- } catch {
229
- showNotification(title: "Error", body: "Could not start recording: \(error.localizedDescription)")
230
- }
231
- }
232
-
233
- var stdinPipeRef: Pipe?
234
-
235
- func stopAndTranscribe() {
236
- guard let process = recordProcess else { return }
237
-
238
- // Send 'q' to ffmpeg stdin for graceful stop (flushes WAV header)
239
- if let pipe = stdinPipeRef {
240
- pipe.fileHandleForWriting.write("q".data(using: .utf8)!)
241
- try? pipe.fileHandleForWriting.close()
242
- }
243
- // Wait for graceful exit, then force if needed
244
- Thread.sleep(forTimeInterval: 1.0)
245
- if process.isRunning {
246
- process.terminate()
247
- Thread.sleep(forTimeInterval: 0.3)
248
- }
249
- if process.isRunning {
250
- process.interrupt()
251
- }
252
- recordProcess = nil
253
- stdinPipeRef = nil
254
- isRecording = false
255
- updateIcon()
256
-
257
- guard let audioPath = currentAudioPath else { return }
258
- currentAudioPath = nil
259
-
260
- // Check if audio file exists and has content
261
- let fileManager = FileManager.default
262
- guard fileManager.fileExists(atPath: audioPath) else {
263
- showNotification(title: "Error", body: "Audio file not created — check mic permissions")
264
- return
265
- }
266
-
267
- let attrs = try? fileManager.attributesOfItem(atPath: audioPath)
268
- let size = attrs?[.size] as? Int ?? 0
269
- if size < 1000 {
270
- showNotification(title: "Error", body: "Audio too short (\(size) bytes) — try speaking longer")
271
- return
272
- }
273
-
274
- showNotification(title: "Transcribing...", body: "Processing your recording")
275
-
276
- // Transcribe in background
277
- DispatchQueue.global(qos: .userInitiated).async { [self] in
278
- let output = self.runCommand(self.recordingsBin, arguments: ["transcribe", audioPath, "--json"])
279
-
280
- // The CLI outputs "Transcribing...\n" then the text, then JSON on --json
281
- // Find the JSON object in the output
282
- var text = ""
283
-
284
- // Try to find JSON in the output
285
- if let jsonStart = output.range(of: "{"),
286
- let jsonEnd = output.range(of: "}", options: .backwards) {
287
- let jsonStr = String(output[jsonStart.lowerBound...jsonEnd.upperBound])
288
- if let data = jsonStr.data(using: .utf8),
289
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
290
- if let pt = json["processed_text"] as? String, !pt.isEmpty {
291
- text = pt
292
- } else if let rt = json["raw_text"] as? String, !rt.isEmpty {
293
- text = rt
294
- }
295
- }
296
- }
297
-
298
- // Fallback: grab everything after the last newline before JSON
299
- if text.isEmpty {
300
- let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty && !$0.contains("Transcribing") && !$0.contains("{") }
301
- for line in lines {
302
- let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
303
- if !trimmed.isEmpty && !trimmed.hasPrefix("Saved") {
304
- text = trimmed
305
- break
306
- }
307
- }
308
- }
309
-
310
- guard !text.isEmpty else {
311
- DispatchQueue.main.async {
312
- let dbg = output.prefix(200)
313
- self.showNotification(title: "Error", body: "Empty transcription. Output: \(dbg)")
314
- }
315
- return
316
- }
317
-
318
- DispatchQueue.main.async {
319
- // Copy to clipboard
320
- let pasteboard = NSPasteboard.general
321
- pasteboard.clearContents()
322
- pasteboard.setString(text, forType: .string)
323
-
324
- // Auto-paste into the frontmost app (like Wispr Flow)
325
- self.simulatePaste()
326
-
327
- self.showNotification(title: "Done", body: String(text.prefix(80)))
328
- }
329
- }
330
- }
331
-
332
- // ── Helpers ─────────────────────────────────────────────────────────────
333
-
334
- func simulatePaste() {
335
- // Simulate Cmd+V
336
- let source = CGEventSource(stateID: .hidSystemState)
337
-
338
- let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) // V key
339
- keyDown?.flags = .maskCommand
340
- keyDown?.post(tap: .cghidEventTap)
341
-
342
- let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
343
- keyUp?.flags = .maskCommand
344
- keyUp?.post(tap: .cghidEventTap)
345
- }
346
-
347
- func runCommand(_ command: String, arguments: [String]) -> String {
348
- let process = Process()
349
- let pipe = Pipe()
350
- let errPipe = Pipe()
351
- process.executableURL = URL(fileURLWithPath: "/bin/bash")
352
-
353
- let home = FileManager.default.homeDirectoryForCurrentUser.path
354
- let args = arguments.map { "\"\($0)\"" }.joined(separator: " ")
355
- process.arguments = ["-c", """
356
- export PATH="\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
357
- "\(command)" \(args)
358
- """]
359
- process.standardOutput = pipe
360
- process.standardError = errPipe
361
-
362
- do {
363
- try process.run()
364
- process.waitUntilExit()
365
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
366
- return String(data: data, encoding: .utf8) ?? ""
367
- } catch {
368
- return "ERROR: \(error.localizedDescription)"
369
- }
370
- }
371
-
372
- func showNotification(title: String, body: String) {
373
- let process = Process()
374
- process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
375
- process.arguments = ["-e", "display notification \"\(body)\" with title \"\(title)\""]
376
- process.standardOutput = FileHandle.nullDevice
377
- process.standardError = FileHandle.nullDevice
378
- try? process.run()
379
- }
380
-
381
- @objc func quit() {
382
- if let process = recordProcess {
383
- process.interrupt()
384
- }
385
- NSApplication.shared.terminate(nil)
386
- }
387
- }
388
-
389
- // ── Main ────────────────────────────────────────────────────────────────────
390
-
391
- let app = NSApplication.shared
392
- app.setActivationPolicy(.accessory) // Menu bar only, no dock icon
393
- let delegate = AppDelegate()
394
- app.delegate = delegate
395
- app.run()