@hasna/recordings 0.1.14 → 0.1.16

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/cli/index.js CHANGED
@@ -7,7 +7,7 @@ var __require = import.meta.require;
7
7
  var require_package = __commonJS((exports, module) => {
8
8
  module.exports = {
9
9
  name: "@hasna/recordings",
10
- version: "0.1.14",
10
+ version: "0.1.16",
11
11
  type: "module",
12
12
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
13
13
  repository: {
@@ -10466,7 +10466,7 @@ async function processText(rawText, config, systemPrompt) {
10466
10466
  }
10467
10467
 
10468
10468
  // src/version.ts
10469
- var VERSION = "0.1.14";
10469
+ var VERSION = "0.1.16";
10470
10470
 
10471
10471
  // src/cli/index.ts
10472
10472
  var program = new Command;
package/dist/mcp/index.js CHANGED
@@ -21,7 +21,7 @@ var __require = import.meta.require;
21
21
  var require_package = __commonJS((exports, module) => {
22
22
  module.exports = {
23
23
  name: "@hasna/recordings",
24
- version: "0.1.14",
24
+ version: "0.1.16",
25
25
  type: "module",
26
26
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
27
27
  repository: {
@@ -14983,7 +14983,7 @@ async function processText(rawText, config, systemPrompt) {
14983
14983
  }
14984
14984
 
14985
14985
  // src/version.ts
14986
- var VERSION = "0.1.14";
14986
+ var VERSION = "0.1.16";
14987
14987
 
14988
14988
  // src/mcp/index.ts
14989
14989
  var config = loadConfig();
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.14";
1
+ export declare const VERSION = "0.1.16";
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.14",
3
+ "version": "0.1.16",
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": {
@@ -16,12 +16,24 @@ 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>?
20
+ private var liveCommitTask: Task<Void, Never>?
19
21
  private var isConfigured = false
20
22
  private var pendingAudioChunks: [Data] = []
21
23
  private var itemOrder: [String] = []
22
24
  private var deltaTextByItem: [String: String] = [:]
23
25
  private var completedTextByItem: [String: String] = [:]
26
+ private var committedItemIDs = Set<String>()
27
+ private var completedItemIDs = Set<String>()
24
28
  private var completedEventCount = 0
29
+ private var queuedCommitCount = 0
30
+ private var uncommittedAudioBytes = 0
31
+ private var lastRealtimeEventAt = Date.distantPast
32
+ private var lastCommitQueuedAt = Date.distantPast
33
+
34
+ private nonisolated static let minimumManualCommitBytes = 5_760
35
+ private nonisolated static let minimumLiveCommitBytes = 38_400
36
+ private nonisolated static let liveCommitIntervalSeconds: TimeInterval = 0.8
25
37
 
26
38
  private let apiKey: String
27
39
  private let homePath: String
@@ -52,7 +64,13 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
52
64
  itemOrder.removeAll(keepingCapacity: true)
53
65
  deltaTextByItem.removeAll(keepingCapacity: true)
54
66
  completedTextByItem.removeAll(keepingCapacity: true)
67
+ committedItemIDs.removeAll(keepingCapacity: true)
68
+ completedItemIDs.removeAll(keepingCapacity: true)
55
69
  completedEventCount = 0
70
+ queuedCommitCount = 0
71
+ uncommittedAudioBytes = 0
72
+ lastRealtimeEventAt = Date()
73
+ lastCommitQueuedAt = Date()
56
74
  error = nil
57
75
 
58
76
  var request = URLRequest(url: Self.transcriptionURL)
@@ -98,36 +116,53 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
98
116
  return
99
117
  }
100
118
  let base64 = data.base64EncodedString()
119
+ uncommittedAudioBytes += data.count
101
120
  let msg: [String: Any] = [
102
121
  "type": "input_audio_buffer.append",
103
122
  "audio": base64,
104
123
  ]
105
- Task { [weak self] in
106
- try? await self?.sendEvent(msg)
107
- }
124
+ enqueueOutboundEvent(msg)
125
+ scheduleLiveCommitIfNeeded()
108
126
  }
109
127
 
110
128
  /// Signal end of input — triggers final transcription completion.
111
- public func commitInput() async {
112
- guard isStreaming, ws != nil else { return }
129
+ @discardableResult
130
+ public func commitInput(reason: String = "final") async -> Bool {
131
+ guard isStreaming, ws != nil else { return false }
113
132
  flushPendingAudio()
133
+ let bytesToCommit = uncommittedAudioBytes
134
+ guard Self.shouldManuallyCommit(uncommittedAudioBytes: bytesToCommit) else {
135
+ return false
136
+ }
114
137
  let msg: [String: Any] = [
115
138
  "type": "input_audio_buffer.commit",
116
139
  ]
117
- try? await sendEvent(msg)
140
+ uncommittedAudioBytes = 0
141
+ queuedCommitCount += 1
142
+ lastRealtimeEventAt = Date()
143
+ lastCommitQueuedAt = Date()
144
+ NativeAppLog.write("realtime commit queued reason=\(reason) bytes=\(bytesToCommit)", homePath: homePath)
145
+ let commitTask = enqueueOutboundEvent(msg)
146
+ await commitTask?.value
147
+ return true
118
148
  }
119
149
 
120
150
  /// Commit buffered input, wait briefly for a final completed event, then close.
121
- public func finish(timeoutMilliseconds: UInt64 = 1_800) async -> String {
151
+ public func finish(timeoutMilliseconds: UInt64 = 2_800) async -> String {
122
152
  guard isStreaming else { return accumulatedText }
123
- let initialCompletedCount = completedEventCount
124
- if initialCompletedCount > 0 {
125
- return stop()
126
- }
127
- await commitInput()
153
+ let completedCountBeforeCommit = completedEventCount
154
+ let didManualCommit = await commitInput()
155
+ let expectedCommitCount = queuedCommitCount
128
156
 
129
157
  let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
130
- while Date() < deadline, isStreaming, completedEventCount == initialCompletedCount {
158
+ while Date() < deadline, isStreaming {
159
+ let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
160
+ let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
161
+ let hasQueuedCommitCompletion = completedEventCount >= expectedCommitCount
162
+ let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
163
+ if !hasIncompleteCommittedItems, hasManualCommitCompletion, hasQueuedCommitCompletion, quietLongEnough {
164
+ break
165
+ }
131
166
  try? await Task.sleep(for: .milliseconds(50))
132
167
  }
133
168
 
@@ -142,8 +177,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
142
177
  isConfigured = false
143
178
  receiveTask?.cancel()
144
179
  ws?.cancel(with: .normalClosure, reason: nil)
180
+ outboundEventTask?.cancel()
181
+ liveCommitTask?.cancel()
145
182
  ws = nil
146
183
  receiveTask = nil
184
+ outboundEventTask = nil
185
+ liveCommitTask = nil
147
186
  pendingAudioChunks.removeAll(keepingCapacity: true)
148
187
  let text = accumulatedText
149
188
  return text
@@ -183,11 +222,14 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
183
222
 
184
223
  switch type {
185
224
  case "input_audio_buffer.committed":
225
+ lastRealtimeEventAt = Date()
186
226
  if let itemID = json["item_id"] as? String {
187
227
  registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
228
+ committedItemIDs.insert(itemID)
188
229
  }
189
230
 
190
231
  case "conversation.item.input_audio_transcription.delta":
232
+ lastRealtimeEventAt = Date()
191
233
  let itemID = json["item_id"] as? String ?? "__default__"
192
234
  registerItem(itemID, previousItemID: nil)
193
235
  if let delta = json["delta"] as? String {
@@ -197,21 +239,29 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
197
239
 
198
240
  case "conversation.item.input_audio_transcription.completed":
199
241
  // The server may send the complete transcript here
242
+ lastRealtimeEventAt = Date()
200
243
  let itemID = json["item_id"] as? String ?? "__default__"
201
244
  registerItem(itemID, previousItemID: nil)
245
+ completedItemIDs.insert(itemID)
246
+ completedEventCount += 1
202
247
  if let text = json["transcript"] as? String, !text.isEmpty {
203
248
  completedTextByItem[itemID] = text
204
- completedEventCount += 1
205
- rebuildAccumulatedText()
206
249
  }
250
+ rebuildAccumulatedText()
207
251
 
208
252
  case "conversation.item.input_audio_transcription.failed":
253
+ lastRealtimeEventAt = Date()
254
+ if let itemID = json["item_id"] as? String {
255
+ completedItemIDs.insert(itemID)
256
+ completedEventCount += 1
257
+ }
209
258
  if let msg = json["error"] as? [String: Any],
210
259
  let message = msg["message"] as? String {
211
260
  self.error = message
212
261
  }
213
262
 
214
263
  case "error":
264
+ lastRealtimeEventAt = Date()
215
265
  if let detail = json["error"] as? [String: Any],
216
266
  let msg = detail["message"] as? String {
217
267
  fputs("[RealtimeClient] Error: \(msg)\n", stderr)
@@ -223,6 +273,37 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
223
273
  }
224
274
  }
225
275
 
276
+ @discardableResult
277
+ private func enqueueOutboundEvent(_ obj: [String: Any]) -> Task<Void, Never>? {
278
+ guard ws != nil else { return nil }
279
+ let previousTask = outboundEventTask
280
+ let task = Task { [weak self] in
281
+ await previousTask?.value
282
+ try? await self?.sendEvent(obj)
283
+ }
284
+ outboundEventTask = task
285
+ return task
286
+ }
287
+
288
+ private func scheduleLiveCommitIfNeeded(now: Date = Date()) {
289
+ guard liveCommitTask == nil else { return }
290
+ let secondsSinceLastCommit = now.timeIntervalSince(lastCommitQueuedAt)
291
+ guard Self.shouldAutoCommitLiveInput(
292
+ uncommittedAudioBytes: uncommittedAudioBytes,
293
+ secondsSinceLastCommit: secondsSinceLastCommit
294
+ ) else { return }
295
+
296
+ liveCommitTask = Task { [weak self] in
297
+ _ = await self?.commitLiveInput()
298
+ }
299
+ }
300
+
301
+ @discardableResult
302
+ private func commitLiveInput() async -> Bool {
303
+ defer { liveCommitTask = nil }
304
+ return await commitInput(reason: "live")
305
+ }
306
+
226
307
  private func registerItem(_ itemID: String, previousItemID: String?) {
227
308
  guard !itemOrder.contains(itemID) else { return }
228
309
  if let previousItemID, let previousIndex = itemOrder.firstIndex(of: previousItemID) {
@@ -298,6 +379,18 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
298
379
  return output
299
380
  }
300
381
 
382
+ private nonisolated static func shouldManuallyCommit(uncommittedAudioBytes: Int) -> Bool {
383
+ uncommittedAudioBytes >= minimumManualCommitBytes
384
+ }
385
+
386
+ private nonisolated static func shouldAutoCommitLiveInput(
387
+ uncommittedAudioBytes: Int,
388
+ secondsSinceLastCommit: TimeInterval
389
+ ) -> Bool {
390
+ uncommittedAudioBytes >= minimumLiveCommitBytes &&
391
+ secondsSinceLastCommit >= liveCommitIntervalSeconds
392
+ }
393
+
301
394
  private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
302
395
  [
303
396
  "type": "session.update",
@@ -310,12 +403,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
310
403
  "rate": 24_000,
311
404
  ],
312
405
  "transcription": transcription,
313
- "turn_detection": [
314
- "type": "server_vad",
315
- "threshold": 0.5,
316
- "prefix_padding_ms": 300,
317
- "silence_duration_ms": 350,
318
- ],
406
+ "turn_detection": NSNull(),
319
407
  "noise_reduction": [
320
408
  "type": "near_field",
321
409
  ],
@@ -366,6 +454,20 @@ extension RealtimeTranscriptionClient {
366
454
  joinTranscriptParts(parts)
367
455
  }
368
456
 
457
+ public nonisolated static func shouldManuallyCommitTestHelper(uncommittedAudioBytes: Int) -> Bool {
458
+ shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
459
+ }
460
+
461
+ public nonisolated static func shouldAutoCommitLiveInputTestHelper(
462
+ uncommittedAudioBytes: Int,
463
+ secondsSinceLastCommit: TimeInterval
464
+ ) -> Bool {
465
+ shouldAutoCommitLiveInput(
466
+ uncommittedAudioBytes: uncommittedAudioBytes,
467
+ secondsSinceLastCommit: secondsSinceLastCommit
468
+ )
469
+ }
470
+
369
471
  public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
370
472
  var transcription: [String: Any] = [
371
473
  "model": modelID,
@@ -457,7 +457,7 @@ public final class RecordingEngine: ObservableObject {
457
457
 
458
458
  self.liveTranscriptionText = ""
459
459
 
460
- if let text {
460
+ if let text, !Self.shouldFallbackFromPartialRealtime(text: text, pcmByteCount: self.recordedPCM.count) {
461
461
  self.log("finish using realtime text chars=\(text.count)")
462
462
  self.isTranscribing = false
463
463
  self.finishWithText(
@@ -468,6 +468,9 @@ public final class RecordingEngine: ObservableObject {
468
468
  activeProjectName: activeProjectName
469
469
  )
470
470
  } else if let audioPath, self.writeCapturedWAV(to: audioPath) {
471
+ if let text {
472
+ self.log("realtime text looked partial chars=\(text.count); falling back to CLI")
473
+ }
471
474
  self.log("falling back to CLI transcription audioPath=\(audioPath)")
472
475
  self.fallbackTranscribe(
473
476
  audioPath: audioPath,
@@ -486,6 +489,13 @@ public final class RecordingEngine: ObservableObject {
486
489
  }
487
490
  }
488
491
 
492
+ public nonisolated static func shouldFallbackFromPartialRealtime(text: String, pcmByteCount: Int) -> Bool {
493
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
494
+ guard pcmByteCount >= 48_000, !trimmed.isEmpty else { return false }
495
+ let words = trimmed.split(whereSeparator: { $0.isWhitespace || $0.isNewline })
496
+ return trimmed.count < 12 || words.count <= 2
497
+ }
498
+
489
499
  private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
490
500
  log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
491
501
  if curMode == .command {
@@ -1,3 +1,4 @@
1
+ import Foundation
1
2
  import Testing
2
3
  @testable import RecordingsLib
3
4
 
@@ -98,8 +99,7 @@ struct RealtimeTranscriptionTests {
98
99
  #expect(transcription?["prompt"] as? String == "Use Alumia as vocabulary")
99
100
  #expect(transcription?["language"] as? String == "en")
100
101
 
101
- let turnDetection = input?["turn_detection"] as? [String: Any]
102
- #expect(turnDetection?["type"] as? String == "server_vad")
102
+ #expect(input?["turn_detection"] is NSNull)
103
103
 
104
104
  let include = session?["include"] as? [String]
105
105
  #expect(include?.contains("item.input_audio_transcription.logprobs") == true)
@@ -110,4 +110,33 @@ 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("Live commits wait for enough audio and elapsed time")
121
+ func liveCommitThreshold() {
122
+ #expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
123
+ uncommittedAudioBytes: 38_399,
124
+ secondsSinceLastCommit: 1.0
125
+ ) == false)
126
+ #expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
127
+ uncommittedAudioBytes: 38_400,
128
+ secondsSinceLastCommit: 0.79
129
+ ) == false)
130
+ #expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
131
+ uncommittedAudioBytes: 38_400,
132
+ secondsSinceLastCommit: 0.8
133
+ ) == true)
134
+ }
135
+
136
+ @Test("Partial realtime text falls back for longer recordings")
137
+ func partialRealtimeFallback() {
138
+ #expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)
139
+ #expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "This is a complete sentence.", pcmByteCount: 96_000) == false)
140
+ #expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
141
+ }
113
142
  }