@hasna/recordings 0.1.20 → 0.1.21
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/LICENSE +191 -170
- package/README.md +8 -1
- package/dist/cli/index.js +155 -4
- package/dist/db/database.d.ts.map +1 -1
- package/dist/index.js +26 -1
- package/dist/mcp/http.d.ts +12 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +2 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +444 -354
- package/dist/version.d.ts +1 -1
- package/package.json +5 -1
- package/scripts/install_macos_app.sh +32 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +2 -3
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +76 -12
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +27 -1
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +23 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +13 -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.21";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/recordings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
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
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hasna/recordings.git"
|
|
9
|
+
},
|
|
6
10
|
"main": "dist/index.js",
|
|
7
11
|
"types": "dist/index.d.ts",
|
|
8
12
|
"bin": {
|
|
@@ -73,4 +73,36 @@ rm -rf "$APP_DEST"
|
|
|
73
73
|
mkdir -p "$DATA_DIR"
|
|
74
74
|
cp -R "$APP_SOURCE" "$APP_DEST" || warn_or_fail "failed to copy app bundle"
|
|
75
75
|
|
|
76
|
+
current_cdhash() {
|
|
77
|
+
codesign -d --verbose=4 "$1" 2>&1 | awk -F= '/^CDHash=/ { print toupper($2); exit }'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
tcc_csreq_hex() {
|
|
81
|
+
local db_path="$1"
|
|
82
|
+
local service="$2"
|
|
83
|
+
if [ ! -r "$db_path" ] || ! command -v sqlite3 >/dev/null 2>&1; then
|
|
84
|
+
return 0
|
|
85
|
+
fi
|
|
86
|
+
sqlite3 "$db_path" \
|
|
87
|
+
"SELECT hex(csreq) FROM access WHERE service = '${service}' AND client = 'com.hasna.recordings' ORDER BY last_modified DESC LIMIT 1;" \
|
|
88
|
+
2>/dev/null || true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
reset_stale_permission() {
|
|
92
|
+
local service="$1"
|
|
93
|
+
local tcc_service="$2"
|
|
94
|
+
local db_path="$3"
|
|
95
|
+
local cdhash="$4"
|
|
96
|
+
local csreq_hex
|
|
97
|
+
csreq_hex="$(tcc_csreq_hex "$db_path" "$tcc_service" | tr '[:lower:]' '[:upper:]')"
|
|
98
|
+
if [ -n "$cdhash" ] && [ -n "$csreq_hex" ] && [[ "$csreq_hex" != *"$cdhash"* ]]; then
|
|
99
|
+
tccutil reset "$service" com.hasna.recordings >/dev/null 2>&1 || true
|
|
100
|
+
echo "Reset stale ${service} permission for the newly installed Recordings.app."
|
|
101
|
+
fi
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
|
|
105
|
+
reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
106
|
+
reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
107
|
+
|
|
76
108
|
echo "Installed Recordings.app from package: ${APP_DEST}"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
|
-
import KeyboardShortcuts
|
|
2
|
+
@preconcurrency import KeyboardShortcuts
|
|
3
3
|
|
|
4
4
|
public struct MenuBarPopover: View {
|
|
5
5
|
@ObservedObject public var engine: RecordingEngine
|
|
@@ -237,11 +237,10 @@ public struct MenuBarPopover: View {
|
|
|
237
237
|
showProject: filterProjectId == nil,
|
|
238
238
|
isCopied: copiedIndex == i
|
|
239
239
|
) {
|
|
240
|
-
NSPasteboard.general.clearContents()
|
|
241
|
-
NSPasteboard.general.setString(item.displayText, forType: .string)
|
|
242
240
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
243
241
|
copiedIndex = i
|
|
244
242
|
}
|
|
243
|
+
engine.pasteIntoFrontApp(item.displayText)
|
|
245
244
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
246
245
|
withAnimation { if copiedIndex == i { copiedIndex = nil } }
|
|
247
246
|
}
|
|
@@ -16,12 +16,20 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
16
16
|
|
|
17
17
|
private var ws: URLSessionWebSocketTask?
|
|
18
18
|
private var receiveTask: Task<Void, Never>?
|
|
19
|
+
private var outboundEventTask: Task<Void, Never>?
|
|
19
20
|
private var isConfigured = false
|
|
20
21
|
private var pendingAudioChunks: [Data] = []
|
|
21
22
|
private var itemOrder: [String] = []
|
|
22
23
|
private var deltaTextByItem: [String: String] = [:]
|
|
23
24
|
private var completedTextByItem: [String: String] = [:]
|
|
25
|
+
private var committedItemIDs = Set<String>()
|
|
26
|
+
private var completedItemIDs = Set<String>()
|
|
24
27
|
private var completedEventCount = 0
|
|
28
|
+
private var queuedCommitCount = 0
|
|
29
|
+
private var uncommittedAudioBytes = 0
|
|
30
|
+
private var lastRealtimeEventAt = Date.distantPast
|
|
31
|
+
|
|
32
|
+
private nonisolated static let minimumManualCommitBytes = 5_760
|
|
25
33
|
|
|
26
34
|
private let apiKey: String
|
|
27
35
|
private let homePath: String
|
|
@@ -52,7 +60,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
52
60
|
itemOrder.removeAll(keepingCapacity: true)
|
|
53
61
|
deltaTextByItem.removeAll(keepingCapacity: true)
|
|
54
62
|
completedTextByItem.removeAll(keepingCapacity: true)
|
|
63
|
+
committedItemIDs.removeAll(keepingCapacity: true)
|
|
64
|
+
completedItemIDs.removeAll(keepingCapacity: true)
|
|
55
65
|
completedEventCount = 0
|
|
66
|
+
queuedCommitCount = 0
|
|
67
|
+
uncommittedAudioBytes = 0
|
|
68
|
+
lastRealtimeEventAt = Date()
|
|
56
69
|
error = nil
|
|
57
70
|
|
|
58
71
|
var request = URLRequest(url: Self.transcriptionURL)
|
|
@@ -98,33 +111,51 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
98
111
|
return
|
|
99
112
|
}
|
|
100
113
|
let base64 = data.base64EncodedString()
|
|
114
|
+
uncommittedAudioBytes += data.count
|
|
101
115
|
let msg: [String: Any] = [
|
|
102
116
|
"type": "input_audio_buffer.append",
|
|
103
117
|
"audio": base64,
|
|
104
118
|
]
|
|
105
|
-
|
|
106
|
-
try? await self?.sendEvent(msg)
|
|
107
|
-
}
|
|
119
|
+
enqueueOutboundEvent(msg)
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
/// Signal end of input — triggers final transcription completion.
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
@discardableResult
|
|
124
|
+
public func commitInput(reason: String = "final") async -> Bool {
|
|
125
|
+
guard isStreaming, ws != nil else { return false }
|
|
113
126
|
flushPendingAudio()
|
|
127
|
+
let bytesToCommit = uncommittedAudioBytes
|
|
128
|
+
guard Self.shouldManuallyCommit(uncommittedAudioBytes: bytesToCommit) else {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
114
131
|
let msg: [String: Any] = [
|
|
115
132
|
"type": "input_audio_buffer.commit",
|
|
116
133
|
]
|
|
117
|
-
|
|
134
|
+
uncommittedAudioBytes = 0
|
|
135
|
+
queuedCommitCount += 1
|
|
136
|
+
lastRealtimeEventAt = Date()
|
|
137
|
+
NativeAppLog.write("realtime commit queued reason=\(reason) bytes=\(bytesToCommit)", homePath: homePath)
|
|
138
|
+
let commitTask = enqueueOutboundEvent(msg)
|
|
139
|
+
await commitTask?.value
|
|
140
|
+
return true
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
/// Commit buffered input, wait briefly for a final completed event, then close.
|
|
121
|
-
public func finish(timeoutMilliseconds: UInt64 =
|
|
144
|
+
public func finish(timeoutMilliseconds: UInt64 = 2_800) async -> String {
|
|
122
145
|
guard isStreaming else { return accumulatedText }
|
|
123
|
-
let
|
|
124
|
-
await commitInput()
|
|
146
|
+
let completedCountBeforeCommit = completedEventCount
|
|
147
|
+
let didManualCommit = await commitInput()
|
|
148
|
+
let expectedCommitCount = queuedCommitCount
|
|
125
149
|
|
|
126
150
|
let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
|
|
127
|
-
while Date() < deadline, isStreaming
|
|
151
|
+
while Date() < deadline, isStreaming {
|
|
152
|
+
let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
|
|
153
|
+
let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
|
|
154
|
+
let hasQueuedCommitCompletion = completedEventCount >= expectedCommitCount
|
|
155
|
+
let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
|
|
156
|
+
if !hasIncompleteCommittedItems, hasManualCommitCompletion, hasQueuedCommitCompletion, quietLongEnough {
|
|
157
|
+
break
|
|
158
|
+
}
|
|
128
159
|
try? await Task.sleep(for: .milliseconds(50))
|
|
129
160
|
}
|
|
130
161
|
|
|
@@ -139,8 +170,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
139
170
|
isConfigured = false
|
|
140
171
|
receiveTask?.cancel()
|
|
141
172
|
ws?.cancel(with: .normalClosure, reason: nil)
|
|
173
|
+
outboundEventTask?.cancel()
|
|
142
174
|
ws = nil
|
|
143
175
|
receiveTask = nil
|
|
176
|
+
outboundEventTask = nil
|
|
144
177
|
pendingAudioChunks.removeAll(keepingCapacity: true)
|
|
145
178
|
let text = accumulatedText
|
|
146
179
|
return text
|
|
@@ -180,11 +213,14 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
180
213
|
|
|
181
214
|
switch type {
|
|
182
215
|
case "input_audio_buffer.committed":
|
|
216
|
+
lastRealtimeEventAt = Date()
|
|
183
217
|
if let itemID = json["item_id"] as? String {
|
|
184
218
|
registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
|
|
219
|
+
committedItemIDs.insert(itemID)
|
|
185
220
|
}
|
|
186
221
|
|
|
187
222
|
case "conversation.item.input_audio_transcription.delta":
|
|
223
|
+
lastRealtimeEventAt = Date()
|
|
188
224
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
189
225
|
registerItem(itemID, previousItemID: nil)
|
|
190
226
|
if let delta = json["delta"] as? String {
|
|
@@ -194,21 +230,29 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
194
230
|
|
|
195
231
|
case "conversation.item.input_audio_transcription.completed":
|
|
196
232
|
// The server may send the complete transcript here
|
|
233
|
+
lastRealtimeEventAt = Date()
|
|
197
234
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
198
235
|
registerItem(itemID, previousItemID: nil)
|
|
236
|
+
completedItemIDs.insert(itemID)
|
|
237
|
+
completedEventCount += 1
|
|
199
238
|
if let text = json["transcript"] as? String, !text.isEmpty {
|
|
200
239
|
completedTextByItem[itemID] = text
|
|
201
|
-
completedEventCount += 1
|
|
202
|
-
rebuildAccumulatedText()
|
|
203
240
|
}
|
|
241
|
+
rebuildAccumulatedText()
|
|
204
242
|
|
|
205
243
|
case "conversation.item.input_audio_transcription.failed":
|
|
244
|
+
lastRealtimeEventAt = Date()
|
|
245
|
+
if let itemID = json["item_id"] as? String {
|
|
246
|
+
completedItemIDs.insert(itemID)
|
|
247
|
+
completedEventCount += 1
|
|
248
|
+
}
|
|
206
249
|
if let msg = json["error"] as? [String: Any],
|
|
207
250
|
let message = msg["message"] as? String {
|
|
208
251
|
self.error = message
|
|
209
252
|
}
|
|
210
253
|
|
|
211
254
|
case "error":
|
|
255
|
+
lastRealtimeEventAt = Date()
|
|
212
256
|
if let detail = json["error"] as? [String: Any],
|
|
213
257
|
let msg = detail["message"] as? String {
|
|
214
258
|
fputs("[RealtimeClient] Error: \(msg)\n", stderr)
|
|
@@ -220,6 +264,18 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
220
264
|
}
|
|
221
265
|
}
|
|
222
266
|
|
|
267
|
+
@discardableResult
|
|
268
|
+
private func enqueueOutboundEvent(_ obj: [String: Any]) -> Task<Void, Never>? {
|
|
269
|
+
guard ws != nil else { return nil }
|
|
270
|
+
let previousTask = outboundEventTask
|
|
271
|
+
let task = Task { [weak self] in
|
|
272
|
+
await previousTask?.value
|
|
273
|
+
try? await self?.sendEvent(obj)
|
|
274
|
+
}
|
|
275
|
+
outboundEventTask = task
|
|
276
|
+
return task
|
|
277
|
+
}
|
|
278
|
+
|
|
223
279
|
private func registerItem(_ itemID: String, previousItemID: String?) {
|
|
224
280
|
guard !itemOrder.contains(itemID) else { return }
|
|
225
281
|
if let previousItemID, let previousIndex = itemOrder.firstIndex(of: previousItemID) {
|
|
@@ -295,6 +351,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
295
351
|
return output
|
|
296
352
|
}
|
|
297
353
|
|
|
354
|
+
private nonisolated static func shouldManuallyCommit(uncommittedAudioBytes: Int) -> Bool {
|
|
355
|
+
uncommittedAudioBytes >= minimumManualCommitBytes
|
|
356
|
+
}
|
|
357
|
+
|
|
298
358
|
private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
|
|
299
359
|
[
|
|
300
360
|
"type": "session.update",
|
|
@@ -363,6 +423,10 @@ extension RealtimeTranscriptionClient {
|
|
|
363
423
|
joinTranscriptParts(parts)
|
|
364
424
|
}
|
|
365
425
|
|
|
426
|
+
public nonisolated static func shouldManuallyCommitTestHelper(uncommittedAudioBytes: Int) -> Bool {
|
|
427
|
+
shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
|
|
428
|
+
}
|
|
429
|
+
|
|
366
430
|
public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
|
|
367
431
|
var transcription: [String: Any] = [
|
|
368
432
|
"model": modelID,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
|
-
import KeyboardShortcuts
|
|
2
|
+
@preconcurrency import KeyboardShortcuts
|
|
3
3
|
|
|
4
4
|
public struct SettingsView: View {
|
|
5
5
|
@ObservedObject public var engine: RecordingEngine
|
|
@@ -45,6 +45,32 @@ public struct SettingsView: View {
|
|
|
45
45
|
.foregroundStyle(.secondary)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
Section("Permissions") {
|
|
49
|
+
HStack {
|
|
50
|
+
Text("Microphone")
|
|
51
|
+
Spacer()
|
|
52
|
+
Text(engine.microphonePermissionLabel)
|
|
53
|
+
.foregroundStyle(.secondary)
|
|
54
|
+
}
|
|
55
|
+
Button("Request Microphone") {
|
|
56
|
+
engine.requestMicrophonePermission()
|
|
57
|
+
}
|
|
58
|
+
HStack {
|
|
59
|
+
Text("Accessibility")
|
|
60
|
+
Spacer()
|
|
61
|
+
Text(engine.accessibilityPermissionLabel)
|
|
62
|
+
.foregroundStyle(.secondary)
|
|
63
|
+
}
|
|
64
|
+
HStack {
|
|
65
|
+
Button("Request Accessibility") {
|
|
66
|
+
engine.requestAccessibilityPermission()
|
|
67
|
+
}
|
|
68
|
+
Button("Open Accessibility Settings") {
|
|
69
|
+
engine.openAccessibilitySettings()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
Section("System Prompt") {
|
|
49
75
|
TextEditor(text: $projectStore.settings.globalSystemPrompt)
|
|
50
76
|
.frame(height: 80)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import RecordingsLib
|
|
4
|
+
|
|
5
|
+
struct NativeAppDiagnosticsTests {
|
|
6
|
+
@Test("Native app log writes to recordings log file")
|
|
7
|
+
func logWritesFile() throws {
|
|
8
|
+
let home = try makeHome()
|
|
9
|
+
|
|
10
|
+
NativeAppLog.write("diagnostic-test", homePath: home)
|
|
11
|
+
|
|
12
|
+
let path = "\(home)/.hasna/recordings/Recordings.log"
|
|
13
|
+
let text = try String(contentsOfFile: path, encoding: .utf8)
|
|
14
|
+
#expect(text.contains("diagnostic-test"))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private func makeHome() throws -> String {
|
|
18
|
+
let url = FileManager.default.temporaryDirectory
|
|
19
|
+
.appendingPathComponent("recordings-diagnostics-tests-\(UUID().uuidString)")
|
|
20
|
+
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
21
|
+
return url.path
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -110,4 +110,17 @@ struct RealtimeTranscriptionTests {
|
|
|
110
110
|
let text = RealtimeTranscriptionClient.joinTranscriptPartsTestHelper(["Hello", "world.", " Next"])
|
|
111
111
|
#expect(text == "Hello world. Next")
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
@Test("Manual commit waits for enough buffered PCM audio")
|
|
115
|
+
func manualCommitThreshold() {
|
|
116
|
+
#expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 4_799) == false)
|
|
117
|
+
#expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 5_760) == true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Test("Partial realtime text falls back for longer recordings")
|
|
121
|
+
func partialRealtimeFallback() {
|
|
122
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)
|
|
123
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "This is a complete sentence.", pcmByteCount: 96_000) == false)
|
|
124
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
|
|
125
|
+
}
|
|
113
126
|
}
|