@hasna/recordings 0.0.3

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.
@@ -0,0 +1,352 @@
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
+ return "\(home)/.recordings"
22
+ }()
23
+
24
+ let audioDir: String = {
25
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
26
+ return "\(home)/.recordings/audio"
27
+ }()
28
+
29
+ let recordingsBin: String = {
30
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
31
+ return "\(home)/.bun/bin/recordings"
32
+ }()
33
+
34
+ func applicationDidFinishLaunching(_ notification: Notification) {
35
+ // Create audio dir
36
+ try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
37
+
38
+ // Menu bar icon
39
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
40
+ updateIcon()
41
+
42
+ // Menu
43
+ let menu = NSMenu()
44
+ menu.addItem(NSMenuItem(title: "Hold Space (1s) to Record", action: #selector(toggleRecording), keyEquivalent: ""))
45
+ menu.addItem(NSMenuItem.separator())
46
+ menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
47
+ statusItem.menu = menu
48
+
49
+ // Register global hotkey: F5
50
+ registerHotkey()
51
+
52
+ showNotification(title: "Recordings", body: "Ready — hold Space for 1s to record")
53
+ }
54
+
55
+ func updateIcon() {
56
+ if let button = statusItem.button {
57
+ button.title = isRecording ? "⏺" : "🎙"
58
+ }
59
+ }
60
+
61
+ // ── Global Hotkey ───────────────────────────────────────────────────────
62
+
63
+ func registerHotkey() {
64
+ // Hold Space for 1+ second to start recording, release to stop.
65
+ // Uses CGEventTap to monitor all keyboard events globally.
66
+ // Normal space presses (< 1 second) pass through untouched.
67
+
68
+ let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue)
69
+
70
+ guard let tap = CGEvent.tapCreate(
71
+ tap: .cgSessionEventTap,
72
+ place: .headInsertEventTap,
73
+ options: .defaultTap,
74
+ eventsOfInterest: eventMask,
75
+ callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
76
+ let appDelegate = Unmanaged<AppDelegate>.fromOpaque(refcon!).takeUnretainedValue()
77
+ let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
78
+
79
+ // Space bar = keycode 49
80
+ guard keyCode == 49 else {
81
+ return Unmanaged.passRetained(event)
82
+ }
83
+
84
+ if type == .keyDown {
85
+ // Ignore key repeat events (auto-repeat while held)
86
+ let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat)
87
+ if isRepeat != 0 {
88
+ // Already tracking this press — suppress the repeat
89
+ if appDelegate.spaceDownTime != nil {
90
+ return nil // Swallow repeat while we're tracking
91
+ }
92
+ return Unmanaged.passRetained(event)
93
+ }
94
+
95
+ if appDelegate.spaceDownTime == nil {
96
+ appDelegate.spaceDownTime = Date()
97
+ // Start a timer — if space is still held after 1s, begin recording
98
+ DispatchQueue.main.async {
99
+ appDelegate.longPressTimer?.invalidate()
100
+ appDelegate.longPressTimer = Timer.scheduledTimer(withTimeInterval: appDelegate.longPressDuration, repeats: false) { _ in
101
+ if appDelegate.spaceDownTime != nil && !appDelegate.isRecording {
102
+ appDelegate.startRecording()
103
+ }
104
+ }
105
+ }
106
+ }
107
+ // Let the keyDown through for now (normal typing)
108
+ return Unmanaged.passRetained(event)
109
+
110
+ } else if type == .keyUp {
111
+ let wasLongPress = appDelegate.isRecording
112
+
113
+ if appDelegate.isRecording {
114
+ // Space released after recording — stop and transcribe
115
+ DispatchQueue.main.async {
116
+ appDelegate.stopAndTranscribe()
117
+ }
118
+ }
119
+
120
+ // Cancel timer
121
+ DispatchQueue.main.async {
122
+ appDelegate.longPressTimer?.invalidate()
123
+ appDelegate.longPressTimer = nil
124
+ }
125
+ appDelegate.spaceDownTime = nil
126
+
127
+ if wasLongPress {
128
+ // Swallow the keyUp so it doesn't type a space
129
+ return nil
130
+ }
131
+ return Unmanaged.passRetained(event)
132
+ }
133
+
134
+ return Unmanaged.passRetained(event)
135
+ },
136
+ userInfo: Unmanaged.passUnretained(self).toOpaque()
137
+ ) else {
138
+ showNotification(title: "Error", body: "Could not create event tap. Grant Accessibility permission in System Settings > Privacy > Accessibility")
139
+ return
140
+ }
141
+
142
+ let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
143
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
144
+ CGEvent.tapEnable(tap: tap, enable: true)
145
+ }
146
+
147
+ // ── Recording ────────────────────────────────────────────────────────────
148
+
149
+ @objc func toggleRecording() {
150
+ if isRecording { stopAndTranscribe() } else { startRecording() }
151
+ }
152
+
153
+ func startRecording() {
154
+ let formatter = DateFormatter()
155
+ formatter.dateFormat = "yyyyMMdd'T'HHmmss"
156
+ let filename = "recording-\(formatter.string(from: Date())).wav"
157
+ let filepath = "\(audioDir)/\(filename)"
158
+ currentAudioPath = filepath
159
+
160
+ // Run ffmpeg via bash — pipe stdin so we can send 'q' to stop gracefully
161
+ let process = Process()
162
+ let stdinPipe = Pipe()
163
+ process.executableURL = URL(fileURLWithPath: "/bin/bash")
164
+ process.arguments = ["-c", """
165
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
166
+ if command -v ffmpeg &>/dev/null; then
167
+ ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t 300 "\(filepath)" -y 2>/dev/null
168
+ elif command -v rec &>/dev/null; then
169
+ rec -r 16000 -c 1 -b 16 "\(filepath)" trim 0 300
170
+ else
171
+ exit 1
172
+ fi
173
+ """]
174
+ process.standardInput = stdinPipe
175
+ process.standardOutput = FileHandle.nullDevice
176
+ process.standardError = FileHandle.nullDevice
177
+
178
+ do {
179
+ try process.run()
180
+ recordProcess = process
181
+ stdinPipeRef = stdinPipe
182
+ isRecording = true
183
+ updateIcon()
184
+ showNotification(title: "Recording...", body: "Press F5 to stop")
185
+ } catch {
186
+ showNotification(title: "Error", body: "Could not start recording: \(error.localizedDescription)")
187
+ }
188
+ }
189
+
190
+ var stdinPipeRef: Pipe?
191
+
192
+ func stopAndTranscribe() {
193
+ guard let process = recordProcess else { return }
194
+
195
+ // Send 'q' to ffmpeg stdin for graceful stop (flushes WAV header)
196
+ if let pipe = stdinPipeRef {
197
+ pipe.fileHandleForWriting.write("q".data(using: .utf8)!)
198
+ try? pipe.fileHandleForWriting.close()
199
+ }
200
+ // Wait for graceful exit, then force if needed
201
+ Thread.sleep(forTimeInterval: 1.0)
202
+ if process.isRunning {
203
+ process.terminate()
204
+ Thread.sleep(forTimeInterval: 0.3)
205
+ }
206
+ if process.isRunning {
207
+ process.interrupt()
208
+ }
209
+ recordProcess = nil
210
+ stdinPipeRef = nil
211
+ isRecording = false
212
+ updateIcon()
213
+
214
+ guard let audioPath = currentAudioPath else { return }
215
+ currentAudioPath = nil
216
+
217
+ // Check if audio file exists and has content
218
+ let fileManager = FileManager.default
219
+ guard fileManager.fileExists(atPath: audioPath) else {
220
+ showNotification(title: "Error", body: "Audio file not created — check mic permissions")
221
+ return
222
+ }
223
+
224
+ let attrs = try? fileManager.attributesOfItem(atPath: audioPath)
225
+ let size = attrs?[.size] as? Int ?? 0
226
+ if size < 1000 {
227
+ showNotification(title: "Error", body: "Audio too short (\(size) bytes) — try speaking longer")
228
+ return
229
+ }
230
+
231
+ showNotification(title: "Transcribing...", body: "Processing your recording")
232
+
233
+ // Transcribe in background
234
+ DispatchQueue.global(qos: .userInitiated).async { [self] in
235
+ let output = self.runCommand(self.recordingsBin, arguments: ["transcribe", audioPath, "--json"])
236
+
237
+ // The CLI outputs "Transcribing...\n" then the text, then JSON on --json
238
+ // Find the JSON object in the output
239
+ var text = ""
240
+
241
+ // Try to find JSON in the output
242
+ if let jsonStart = output.range(of: "{"),
243
+ let jsonEnd = output.range(of: "}", options: .backwards) {
244
+ let jsonStr = String(output[jsonStart.lowerBound...jsonEnd.upperBound])
245
+ if let data = jsonStr.data(using: .utf8),
246
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
247
+ if let pt = json["processed_text"] as? String, !pt.isEmpty {
248
+ text = pt
249
+ } else if let rt = json["raw_text"] as? String, !rt.isEmpty {
250
+ text = rt
251
+ }
252
+ }
253
+ }
254
+
255
+ // Fallback: grab everything after the last newline before JSON
256
+ if text.isEmpty {
257
+ let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty && !$0.contains("Transcribing") && !$0.contains("{") }
258
+ for line in lines {
259
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
260
+ if !trimmed.isEmpty && !trimmed.hasPrefix("Saved") {
261
+ text = trimmed
262
+ break
263
+ }
264
+ }
265
+ }
266
+
267
+ guard !text.isEmpty else {
268
+ DispatchQueue.main.async {
269
+ let dbg = output.prefix(200)
270
+ self.showNotification(title: "Error", body: "Empty transcription. Output: \(dbg)")
271
+ }
272
+ return
273
+ }
274
+
275
+ DispatchQueue.main.async {
276
+ // Copy to clipboard
277
+ let pasteboard = NSPasteboard.general
278
+ pasteboard.clearContents()
279
+ pasteboard.setString(text, forType: .string)
280
+
281
+ // Auto-paste into the frontmost app (like Wispr Flow)
282
+ self.simulatePaste()
283
+
284
+ self.showNotification(title: "Done", body: String(text.prefix(80)))
285
+ }
286
+ }
287
+ }
288
+
289
+ // ── Helpers ─────────────────────────────────────────────────────────────
290
+
291
+ func simulatePaste() {
292
+ // Simulate Cmd+V
293
+ let source = CGEventSource(stateID: .hidSystemState)
294
+
295
+ let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) // V key
296
+ keyDown?.flags = .maskCommand
297
+ keyDown?.post(tap: .cghidEventTap)
298
+
299
+ let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
300
+ keyUp?.flags = .maskCommand
301
+ keyUp?.post(tap: .cghidEventTap)
302
+ }
303
+
304
+ func runCommand(_ command: String, arguments: [String]) -> String {
305
+ let process = Process()
306
+ let pipe = Pipe()
307
+ let errPipe = Pipe()
308
+ process.executableURL = URL(fileURLWithPath: "/bin/bash")
309
+
310
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
311
+ let args = arguments.map { "\"\($0)\"" }.joined(separator: " ")
312
+ process.arguments = ["-c", """
313
+ export PATH="\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
314
+ "\(command)" \(args)
315
+ """]
316
+ process.standardOutput = pipe
317
+ process.standardError = errPipe
318
+
319
+ do {
320
+ try process.run()
321
+ process.waitUntilExit()
322
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
323
+ return String(data: data, encoding: .utf8) ?? ""
324
+ } catch {
325
+ return "ERROR: \(error.localizedDescription)"
326
+ }
327
+ }
328
+
329
+ func showNotification(title: String, body: String) {
330
+ let process = Process()
331
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
332
+ process.arguments = ["-e", "display notification \"\(body)\" with title \"\(title)\""]
333
+ process.standardOutput = FileHandle.nullDevice
334
+ process.standardError = FileHandle.nullDevice
335
+ try? process.run()
336
+ }
337
+
338
+ @objc func quit() {
339
+ if let process = recordProcess {
340
+ process.interrupt()
341
+ }
342
+ NSApplication.shared.terminate(nil)
343
+ }
344
+ }
345
+
346
+ // ── Main ────────────────────────────────────────────────────────────────────
347
+
348
+ let app = NSApplication.shared
349
+ app.setActivationPolicy(.accessory) // Menu bar only, no dock icon
350
+ let delegate = AppDelegate()
351
+ app.delegate = delegate
352
+ app.run()
@@ -0,0 +1,138 @@
1
+ // ── Recording Types ──────────────────────────────────────────────────────────
2
+
3
+ export interface Recording {
4
+ id: string;
5
+ audio_path: string | null;
6
+ raw_text: string;
7
+ processed_text: string | null;
8
+ processing_mode: ProcessingMode;
9
+ model_used: string;
10
+ enhancement_model: string | null;
11
+ duration_ms: number;
12
+ language: string | null;
13
+ tags: string[];
14
+ agent_id: string | null;
15
+ project_id: string | null;
16
+ session_id: string | null;
17
+ metadata: Record<string, unknown>;
18
+ created_at: string;
19
+ }
20
+
21
+ export type ProcessingMode = "raw" | "enhanced";
22
+
23
+ export interface CreateRecordingInput {
24
+ audio_path?: string;
25
+ raw_text: string;
26
+ processed_text?: string;
27
+ processing_mode?: ProcessingMode;
28
+ model_used?: string;
29
+ enhancement_model?: string;
30
+ duration_ms?: number;
31
+ language?: string;
32
+ tags?: string[];
33
+ agent_id?: string;
34
+ project_id?: string;
35
+ session_id?: string;
36
+ metadata?: Record<string, unknown>;
37
+ }
38
+
39
+ export interface RecordingFilter {
40
+ agent_id?: string;
41
+ project_id?: string;
42
+ session_id?: string;
43
+ processing_mode?: ProcessingMode;
44
+ tags?: string[];
45
+ search?: string;
46
+ since?: string;
47
+ until?: string;
48
+ limit?: number;
49
+ offset?: number;
50
+ }
51
+
52
+ // ── Agent Types ─────────────────────────────────────────────────────────────
53
+
54
+ export interface Agent {
55
+ id: string;
56
+ name: string;
57
+ description: string | null;
58
+ role: string;
59
+ metadata: Record<string, unknown>;
60
+ created_at: string;
61
+ last_seen_at: string;
62
+ }
63
+
64
+ // ── Project Types ───────────────────────────────────────────────────────────
65
+
66
+ export interface Project {
67
+ id: string;
68
+ name: string;
69
+ path: string;
70
+ description: string | null;
71
+ created_at: string;
72
+ updated_at: string;
73
+ }
74
+
75
+ // ── Config Types ────────────────────────────────────────────────────────────
76
+
77
+ export interface RecordingsConfig {
78
+ openai_api_key: string;
79
+ enhancement_api_key: string;
80
+ transcription_model: string;
81
+ enhancement_model: string;
82
+ language: string;
83
+ audio_format: "wav" | "mp3" | "m4a" | "webm";
84
+ sample_rate: number;
85
+ record_command: string;
86
+ hotkey: string;
87
+ auto_enhance: boolean;
88
+ enhance_triggers: string[];
89
+ db_path: string;
90
+ audio_dir: string;
91
+ max_recording_seconds: number;
92
+ }
93
+
94
+ // ── Transcription Types ─────────────────────────────────────────────────────
95
+
96
+ export interface TranscriptionResult {
97
+ text: string;
98
+ duration_ms: number;
99
+ model: string;
100
+ language: string | null;
101
+ }
102
+
103
+ export interface EnhancementResult {
104
+ original: string;
105
+ enhanced: string;
106
+ model: string;
107
+ reasoning: string | null;
108
+ }
109
+
110
+ // ── Errors ──────────────────────────────────────────────────────────────────
111
+
112
+ export class RecordingNotFoundError extends Error {
113
+ constructor(id: string) {
114
+ super(`Recording not found: ${id}`);
115
+ this.name = "RecordingNotFoundError";
116
+ }
117
+ }
118
+
119
+ export class RecordingError extends Error {
120
+ constructor(message: string) {
121
+ super(message);
122
+ this.name = "RecordingError";
123
+ }
124
+ }
125
+
126
+ export class TranscriptionError extends Error {
127
+ constructor(message: string) {
128
+ super(message);
129
+ this.name = "TranscriptionError";
130
+ }
131
+ }
132
+
133
+ export class EnhancementError extends Error {
134
+ constructor(message: string) {
135
+ super(message);
136
+ this.name = "EnhancementError";
137
+ }
138
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "types": ["bun"]
18
+ },
19
+ "include": ["src/**/*.ts"],
20
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
21
+ }