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