@hasna/recordings 0.1.22 → 0.1.24
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 +11 -5
- package/dist/cli/index.js +5365 -9390
- package/dist/cli/storage.d.ts +3 -0
- package/dist/cli/storage.d.ts.map +1 -0
- package/dist/db/database.d.ts +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrate.d.ts +8 -0
- package/dist/db/pg-migrate.d.ts.map +1 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +12 -0
- package/dist/db/remote-storage.d.ts.map +1 -0
- package/dist/db/sqlite-adapter.d.ts +14 -0
- package/dist/db/sqlite-adapter.d.ts.map +1 -0
- package/dist/db/storage-config.d.ts +27 -0
- package/dist/db/storage-config.d.ts.map +1 -0
- package/dist/db/storage-sync.d.ts +36 -0
- package/dist/db/storage-sync.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +684 -4857
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +1797 -6616
- package/dist/mcp/storage-tools.d.ts +3 -0
- package/dist/mcp/storage-tools.d.ts.map +1 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +5656 -0
- package/dist/version.d.ts +1 -1
- package/package.json +10 -4
- package/scripts/install_macos_app.sh +17 -0
- package/src/native/Recordings/App/RecordingsApp.swift +3 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +101 -80
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +26 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +13 -1
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +92 -31
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +5 -1
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +12 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +49 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +54 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +58 -0
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.24";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/recordings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
|
|
6
6
|
"repository": {
|
|
@@ -17,13 +17,18 @@
|
|
|
17
17
|
".": {
|
|
18
18
|
"import": "./dist/index.js",
|
|
19
19
|
"types": "./dist/index.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./storage": {
|
|
22
|
+
"import": "./dist/storage.js",
|
|
23
|
+
"types": "./dist/storage.d.ts"
|
|
20
24
|
}
|
|
21
25
|
},
|
|
22
26
|
"scripts": {
|
|
23
|
-
"
|
|
27
|
+
"clean": "rm -rf dist",
|
|
28
|
+
"build": "bun run clean && bun run build:cli && bun run build:mcp && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
24
29
|
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
25
30
|
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
26
|
-
"build:lib": "bun build src/index.ts --target=bun --
|
|
31
|
+
"build:lib": "bun build src/index.ts src/storage.ts --target=bun --outdir=dist --external=openai",
|
|
27
32
|
"typecheck": "tsc --noEmit",
|
|
28
33
|
"test": "bun test",
|
|
29
34
|
"test:coverage": "bun test --coverage",
|
|
@@ -45,15 +50,16 @@
|
|
|
45
50
|
"LICENSE"
|
|
46
51
|
],
|
|
47
52
|
"dependencies": {
|
|
48
|
-
"@hasna/cloud": "^0.1.24",
|
|
49
53
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
50
54
|
"chalk": "^5.4.1",
|
|
51
55
|
"commander": "^13.1.0",
|
|
52
56
|
"openai": "^5.1.0",
|
|
57
|
+
"pg": "^8.13.3",
|
|
53
58
|
"zod": "^3.24.2"
|
|
54
59
|
},
|
|
55
60
|
"devDependencies": {
|
|
56
61
|
"@types/bun": "^1.2.5",
|
|
62
|
+
"@types/pg": "^8.11.11",
|
|
57
63
|
"typescript": "^5.8.2"
|
|
58
64
|
},
|
|
59
65
|
"publishConfig": {
|
|
@@ -105,4 +105,21 @@ APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
|
|
|
105
105
|
reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
106
106
|
reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
107
107
|
|
|
108
|
+
# A second copy in ~/Applications splits TCC permissions and leaves users running
|
|
109
|
+
# stale builds. Keep that launch point but always point it at the fresh build.
|
|
110
|
+
ALT_COPY="${HOME}/Applications/Recordings.app"
|
|
111
|
+
if [ -d "$ALT_COPY" ]; then
|
|
112
|
+
rm -rf "$ALT_COPY"
|
|
113
|
+
cp -R "$APP_SOURCE" "$ALT_COPY" || true
|
|
114
|
+
echo "Updated stale copy at ${ALT_COPY} to the freshly built app."
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# If an old instance is running, restart it on the new build.
|
|
118
|
+
if pgrep -x Recordings >/dev/null 2>&1; then
|
|
119
|
+
pkill -x Recordings || true
|
|
120
|
+
sleep 1
|
|
121
|
+
open "$APP_DEST" || true
|
|
122
|
+
echo "Restarted Recordings.app on the new build."
|
|
123
|
+
fi
|
|
124
|
+
|
|
108
125
|
echo "Installed Recordings.app from package: ${APP_DEST}"
|
|
@@ -25,10 +25,11 @@ struct RecordingsApp: App {
|
|
|
25
25
|
}
|
|
26
26
|
} label: {
|
|
27
27
|
if engine.isRecording {
|
|
28
|
-
Image(systemName: "
|
|
29
|
-
.
|
|
28
|
+
Image(systemName: "waveform")
|
|
29
|
+
.symbolEffect(.variableColor.iterative, isActive: true)
|
|
30
30
|
} else if engine.isTranscribing {
|
|
31
31
|
Image(systemName: "ellipsis.circle")
|
|
32
|
+
.symbolEffect(.pulse, isActive: true)
|
|
32
33
|
} else {
|
|
33
34
|
Image(systemName: "mic.fill")
|
|
34
35
|
}
|
|
@@ -28,11 +28,10 @@ public struct MenuBarPopover: View {
|
|
|
28
28
|
VStack(spacing: 0) {
|
|
29
29
|
header
|
|
30
30
|
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
|
31
|
-
Divider()
|
|
32
31
|
modePicker
|
|
33
|
-
.padding(.horizontal, 16).padding(.top,
|
|
32
|
+
.padding(.horizontal, 16).padding(.top, 2)
|
|
34
33
|
recordingArea
|
|
35
|
-
.padding(.horizontal, 16).padding(.top,
|
|
34
|
+
.padding(.horizontal, 16).padding(.top, 10).padding(.bottom, 12)
|
|
36
35
|
Divider()
|
|
37
36
|
if !engine.recentTranscriptions.isEmpty {
|
|
38
37
|
filterBar
|
|
@@ -44,6 +43,8 @@ public struct MenuBarPopover: View {
|
|
|
44
43
|
Divider()
|
|
45
44
|
footerMenu
|
|
46
45
|
}
|
|
46
|
+
.animation(.smooth(duration: 0.25), value: engine.isRecording)
|
|
47
|
+
.animation(.smooth(duration: 0.25), value: engine.isTranscribing)
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// MARK: - Header
|
|
@@ -54,16 +55,21 @@ public struct MenuBarPopover: View {
|
|
|
54
55
|
Image(systemName: "mic.fill")
|
|
55
56
|
.foregroundStyle(.tint)
|
|
56
57
|
Text("Hasna Recordings")
|
|
58
|
+
.font(.headline)
|
|
57
59
|
Spacer()
|
|
58
60
|
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
59
61
|
Text(shortcut.description)
|
|
62
|
+
.font(.caption)
|
|
60
63
|
.foregroundStyle(.secondary)
|
|
64
|
+
.padding(.horizontal, 7)
|
|
65
|
+
.padding(.vertical, 3)
|
|
66
|
+
.glassEffect(.regular, in: .capsule)
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
if !projectStore.settings.projects.isEmpty {
|
|
64
70
|
HStack(spacing: 4) {
|
|
65
71
|
Image(systemName: "folder.fill")
|
|
66
|
-
.
|
|
72
|
+
.foregroundStyle(projectStore.activeProject != nil ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
|
67
73
|
Menu {
|
|
68
74
|
Button("None") { projectStore.setActive(nil) }
|
|
69
75
|
Divider()
|
|
@@ -102,91 +108,103 @@ public struct MenuBarPopover: View {
|
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
.pickerStyle(.segmented)
|
|
111
|
+
.help(engine.mode.hint)
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
@ViewBuilder
|
|
108
115
|
private var recordingArea: some View {
|
|
109
116
|
if engine.isRecording {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.multilineTextAlignment(.leading)
|
|
125
|
-
.lineLimit(3)
|
|
126
|
-
.padding(.vertical, 4)
|
|
127
|
-
Text("Listening...")
|
|
128
|
-
.font(.caption)
|
|
129
|
-
.foregroundStyle(.green)
|
|
130
|
-
}
|
|
131
|
-
if let project = projectStore.activeProject {
|
|
132
|
-
HStack(spacing: 4) {
|
|
133
|
-
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
134
|
-
Text(project.name).foregroundStyle(.secondary)
|
|
135
|
-
Spacer()
|
|
136
|
-
}
|
|
137
|
-
}
|
|
117
|
+
VStack(spacing: 8) {
|
|
118
|
+
HStack {
|
|
119
|
+
Image(systemName: "waveform")
|
|
120
|
+
.symbolEffect(.variableColor.iterative, isActive: true)
|
|
121
|
+
.foregroundStyle(.red)
|
|
122
|
+
Text(fmt(engine.recordingDuration))
|
|
123
|
+
.monospacedDigit()
|
|
124
|
+
.contentTransition(.numericText())
|
|
125
|
+
.font(.title3.weight(.medium))
|
|
126
|
+
Spacer()
|
|
127
|
+
Button("Stop", systemImage: "stop.fill") { engine.stopAndTranscribe() }
|
|
128
|
+
.buttonStyle(.glassProminent)
|
|
129
|
+
.tint(.red)
|
|
130
|
+
.controlSize(.small)
|
|
138
131
|
}
|
|
139
|
-
.
|
|
140
|
-
|
|
132
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
133
|
+
Text(engine.liveTranscriptionText)
|
|
134
|
+
.font(.callout)
|
|
135
|
+
.foregroundStyle(.primary)
|
|
136
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
137
|
+
.lineLimit(3)
|
|
138
|
+
.contentTransition(.opacity)
|
|
139
|
+
} else {
|
|
140
|
+
Text("Listening…")
|
|
141
|
+
.font(.caption)
|
|
142
|
+
.foregroundStyle(.secondary)
|
|
143
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
144
|
+
}
|
|
145
|
+
if let project = projectStore.activeProject {
|
|
146
|
+
activeProjectTag(project)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
.padding(12)
|
|
150
|
+
.glassEffect(.regular.tint(.red.opacity(0.1)), in: .rect(cornerRadius: 14))
|
|
141
151
|
} else if engine.isTranscribing {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.lineLimit(3)
|
|
156
|
-
.padding(.vertical, 4)
|
|
157
|
-
}
|
|
158
|
-
if let project = projectStore.activeProject {
|
|
159
|
-
HStack(spacing: 4) {
|
|
160
|
-
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
161
|
-
Text(project.name).foregroundStyle(.secondary)
|
|
162
|
-
Spacer()
|
|
163
|
-
}
|
|
164
|
-
}
|
|
152
|
+
VStack(spacing: 8) {
|
|
153
|
+
HStack(spacing: 8) {
|
|
154
|
+
ProgressView().controlSize(.small)
|
|
155
|
+
Text("Transcribing…")
|
|
156
|
+
.foregroundStyle(.secondary)
|
|
157
|
+
Spacer()
|
|
158
|
+
}
|
|
159
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
160
|
+
Text(engine.liveTranscriptionText)
|
|
161
|
+
.font(.callout)
|
|
162
|
+
.foregroundStyle(.primary)
|
|
163
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
164
|
+
.lineLimit(3)
|
|
165
165
|
}
|
|
166
|
-
.
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
if let project = projectStore.activeProject {
|
|
167
|
+
activeProjectTag(project)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
.frame(maxWidth: .infinity)
|
|
171
|
+
.padding(12)
|
|
172
|
+
.glassEffect(.regular, in: .rect(cornerRadius: 14))
|
|
169
173
|
} else {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
VStack(spacing: 8) {
|
|
175
|
+
Button {
|
|
176
|
+
engine.startRecording()
|
|
177
|
+
} label: {
|
|
178
|
+
Label("Record", systemImage: "mic.fill")
|
|
179
|
+
.frame(maxWidth: .infinity)
|
|
180
|
+
}
|
|
181
|
+
.buttonStyle(.glassProminent)
|
|
182
|
+
.controlSize(.large)
|
|
175
183
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.foregroundStyle(.tertiary)
|
|
179
|
-
}
|
|
180
|
-
Text(engine.statusMessage)
|
|
184
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
185
|
+
Text("or hold \(shortcut.description)")
|
|
181
186
|
.font(.caption)
|
|
182
|
-
.foregroundStyle(
|
|
183
|
-
.multilineTextAlignment(.center)
|
|
184
|
-
.lineLimit(2)
|
|
187
|
+
.foregroundStyle(.tertiary)
|
|
185
188
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
Text(engine.statusMessage)
|
|
190
|
+
.font(.caption)
|
|
191
|
+
.foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
|
|
192
|
+
.multilineTextAlignment(.center)
|
|
193
|
+
.lineLimit(2)
|
|
194
|
+
}
|
|
195
|
+
.frame(maxWidth: .infinity)
|
|
196
|
+
.padding(12)
|
|
197
|
+
.glassEffect(.regular, in: .rect(cornerRadius: 14))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func activeProjectTag(_ project: RecProject) -> some View {
|
|
202
|
+
HStack(spacing: 4) {
|
|
203
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
204
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
205
|
+
Spacer()
|
|
189
206
|
}
|
|
207
|
+
.font(.caption)
|
|
190
208
|
}
|
|
191
209
|
|
|
192
210
|
// MARK: - Filter Bar
|
|
@@ -300,13 +318,16 @@ struct FilterChip: View {
|
|
|
300
318
|
var body: some View {
|
|
301
319
|
Button(action: action) {
|
|
302
320
|
Text(label)
|
|
303
|
-
.
|
|
304
|
-
.padding(.
|
|
305
|
-
.
|
|
306
|
-
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
321
|
+
.font(.caption)
|
|
322
|
+
.padding(.horizontal, 9)
|
|
323
|
+
.padding(.vertical, 4)
|
|
307
324
|
}
|
|
308
325
|
.buttonStyle(.plain)
|
|
309
326
|
.foregroundStyle(isActive ? .primary : .secondary)
|
|
327
|
+
.glassEffect(
|
|
328
|
+
isActive ? .regular.tint(.accentColor.opacity(0.25)).interactive() : .regular.interactive(),
|
|
329
|
+
in: .capsule
|
|
330
|
+
)
|
|
310
331
|
}
|
|
311
332
|
}
|
|
312
333
|
|
|
@@ -21,6 +21,32 @@ enum OpenAIAPIKeyStore {
|
|
|
21
21
|
return ""
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
|
|
25
|
+
/// shells out to for final transcription) uses the same key as the app itself.
|
|
26
|
+
static func save(key: String, homePath: String) throws {
|
|
27
|
+
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
28
|
+
let dir = URL(fileURLWithPath: homePath)
|
|
29
|
+
.appendingPathComponent(".hasna")
|
|
30
|
+
.appendingPathComponent("recordings")
|
|
31
|
+
let url = dir.appendingPathComponent("config.json")
|
|
32
|
+
|
|
33
|
+
var json: [String: Any] = [:]
|
|
34
|
+
if let data = try? Data(contentsOf: url),
|
|
35
|
+
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
36
|
+
json = existing
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if trimmed.isEmpty {
|
|
40
|
+
json.removeValue(forKey: "openai_api_key")
|
|
41
|
+
} else {
|
|
42
|
+
json["openai_api_key"] = trimmed
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
46
|
+
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
|
47
|
+
try data.write(to: url, options: .atomic)
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
|
|
25
51
|
let url = URL(fileURLWithPath: homePath)
|
|
26
52
|
.appendingPathComponent(".hasna")
|
|
@@ -166,6 +166,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
166
166
|
/// Returns the final accumulated transcription text.
|
|
167
167
|
@discardableResult
|
|
168
168
|
public func stop() -> String {
|
|
169
|
+
if isStreaming {
|
|
170
|
+
NativeAppLog.write(
|
|
171
|
+
"realtime session summary items=\(itemOrder.count) committed=\(committedItemIDs.count) completed=\(completedItemIDs.count) deltaChars=\(deltaTextByItem.values.map(\.count).reduce(0, +)) finalChars=\(accumulatedText.count) error=\(error ?? "none")",
|
|
172
|
+
homePath: homePath
|
|
173
|
+
)
|
|
174
|
+
}
|
|
169
175
|
isStreaming = false
|
|
170
176
|
isConfigured = false
|
|
171
177
|
receiveTask?.cancel()
|
|
@@ -256,7 +262,13 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
256
262
|
if let detail = json["error"] as? [String: Any],
|
|
257
263
|
let msg = detail["message"] as? String {
|
|
258
264
|
fputs("[RealtimeClient] Error: \(msg)\n", stderr)
|
|
259
|
-
|
|
265
|
+
// "buffer too small" on commit just means server VAD already consumed
|
|
266
|
+
// the audio — the transcript is intact, so don't surface it as a failure.
|
|
267
|
+
if msg.localizedCaseInsensitiveContains("buffer too small") {
|
|
268
|
+
NativeAppLog.write("realtime benign commit error: \(msg)", homePath: homePath)
|
|
269
|
+
} else {
|
|
270
|
+
self.error = msg
|
|
271
|
+
}
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
default:
|
|
@@ -411,7 +411,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
411
411
|
if let message = client.error, !message.isEmpty {
|
|
412
412
|
await MainActor.run {
|
|
413
413
|
self.log("realtime unavailable message=\(message)")
|
|
414
|
-
self.
|
|
414
|
+
if self.isRecording {
|
|
415
|
+
self.statusMessage = "Live preview unavailable — will transcribe after recording"
|
|
416
|
+
}
|
|
415
417
|
}
|
|
416
418
|
}
|
|
417
419
|
}
|
|
@@ -464,21 +466,33 @@ public final class RecordingEngine: ObservableObject {
|
|
|
464
466
|
self.liveTranscriptionText = ""
|
|
465
467
|
|
|
466
468
|
if let audioPath, self.writeCapturedWAV(to: audioPath) {
|
|
467
|
-
|
|
468
|
-
self.log("realtime preview ignored for final chars=\(realtimeText.count)")
|
|
469
|
-
}
|
|
470
|
-
self.log("transcribing captured full audio audioPath=\(audioPath)")
|
|
469
|
+
self.log("transcribing captured full audio audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
|
|
471
470
|
self.fallbackTranscribe(
|
|
472
471
|
audioPath: audioPath,
|
|
473
472
|
curMode: curMode,
|
|
474
473
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
475
474
|
targetAppPid: targetAppPid,
|
|
476
475
|
activeProjectId: activeProjectId,
|
|
477
|
-
activeProjectName: activeProjectName
|
|
476
|
+
activeProjectName: activeProjectName,
|
|
477
|
+
realtimeText: realtimeText
|
|
478
478
|
)
|
|
479
479
|
} else {
|
|
480
|
-
|
|
481
|
-
|
|
480
|
+
let resolved = Self.resolveFinalTranscript(cliText: nil, cliError: "No audio captured", realtimeText: realtimeText)
|
|
481
|
+
if let text = resolved.text {
|
|
482
|
+
self.log("no audio file written; using realtime transcript chars=\(text.count)")
|
|
483
|
+
self.isTranscribing = false
|
|
484
|
+
self.finishWithText(
|
|
485
|
+
text,
|
|
486
|
+
curMode: curMode,
|
|
487
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
488
|
+
targetAppPid: targetAppPid,
|
|
489
|
+
activeProjectId: activeProjectId,
|
|
490
|
+
activeProjectName: activeProjectName
|
|
491
|
+
)
|
|
492
|
+
} else {
|
|
493
|
+
self.log("no audio captured")
|
|
494
|
+
self.finish(resolved.failureStatus ?? "No audio captured")
|
|
495
|
+
}
|
|
482
496
|
}
|
|
483
497
|
|
|
484
498
|
self.activeAudioPath = nil
|
|
@@ -502,7 +516,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
502
516
|
|
|
503
517
|
let shortcutText = voiceShortcuts?.match(text)
|
|
504
518
|
let output = shortcutText ?? text
|
|
505
|
-
pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
|
|
519
|
+
pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid, restoreClipboard: true)
|
|
506
520
|
recentTranscriptions.insert(
|
|
507
521
|
TranscriptionResult(
|
|
508
522
|
rawText: text,
|
|
@@ -584,7 +598,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
584
598
|
|
|
585
599
|
// MARK: - Fallback Transcription
|
|
586
600
|
|
|
587
|
-
private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
|
|
601
|
+
private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?, realtimeText: String? = nil) {
|
|
588
602
|
let homePath = home
|
|
589
603
|
|
|
590
604
|
isTranscribing = true
|
|
@@ -592,25 +606,27 @@ public final class RecordingEngine: ObservableObject {
|
|
|
592
606
|
|
|
593
607
|
Task.detached {
|
|
594
608
|
let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
609
|
+
let cliError = CLIRunner.parseError(output)
|
|
610
|
+
let cliText = cliError == nil ? CLIRunner.parseJSON(output) : nil
|
|
611
|
+
|
|
612
|
+
await MainActor.run {
|
|
613
|
+
if let cliError {
|
|
614
|
+
self.log("cli transcription failed error=\(cliError)")
|
|
615
|
+
} else if cliText == nil {
|
|
616
|
+
self.log("cli transcription empty output=\(output.prefix(160))")
|
|
599
617
|
}
|
|
600
|
-
return
|
|
601
|
-
}
|
|
602
618
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
self.finish("Empty transcription")
|
|
619
|
+
let resolved = Self.resolveFinalTranscript(cliText: cliText, cliError: cliError, realtimeText: realtimeText)
|
|
620
|
+
guard let text = resolved.text else {
|
|
621
|
+
self.finish(resolved.failureStatus ?? "Transcription failed")
|
|
622
|
+
return
|
|
608
623
|
}
|
|
609
|
-
return
|
|
610
|
-
}
|
|
611
624
|
|
|
612
|
-
|
|
613
|
-
|
|
625
|
+
if cliText == nil {
|
|
626
|
+
self.log("using realtime transcript fallback chars=\(text.count)")
|
|
627
|
+
} else {
|
|
628
|
+
self.log("cli transcription succeeded chars=\(text.count)")
|
|
629
|
+
}
|
|
614
630
|
self.isTranscribing = false
|
|
615
631
|
self.finishWithText(
|
|
616
632
|
text,
|
|
@@ -707,9 +723,10 @@ public final class RecordingEngine: ObservableObject {
|
|
|
707
723
|
|
|
708
724
|
// MARK: - Paste
|
|
709
725
|
|
|
710
|
-
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil) {
|
|
726
|
+
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
|
|
711
727
|
log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
|
|
712
728
|
let pb = NSPasteboard.general
|
|
729
|
+
let previousClipboard = restoreClipboard ? pb.string(forType: .string) : nil
|
|
713
730
|
pb.clearContents()
|
|
714
731
|
pb.setString(text, forType: .string)
|
|
715
732
|
|
|
@@ -724,6 +741,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
724
741
|
|
|
725
742
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
726
743
|
let runningApps = NSWorkspace.shared.runningApplications
|
|
744
|
+
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
727
745
|
let candidates = runningApps.map {
|
|
728
746
|
PasteTargetCandidate(
|
|
729
747
|
pid: $0.processIdentifier,
|
|
@@ -735,7 +753,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
735
753
|
candidates: candidates,
|
|
736
754
|
currentPid: myPID,
|
|
737
755
|
targetBundleIdentifier: targetAppBundleIdentifier,
|
|
738
|
-
targetPid: targetAppPid
|
|
756
|
+
targetPid: targetAppPid,
|
|
757
|
+
frontmostPid: frontmostPid
|
|
739
758
|
)
|
|
740
759
|
let targetApp = selectedTarget.flatMap { selected in
|
|
741
760
|
runningApps.first { $0.processIdentifier == selected.pid }
|
|
@@ -748,9 +767,13 @@ public final class RecordingEngine: ObservableObject {
|
|
|
748
767
|
}
|
|
749
768
|
|
|
750
769
|
// Activate the exact app that owned focus when recording started, then paste after focus settles.
|
|
751
|
-
app.
|
|
770
|
+
let alreadyFrontmost = app.processIdentifier == frontmostPid
|
|
771
|
+
if !alreadyFrontmost {
|
|
772
|
+
app.activate(options: [.activateIgnoringOtherApps])
|
|
773
|
+
}
|
|
752
774
|
|
|
753
|
-
|
|
775
|
+
let pasteDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
|
|
776
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + pasteDelay) {
|
|
754
777
|
let src = CGEventSource(stateID: .hidSystemState)
|
|
755
778
|
if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
|
|
756
779
|
let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
|
|
@@ -759,8 +782,19 @@ public final class RecordingEngine: ObservableObject {
|
|
|
759
782
|
down.post(tap: .cgSessionEventTap)
|
|
760
783
|
up.post(tap: .cgSessionEventTap)
|
|
761
784
|
}
|
|
762
|
-
self.log("paste event posted")
|
|
785
|
+
self.log("paste event posted target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost)")
|
|
763
786
|
self.statusMessage = "Pasted (\(text.count) chars)"
|
|
787
|
+
|
|
788
|
+
if let previousClipboard {
|
|
789
|
+
// Give the target app time to consume the paste, then hand the clipboard back.
|
|
790
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
|
791
|
+
let pb = NSPasteboard.general
|
|
792
|
+
if pb.string(forType: .string) == text {
|
|
793
|
+
pb.clearContents()
|
|
794
|
+
pb.setString(previousClipboard, forType: .string)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
764
798
|
}
|
|
765
799
|
}
|
|
766
800
|
|
|
@@ -768,7 +802,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
768
802
|
candidates: [PasteTargetCandidate],
|
|
769
803
|
currentPid: pid_t,
|
|
770
804
|
targetBundleIdentifier: String?,
|
|
771
|
-
targetPid: pid_t
|
|
805
|
+
targetPid: pid_t?,
|
|
806
|
+
frontmostPid: pid_t? = nil
|
|
772
807
|
) -> PasteTargetCandidate? {
|
|
773
808
|
candidates.first {
|
|
774
809
|
guard let targetPid else { return false }
|
|
@@ -777,11 +812,28 @@ public final class RecordingEngine: ObservableObject {
|
|
|
777
812
|
guard $0.pid != currentPid, $0.isRegularApp else { return false }
|
|
778
813
|
guard let targetBundleIdentifier else { return false }
|
|
779
814
|
return $0.bundleIdentifier == targetBundleIdentifier
|
|
815
|
+
} ?? candidates.first {
|
|
816
|
+
guard let frontmostPid else { return false }
|
|
817
|
+
return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
|
|
780
818
|
} ?? candidates.first {
|
|
781
819
|
$0.isRegularApp && $0.pid != currentPid
|
|
782
820
|
}
|
|
783
821
|
}
|
|
784
822
|
|
|
823
|
+
nonisolated static func resolveFinalTranscript(
|
|
824
|
+
cliText: String?,
|
|
825
|
+
cliError: String?,
|
|
826
|
+
realtimeText: String?
|
|
827
|
+
) -> (text: String?, failureStatus: String?) {
|
|
828
|
+
if let cliText, !cliText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
829
|
+
return (cliText, nil)
|
|
830
|
+
}
|
|
831
|
+
if let realtimeText, !realtimeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
832
|
+
return (realtimeText, nil)
|
|
833
|
+
}
|
|
834
|
+
return (nil, cliError ?? "Empty transcription")
|
|
835
|
+
}
|
|
836
|
+
|
|
785
837
|
private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
|
|
786
838
|
if AXIsProcessTrusted() {
|
|
787
839
|
return true
|
|
@@ -861,6 +913,15 @@ enum CLIRunner: Sendable {
|
|
|
861
913
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
862
914
|
guard trimmed.hasPrefix("ERROR:") else { return nil }
|
|
863
915
|
let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
916
|
+
let lowercased = message.lowercased()
|
|
917
|
+
if lowercased.contains("401") || lowercased.contains("incorrect api key")
|
|
918
|
+
|| lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
|
|
919
|
+
return "OpenAI API key invalid or expired — update it in Recordings Settings"
|
|
920
|
+
}
|
|
921
|
+
if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
|
|
922
|
+
|| lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
|
|
923
|
+
return "OpenAI quota exceeded — check the OpenAI account billing"
|
|
924
|
+
}
|
|
864
925
|
if message.contains("OpenAI API key not configured") {
|
|
865
926
|
return "OpenAI API key not configured on this Mac"
|
|
866
927
|
}
|
|
@@ -29,7 +29,11 @@ public struct SettingsView: View {
|
|
|
29
29
|
Section("OpenAI") {
|
|
30
30
|
SecureField("API key", text: $openAIAPIKey)
|
|
31
31
|
.textFieldStyle(.roundedBorder)
|
|
32
|
-
|
|
32
|
+
.onChange(of: openAIAPIKey) {
|
|
33
|
+
// Keep the CLI config in sync — final transcription shells out to it.
|
|
34
|
+
try? OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
|
|
35
|
+
}
|
|
36
|
+
Text("Used for live transcription and the final paste. Stored in ~/.hasna/recordings/config.json.")
|
|
33
37
|
.foregroundStyle(.secondary)
|
|
34
38
|
}
|
|
35
39
|
|