@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/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.11";
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.20",
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
- Task { [weak self] in
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
- public func commitInput() async {
112
- guard isStreaming, ws != nil else { return }
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
- try? await sendEvent(msg)
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 = 1_800) async -> String {
144
+ public func finish(timeoutMilliseconds: UInt64 = 2_800) async -> String {
122
145
  guard isStreaming else { return accumulatedText }
123
- let initialCompletedCount = completedEventCount
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, completedEventCount == initialCompletedCount {
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
  }