@hasna/recordings 0.1.10 → 0.1.12
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/README.md +2 -0
- package/dist/cli/index.js +342 -153
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/index.js +114 -33
- 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/lib/recorder.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts +8 -2
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +183 -52
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +20 -3
- package/scripts/install_macos_app.sh +76 -0
- package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +16 -4
- package/src/native/Recordings/Package.resolved +19 -1
- package/src/native/Recordings/Package.swift +16 -5
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +131 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +361 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +126 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +218 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
- package/src/native/Recordings/build.sh +6 -6
- package/bun.lock +0 -250
- package/bunfig.toml +0 -2
- package/src/__tests__/agents.test.ts +0 -136
- package/src/__tests__/config.test.ts +0 -252
- package/src/__tests__/database.test.ts +0 -167
- package/src/__tests__/enhancer.test.ts +0 -574
- package/src/__tests__/preload.ts +0 -4
- package/src/__tests__/projects.test.ts +0 -109
- package/src/__tests__/recorder.test.ts +0 -278
- package/src/__tests__/recordings.test.ts +0 -353
- package/src/__tests__/transcriber.test.ts +0 -322
- package/src/__tests__/types.test.ts +0 -75
- package/src/cli/index.ts +0 -1115
- package/src/db/agents.ts +0 -104
- package/src/db/database.ts +0 -163
- package/src/db/pg-migrations.ts +0 -82
- package/src/db/projects.ts +0 -71
- package/src/db/recordings.ts +0 -225
- package/src/index.ts +0 -81
- package/src/lib/config.ts +0 -183
- package/src/lib/enhancer.ts +0 -167
- package/src/lib/recorder.ts +0 -198
- package/src/lib/transcriber.ts +0 -105
- package/src/mcp/index.ts +0 -464
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +0 -122
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +0 -188
- package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -355
- package/src/native/Recordings/Recordings/SettingsView.swift +0 -93
- package/src/native/Recordings/test_fn.swift +0 -79
- package/src/native/Recordings/test_fn2.swift +0 -33
- package/src/native/RecordingsHelper.swift +0 -395
- package/src/types/index.ts +0 -144
- package/tsconfig.json +0 -21
- /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
@preconcurrency import ApplicationServices
|
|
3
|
+
import SwiftUI
|
|
4
|
+
import KeyboardShortcuts
|
|
5
|
+
|
|
6
|
+
// MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
|
|
7
|
+
|
|
8
|
+
extension KeyboardShortcuts.Name {
|
|
9
|
+
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
|
+
|
|
146
|
+
// Load preferences
|
|
147
|
+
if let savedMode = UserDefaults.standard.string(forKey: "recordingMode"),
|
|
148
|
+
let parsedMode = RecordingMode(rawValue: savedMode) {
|
|
149
|
+
mode = parsedMode
|
|
150
|
+
}
|
|
151
|
+
useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
|
|
152
|
+
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
153
|
+
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
|
|
157
|
+
fnMonitor.onFnKeyDown = { [weak self] in
|
|
158
|
+
Task { @MainActor [weak self] in
|
|
159
|
+
guard let self, self.useFnKey, !self.isRecording else { return }
|
|
160
|
+
self.startRecording(trigger: .fnKey)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
fnMonitor.onFnKeyUp = { [weak self] in
|
|
164
|
+
Task { @MainActor [weak self] in
|
|
165
|
+
guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
|
|
166
|
+
self.stopAndTranscribe()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
updateFnMonitor()
|
|
170
|
+
|
|
171
|
+
KeyboardShortcuts.onKeyDown(for: .toggleRecording) { [weak self] in
|
|
172
|
+
Task { @MainActor [weak self] in
|
|
173
|
+
guard let self, !self.keyboardShortcutIsDown else { return }
|
|
174
|
+
self.keyboardShortcutIsDown = true
|
|
175
|
+
guard !self.isRecording else { return }
|
|
176
|
+
self.startRecording(trigger: .keyboardShortcut)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
|
|
180
|
+
Task { @MainActor [weak self] in
|
|
181
|
+
guard let self, self.keyboardShortcutIsDown else { return }
|
|
182
|
+
self.keyboardShortcutIsDown = false
|
|
183
|
+
guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
|
|
184
|
+
self.stopAndTranscribe()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
updateStatus()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private func updateFnMonitor() {
|
|
192
|
+
if useFnKey {
|
|
193
|
+
let ok = fnMonitor.start()
|
|
194
|
+
if !ok {
|
|
195
|
+
statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
fnMonitor.stop()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public func updateStatus() {
|
|
203
|
+
if isRecording || isTranscribing { return }
|
|
204
|
+
statusMessage = "Ready"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// MARK: - Toggle
|
|
208
|
+
|
|
209
|
+
public func toggleRecording() {
|
|
210
|
+
if isRecording { stopAndTranscribe() } else { startRecording(trigger: .manual) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// MARK: - Start Recording (Streaming)
|
|
214
|
+
|
|
215
|
+
public func startRecording(trigger: RecordingTrigger = .manual) {
|
|
216
|
+
guard !isRecording else { return }
|
|
217
|
+
activeTrigger = trigger
|
|
218
|
+
keyboardShortcutIsDown = trigger == .keyboardShortcut
|
|
219
|
+
|
|
220
|
+
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
221
|
+
let frontmostApp = NSWorkspace.shared.frontmostApplication
|
|
222
|
+
let isOwnApp = frontmostApp?.processIdentifier == myPID
|
|
223
|
+
targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
|
|
224
|
+
targetAppPid = isOwnApp ? nil : frontmostApp?.processIdentifier
|
|
225
|
+
|
|
226
|
+
if let store = projectStore {
|
|
227
|
+
let windowTitle = Self.focusedWindowTitle(pid: frontmostApp?.processIdentifier)
|
|
228
|
+
let projects = store.settings.projects
|
|
229
|
+
let detected = ProjectStore.matchProject(windowTitle: windowTitle, bundleId: targetAppBundleIdentifier, projects: projects)
|
|
230
|
+
if let detected {
|
|
231
|
+
store.setActive(detected.id)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
236
|
+
case .authorized:
|
|
237
|
+
startNativeRecording()
|
|
238
|
+
case .notDetermined:
|
|
239
|
+
statusMessage = "Allow microphone access to record"
|
|
240
|
+
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
|
241
|
+
Task { @MainActor [weak self] in
|
|
242
|
+
guard let self else { return }
|
|
243
|
+
if granted {
|
|
244
|
+
self.startNativeRecording()
|
|
245
|
+
} else {
|
|
246
|
+
self.resetRecordingIntent()
|
|
247
|
+
self.statusMessage = "Microphone permission denied"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
case .denied, .restricted:
|
|
252
|
+
resetRecordingIntent()
|
|
253
|
+
statusMessage = "Enable Microphone permission for Recordings in System Settings"
|
|
254
|
+
@unknown default:
|
|
255
|
+
resetRecordingIntent()
|
|
256
|
+
statusMessage = "Microphone permission unavailable"
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private func startNativeRecording() {
|
|
261
|
+
let streamState = PCMStreamState()
|
|
262
|
+
pcmStreamState = streamState
|
|
263
|
+
|
|
264
|
+
let apiKey = openAIAPIKey
|
|
265
|
+
if !apiKey.isEmpty {
|
|
266
|
+
startRealtimeStreaming(apiKey: apiKey)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let client = realtimeClient
|
|
270
|
+
let recorder = NativePCMRecorder { [weak client] data in
|
|
271
|
+
Task {
|
|
272
|
+
let chunks = await streamState.append(data, chunkSize: 4_800)
|
|
273
|
+
for chunk in chunks {
|
|
274
|
+
await client?.sendAudio(chunk)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
do {
|
|
280
|
+
try recorder.start()
|
|
281
|
+
nativeRecorder = recorder
|
|
282
|
+
isRecording = true
|
|
283
|
+
recordingDuration = 0
|
|
284
|
+
streamingText = ""
|
|
285
|
+
liveTranscriptionText = ""
|
|
286
|
+
recordedPCM.removeAll(keepingCapacity: true)
|
|
287
|
+
activeAudioPath = "\(audioDir)/recording-\(Self.timestampForFilename()).wav"
|
|
288
|
+
let trigger = activeTrigger ?? .manual
|
|
289
|
+
statusMessage = switch (mode, trigger) {
|
|
290
|
+
case (.command, _): "Speak your instruction..."
|
|
291
|
+
case (_, .manual): "Recording — click Stop when finished"
|
|
292
|
+
case (_, .fnKey), (_, .keyboardShortcut): "Recording — release to stop"
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
296
|
+
Task { @MainActor [weak self] in
|
|
297
|
+
self?.recordingDuration += 0.1
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
realtimeClient?.stop()
|
|
302
|
+
realtimeClient = nil
|
|
303
|
+
streamingTask?.cancel()
|
|
304
|
+
streamingTask = nil
|
|
305
|
+
pcmStreamState = nil
|
|
306
|
+
resetRecordingIntent()
|
|
307
|
+
statusMessage = "Failed: \(error.localizedDescription)"
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// MARK: - Real-time Streaming
|
|
312
|
+
|
|
313
|
+
private func startRealtimeStreaming(apiKey: String) {
|
|
314
|
+
let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
|
|
315
|
+
let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
|
|
316
|
+
realtimeClient = client
|
|
317
|
+
|
|
318
|
+
streamingTask = Task {
|
|
319
|
+
await client.startStreaming(systemPrompt: systemPrompt)
|
|
320
|
+
|
|
321
|
+
// Receive deltas
|
|
322
|
+
while client.isStreaming {
|
|
323
|
+
try? await Task.sleep(for: .milliseconds(100))
|
|
324
|
+
let text = client.accumulatedText
|
|
325
|
+
if text != streamingText {
|
|
326
|
+
await MainActor.run {
|
|
327
|
+
self.streamingText = text
|
|
328
|
+
self.liveTranscriptionText = text
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if let message = client.error, !message.isEmpty {
|
|
334
|
+
await MainActor.run {
|
|
335
|
+
self.statusMessage = "Realtime unavailable — will transcribe after recording"
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// MARK: - Stop & Transcribe
|
|
342
|
+
|
|
343
|
+
public func stopAndTranscribe() {
|
|
344
|
+
guard isRecording else { return }
|
|
345
|
+
|
|
346
|
+
recordingTimer?.invalidate()
|
|
347
|
+
recordingTimer = nil
|
|
348
|
+
|
|
349
|
+
let recorder = nativeRecorder
|
|
350
|
+
nativeRecorder = nil
|
|
351
|
+
recorder?.stop()
|
|
352
|
+
|
|
353
|
+
isRecording = false
|
|
354
|
+
isTranscribing = true
|
|
355
|
+
|
|
356
|
+
let curMode = mode
|
|
357
|
+
let targetAppBundleIdentifier = targetAppBundleIdentifier
|
|
358
|
+
let activeProjectId = projectStore?.settings.activeProjectId
|
|
359
|
+
let activeProjectName = projectStore?.activeProject?.name
|
|
360
|
+
let audioPath = activeAudioPath
|
|
361
|
+
let pcmStreamState = pcmStreamState
|
|
362
|
+
let client = realtimeClient
|
|
363
|
+
resetRecordingIntent()
|
|
364
|
+
self.pcmStreamState = nil
|
|
365
|
+
|
|
366
|
+
Task {
|
|
367
|
+
if let pcmStreamState {
|
|
368
|
+
if let finalChunk = await pcmStreamState.flushPendingChunk() {
|
|
369
|
+
client?.sendAudio(finalChunk)
|
|
370
|
+
}
|
|
371
|
+
self.recordedPCM = await pcmStreamState.capturedPCM()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let streamingResult = await client?.finish() ?? ""
|
|
375
|
+
|
|
376
|
+
self.realtimeClient = nil
|
|
377
|
+
self.streamingTask?.cancel()
|
|
378
|
+
self.streamingTask = nil
|
|
379
|
+
|
|
380
|
+
let text = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
|
|
381
|
+
|
|
382
|
+
self.liveTranscriptionText = ""
|
|
383
|
+
|
|
384
|
+
if let text {
|
|
385
|
+
self.isTranscribing = false
|
|
386
|
+
self.finishWithText(
|
|
387
|
+
text,
|
|
388
|
+
curMode: curMode,
|
|
389
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
390
|
+
activeProjectId: activeProjectId,
|
|
391
|
+
activeProjectName: activeProjectName
|
|
392
|
+
)
|
|
393
|
+
} else if let audioPath, self.writeCapturedWAV(to: audioPath) {
|
|
394
|
+
self.fallbackTranscribe(
|
|
395
|
+
audioPath: audioPath,
|
|
396
|
+
curMode: curMode,
|
|
397
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
398
|
+
activeProjectId: activeProjectId,
|
|
399
|
+
activeProjectName: activeProjectName
|
|
400
|
+
)
|
|
401
|
+
} else {
|
|
402
|
+
self.finish("No audio captured")
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
self.activeAudioPath = nil
|
|
406
|
+
self.recordedPCM.removeAll(keepingCapacity: true)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
|
|
411
|
+
if curMode == .command {
|
|
412
|
+
runCommandMode(instruction: text)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let shortcutText = voiceShortcuts?.match(text)
|
|
417
|
+
let output = shortcutText ?? text
|
|
418
|
+
pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier)
|
|
419
|
+
recentTranscriptions.insert(
|
|
420
|
+
TranscriptionResult(
|
|
421
|
+
rawText: text,
|
|
422
|
+
processedText: shortcutText,
|
|
423
|
+
timestamp: Date(),
|
|
424
|
+
projectId: activeProjectId,
|
|
425
|
+
projectName: activeProjectName
|
|
426
|
+
),
|
|
427
|
+
at: 0
|
|
428
|
+
)
|
|
429
|
+
if recentTranscriptions.count > 20 { recentTranscriptions.removeLast() }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private func writeCapturedWAV(to path: String) -> Bool {
|
|
433
|
+
guard !recordedPCM.isEmpty else { return false }
|
|
434
|
+
do {
|
|
435
|
+
try Self.writeWAV(
|
|
436
|
+
pcmData: recordedPCM,
|
|
437
|
+
sampleRate: 24_000,
|
|
438
|
+
channelCount: 1,
|
|
439
|
+
bitsPerSample: 16,
|
|
440
|
+
to: URL(fileURLWithPath: path)
|
|
441
|
+
)
|
|
442
|
+
return true
|
|
443
|
+
} catch {
|
|
444
|
+
statusMessage = "Failed to save audio"
|
|
445
|
+
return false
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
|
|
450
|
+
let byteRate = sampleRate * UInt32(channelCount) * UInt32(bitsPerSample / 8)
|
|
451
|
+
let blockAlign = channelCount * (bitsPerSample / 8)
|
|
452
|
+
let dataSize = UInt32(pcmData.count)
|
|
453
|
+
let fileSize = UInt32(36) + dataSize
|
|
454
|
+
|
|
455
|
+
var wav = Data()
|
|
456
|
+
func appendASCII(_ string: String) {
|
|
457
|
+
wav.append(contentsOf: string.utf8)
|
|
458
|
+
}
|
|
459
|
+
func appendUInt16LE(_ value: UInt16) {
|
|
460
|
+
wav.append(UInt8(value & 0xff))
|
|
461
|
+
wav.append(UInt8((value >> 8) & 0xff))
|
|
462
|
+
}
|
|
463
|
+
func appendUInt32LE(_ value: UInt32) {
|
|
464
|
+
wav.append(UInt8(value & 0xff))
|
|
465
|
+
wav.append(UInt8((value >> 8) & 0xff))
|
|
466
|
+
wav.append(UInt8((value >> 16) & 0xff))
|
|
467
|
+
wav.append(UInt8((value >> 24) & 0xff))
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
appendASCII("RIFF")
|
|
471
|
+
appendUInt32LE(fileSize)
|
|
472
|
+
appendASCII("WAVE")
|
|
473
|
+
appendASCII("fmt ")
|
|
474
|
+
appendUInt32LE(16)
|
|
475
|
+
appendUInt16LE(1)
|
|
476
|
+
appendUInt16LE(channelCount)
|
|
477
|
+
appendUInt32LE(sampleRate)
|
|
478
|
+
appendUInt32LE(byteRate)
|
|
479
|
+
appendUInt16LE(blockAlign)
|
|
480
|
+
appendUInt16LE(bitsPerSample)
|
|
481
|
+
appendASCII("data")
|
|
482
|
+
appendUInt32LE(dataSize)
|
|
483
|
+
wav.append(pcmData)
|
|
484
|
+
|
|
485
|
+
let dir = url.deletingLastPathComponent()
|
|
486
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
487
|
+
try wav.write(to: url, options: .atomic)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private static func timestampForFilename() -> String {
|
|
491
|
+
let formatter = DateFormatter()
|
|
492
|
+
formatter.dateFormat = "yyyyMMdd-HHmmss-SSS"
|
|
493
|
+
return formatter.string(from: Date())
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// MARK: - Fallback Transcription
|
|
497
|
+
|
|
498
|
+
private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
|
|
499
|
+
let homePath = home
|
|
500
|
+
|
|
501
|
+
isTranscribing = true
|
|
502
|
+
statusMessage = "Transcribing..."
|
|
503
|
+
|
|
504
|
+
Task.detached {
|
|
505
|
+
let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
|
|
506
|
+
if let error = CLIRunner.parseError(output) {
|
|
507
|
+
await MainActor.run { self.finish(error) }
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let text = CLIRunner.parseJSON(output)
|
|
512
|
+
guard let text, !text.isEmpty else {
|
|
513
|
+
await MainActor.run { self.finish("Empty transcription") }
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await MainActor.run {
|
|
518
|
+
self.isTranscribing = false
|
|
519
|
+
self.finishWithText(
|
|
520
|
+
text,
|
|
521
|
+
curMode: curMode,
|
|
522
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
523
|
+
activeProjectId: activeProjectId,
|
|
524
|
+
activeProjectName: activeProjectName
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private func mostRecentAudioFile() -> String? {
|
|
531
|
+
let files = (try? FileManager.default.contentsOfDirectory(atPath: audioDir)) ?? []
|
|
532
|
+
let wavFiles = files.filter { $0.hasSuffix(".wav") }.sorted().reversed()
|
|
533
|
+
return wavFiles.first.map { "\(audioDir)/\($0)" }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private func finish(_ msg: String) {
|
|
537
|
+
isTranscribing = false
|
|
538
|
+
liveTranscriptionText = ""
|
|
539
|
+
statusMessage = msg
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private func resetRecordingIntent() {
|
|
543
|
+
activeTrigger = nil
|
|
544
|
+
keyboardShortcutIsDown = false
|
|
545
|
+
targetAppBundleIdentifier = nil
|
|
546
|
+
targetAppPid = nil
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// MARK: - Command Mode
|
|
550
|
+
|
|
551
|
+
private func runCommandMode(instruction: String) {
|
|
552
|
+
guard ensureAccessibilityPermission(prompt: true) else {
|
|
553
|
+
statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
postKey(0x08, flags: .maskCommand) // Cmd+C
|
|
557
|
+
let homePath = home
|
|
558
|
+
|
|
559
|
+
Task {
|
|
560
|
+
try? await Task.sleep(for: .milliseconds(250))
|
|
561
|
+
let selected = NSPasteboard.general.string(forType: .string) ?? ""
|
|
562
|
+
guard !selected.isEmpty else {
|
|
563
|
+
statusMessage = "No text selected"
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
statusMessage = "Rewriting..."
|
|
567
|
+
isTranscribing = true
|
|
568
|
+
|
|
569
|
+
Task.detached {
|
|
570
|
+
let result = CLIRunner.run(["rewrite", selected, "--instruction", instruction], home: homePath)
|
|
571
|
+
await MainActor.run {
|
|
572
|
+
self.isTranscribing = false
|
|
573
|
+
self.liveTranscriptionText = ""
|
|
574
|
+
if CLIRunner.parseError(result) == nil, !result.isEmpty {
|
|
575
|
+
self.pasteIntoFrontApp(result)
|
|
576
|
+
self.statusMessage = "Rewritten"
|
|
577
|
+
} else {
|
|
578
|
+
self.statusMessage = CLIRunner.parseError(result) ?? "Rewrite failed"
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
|
|
586
|
+
let src = CGEventSource(stateID: .hidSystemState)
|
|
587
|
+
guard let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true),
|
|
588
|
+
let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) else { return }
|
|
589
|
+
down.flags = flags
|
|
590
|
+
up.flags = flags
|
|
591
|
+
down.post(tap: .cgSessionEventTap)
|
|
592
|
+
up.post(tap: .cgSessionEventTap)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// MARK: - Window Title (Accessibility API)
|
|
596
|
+
|
|
597
|
+
private static func focusedWindowTitle(pid: pid_t?) -> String? {
|
|
598
|
+
guard let pid else { return nil }
|
|
599
|
+
let app = AXUIElementCreateApplication(pid)
|
|
600
|
+
var windowRef: CFTypeRef?
|
|
601
|
+
guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
|
|
602
|
+
let window = windowRef else { return nil }
|
|
603
|
+
var titleRef: CFTypeRef?
|
|
604
|
+
guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleRef) == .success,
|
|
605
|
+
let title = titleRef as? String else { return nil }
|
|
606
|
+
return title
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// MARK: - Paste
|
|
610
|
+
|
|
611
|
+
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
|
|
612
|
+
let pb = NSPasteboard.general
|
|
613
|
+
pb.clearContents()
|
|
614
|
+
pb.setString(text, forType: .string)
|
|
615
|
+
|
|
616
|
+
guard ensureAccessibilityPermission(prompt: true) else {
|
|
617
|
+
self.statusMessage = "Copied — enable Accessibility permission for Recordings to paste"
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
622
|
+
let runningApps = NSWorkspace.shared.runningApplications
|
|
623
|
+
let targetApp = runningApps.first(where: {
|
|
624
|
+
guard $0.processIdentifier != myPID, $0.activationPolicy == .regular else { return false }
|
|
625
|
+
guard let bundleIdentifier = targetAppBundleIdentifier else { return false }
|
|
626
|
+
return $0.bundleIdentifier == bundleIdentifier
|
|
627
|
+
}) ?? runningApps.first(where: {
|
|
628
|
+
$0.activationPolicy == .regular && $0.processIdentifier != myPID
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
guard let app = targetApp else {
|
|
632
|
+
self.statusMessage = "No target app found"
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Activate target app and wait for focus to stabilize
|
|
637
|
+
app.activate()
|
|
638
|
+
|
|
639
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
640
|
+
let src = CGEventSource(stateID: .hidSystemState)
|
|
641
|
+
if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
|
|
642
|
+
let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
|
|
643
|
+
down.flags = .maskCommand
|
|
644
|
+
up.flags = .maskCommand
|
|
645
|
+
down.post(tap: .cgSessionEventTap)
|
|
646
|
+
up.post(tap: .cgSessionEventTap)
|
|
647
|
+
}
|
|
648
|
+
self.statusMessage = "Pasted (\(text.count) chars)"
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
|
|
653
|
+
if !prompt {
|
|
654
|
+
return AXIsProcessTrusted()
|
|
655
|
+
}
|
|
656
|
+
return AXIsProcessTrustedWithOptions(
|
|
657
|
+
["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// MARK: - CLI Runner
|
|
663
|
+
|
|
664
|
+
enum CLIRunner: Sendable {
|
|
665
|
+
static func run(_ args: [String], home: String) -> String {
|
|
666
|
+
let bin = "\(home)/.bun/bin/recordings"
|
|
667
|
+
let proc = Process()
|
|
668
|
+
let outPipe = Pipe()
|
|
669
|
+
let errPipe = Pipe()
|
|
670
|
+
if FileManager.default.fileExists(atPath: bin) {
|
|
671
|
+
proc.executableURL = URL(fileURLWithPath: bin)
|
|
672
|
+
proc.arguments = args
|
|
673
|
+
} else {
|
|
674
|
+
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
|
675
|
+
proc.arguments = ["recordings"] + args
|
|
676
|
+
}
|
|
677
|
+
proc.environment = ProcessInfo.processInfo.environment.merging([
|
|
678
|
+
"PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
679
|
+
]) { _, new in new }
|
|
680
|
+
proc.standardOutput = outPipe
|
|
681
|
+
proc.standardError = errPipe
|
|
682
|
+
do {
|
|
683
|
+
try proc.run()
|
|
684
|
+
proc.waitUntilExit()
|
|
685
|
+
let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
686
|
+
let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
687
|
+
if proc.terminationStatus != 0 {
|
|
688
|
+
let details = stderr.isEmpty ? stdout : stderr
|
|
689
|
+
return "ERROR: \(details.trimmingCharacters(in: .whitespacesAndNewlines))"
|
|
690
|
+
}
|
|
691
|
+
return stdout.isEmpty ? stderr : stdout
|
|
692
|
+
} catch {
|
|
693
|
+
return "ERROR: \(error.localizedDescription)"
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
static func parseError(_ output: String) -> String? {
|
|
698
|
+
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
699
|
+
guard trimmed.hasPrefix("ERROR:") else { return nil }
|
|
700
|
+
let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
701
|
+
if message.contains("OpenAI API key not configured") {
|
|
702
|
+
return "OpenAI API key not configured on this Mac"
|
|
703
|
+
}
|
|
704
|
+
if message.isEmpty {
|
|
705
|
+
return "Transcription failed"
|
|
706
|
+
}
|
|
707
|
+
return String(message.prefix(120))
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
static func parseJSON(_ output: String) -> String? {
|
|
711
|
+
if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards),
|
|
712
|
+
s.lowerBound < e.upperBound {
|
|
713
|
+
let json = String(output[s.lowerBound..<e.upperBound])
|
|
714
|
+
if let data = json.data(using: .utf8),
|
|
715
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
716
|
+
if let t = obj["processed_text"] as? String, !t.isEmpty { return t }
|
|
717
|
+
if let t = obj["raw_text"] as? String, !t.isEmpty { return t }
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return output.components(separatedBy: "\n")
|
|
721
|
+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
722
|
+
.first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") && !$0.hasPrefix("ERROR:") }
|
|
723
|
+
}
|
|
724
|
+
}
|