@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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/LICENSE +21 -0
- package/dist/cli/index.js +1446 -0
- package/dist/mcp/index.js +4882 -0
- package/package.json +46 -0
- package/src/__tests__/agents.test.ts +136 -0
- package/src/__tests__/config.test.ts +252 -0
- package/src/__tests__/database.test.ts +167 -0
- package/src/__tests__/enhancer.test.ts +574 -0
- package/src/__tests__/preload.ts +4 -0
- package/src/__tests__/projects.test.ts +109 -0
- package/src/__tests__/recorder.test.ts +278 -0
- package/src/__tests__/recordings.test.ts +353 -0
- package/src/__tests__/transcriber.test.ts +322 -0
- package/src/__tests__/types.test.ts +75 -0
- package/src/cli/index.ts +1078 -0
- package/src/db/agents.ts +81 -0
- package/src/db/database.ts +126 -0
- package/src/db/projects.ts +71 -0
- package/src/db/recordings.ts +219 -0
- package/src/index.ts +81 -0
- package/src/lib/config.ts +166 -0
- package/src/lib/enhancer.ts +167 -0
- package/src/lib/recorder.ts +198 -0
- package/src/lib/transcriber.ts +105 -0
- package/src/mcp/index.ts +405 -0
- package/src/native/RecordingsHelper.swift +352 -0
- package/src/types/index.ts +138 -0
- package/tsconfig.json +21 -0
|
@@ -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
|
+
}
|