@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.
- package/.takumi/settings.local.json +7 -0
- package/README.md +52 -0
- package/bun.lock +2 -2
- package/dist/cli/index.js +3866 -3279
- package/dist/index.js +3848 -3174
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +2 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/mcp/index.js +9865 -8535
- package/package.json +3 -3
- package/src/__tests__/enhancer.test.ts +65 -0
- package/src/cli/index.ts +3 -131
- package/src/lib/config.ts +56 -16
- package/src/lib/enhancer.ts +17 -11
- package/src/mcp/index.ts +2 -0
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +58 -49
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +247 -118
- package/src/native/Recordings/Recordings/ProjectStore.swift +126 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +157 -57
- package/src/native/Recordings/Recordings/RecordingsApp.swift +12 -4
- package/src/native/Recordings/Recordings/SettingsView.swift +147 -36
- package/src/native/RecordingsHelper.swift +0 -395
|
@@ -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()
|