@hasna/recordings 0.1.14 → 0.1.15

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.15",
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.15";
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.15",
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.15";
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.15";
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.15",
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,19 @@ 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 audioSendTask: 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 uncommittedAudioBytes = 0
29
+ private var lastRealtimeEventAt = Date.distantPast
30
+
31
+ private nonisolated static let minimumManualCommitBytes = 5_760
25
32
 
26
33
  private let apiKey: String
27
34
  private let homePath: String
@@ -52,7 +59,11 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
52
59
  itemOrder.removeAll(keepingCapacity: true)
53
60
  deltaTextByItem.removeAll(keepingCapacity: true)
54
61
  completedTextByItem.removeAll(keepingCapacity: true)
62
+ committedItemIDs.removeAll(keepingCapacity: true)
63
+ completedItemIDs.removeAll(keepingCapacity: true)
55
64
  completedEventCount = 0
65
+ uncommittedAudioBytes = 0
66
+ lastRealtimeEventAt = Date()
56
67
  error = nil
57
68
 
58
69
  var request = URLRequest(url: Self.transcriptionURL)
@@ -98,36 +109,49 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
98
109
  return
99
110
  }
100
111
  let base64 = data.base64EncodedString()
112
+ uncommittedAudioBytes += data.count
101
113
  let msg: [String: Any] = [
102
114
  "type": "input_audio_buffer.append",
103
115
  "audio": base64,
104
116
  ]
105
- Task { [weak self] in
117
+ let previousSendTask = audioSendTask
118
+ audioSendTask = Task { [weak self] in
119
+ await previousSendTask?.value
106
120
  try? await self?.sendEvent(msg)
107
121
  }
108
122
  }
109
123
 
110
124
  /// Signal end of input — triggers final transcription completion.
111
- public func commitInput() async {
112
- guard isStreaming, ws != nil else { return }
125
+ @discardableResult
126
+ public func commitInput() async -> Bool {
127
+ guard isStreaming, ws != nil else { return false }
113
128
  flushPendingAudio()
129
+ await audioSendTask?.value
130
+ guard Self.shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes) else {
131
+ return false
132
+ }
114
133
  let msg: [String: Any] = [
115
134
  "type": "input_audio_buffer.commit",
116
135
  ]
136
+ lastRealtimeEventAt = Date()
117
137
  try? await sendEvent(msg)
138
+ return true
118
139
  }
119
140
 
120
141
  /// Commit buffered input, wait briefly for a final completed event, then close.
121
- public func finish(timeoutMilliseconds: UInt64 = 1_800) async -> String {
142
+ public func finish(timeoutMilliseconds: UInt64 = 2_800) async -> String {
122
143
  guard isStreaming else { return accumulatedText }
123
- let initialCompletedCount = completedEventCount
124
- if initialCompletedCount > 0 {
125
- return stop()
126
- }
127
- await commitInput()
144
+ let completedCountBeforeCommit = completedEventCount
145
+ let didManualCommit = await commitInput()
128
146
 
129
147
  let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
130
- while Date() < deadline, isStreaming, completedEventCount == initialCompletedCount {
148
+ while Date() < deadline, isStreaming {
149
+ let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
150
+ let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
151
+ let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
152
+ if !hasIncompleteCommittedItems, hasManualCommitCompletion, quietLongEnough {
153
+ break
154
+ }
131
155
  try? await Task.sleep(for: .milliseconds(50))
132
156
  }
133
157
 
@@ -142,8 +166,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
142
166
  isConfigured = false
143
167
  receiveTask?.cancel()
144
168
  ws?.cancel(with: .normalClosure, reason: nil)
169
+ audioSendTask?.cancel()
145
170
  ws = nil
146
171
  receiveTask = nil
172
+ audioSendTask = nil
147
173
  pendingAudioChunks.removeAll(keepingCapacity: true)
148
174
  let text = accumulatedText
149
175
  return text
@@ -183,11 +209,15 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
183
209
 
184
210
  switch type {
185
211
  case "input_audio_buffer.committed":
212
+ lastRealtimeEventAt = Date()
186
213
  if let itemID = json["item_id"] as? String {
187
214
  registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
215
+ committedItemIDs.insert(itemID)
188
216
  }
217
+ uncommittedAudioBytes = 0
189
218
 
190
219
  case "conversation.item.input_audio_transcription.delta":
220
+ lastRealtimeEventAt = Date()
191
221
  let itemID = json["item_id"] as? String ?? "__default__"
192
222
  registerItem(itemID, previousItemID: nil)
193
223
  if let delta = json["delta"] as? String {
@@ -197,21 +227,25 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
197
227
 
198
228
  case "conversation.item.input_audio_transcription.completed":
199
229
  // The server may send the complete transcript here
230
+ lastRealtimeEventAt = Date()
200
231
  let itemID = json["item_id"] as? String ?? "__default__"
201
232
  registerItem(itemID, previousItemID: nil)
202
233
  if let text = json["transcript"] as? String, !text.isEmpty {
203
234
  completedTextByItem[itemID] = text
235
+ completedItemIDs.insert(itemID)
204
236
  completedEventCount += 1
205
237
  rebuildAccumulatedText()
206
238
  }
207
239
 
208
240
  case "conversation.item.input_audio_transcription.failed":
241
+ lastRealtimeEventAt = Date()
209
242
  if let msg = json["error"] as? [String: Any],
210
243
  let message = msg["message"] as? String {
211
244
  self.error = message
212
245
  }
213
246
 
214
247
  case "error":
248
+ lastRealtimeEventAt = Date()
215
249
  if let detail = json["error"] as? [String: Any],
216
250
  let msg = detail["message"] as? String {
217
251
  fputs("[RealtimeClient] Error: \(msg)\n", stderr)
@@ -298,6 +332,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
298
332
  return output
299
333
  }
300
334
 
335
+ private nonisolated static func shouldManuallyCommit(uncommittedAudioBytes: Int) -> Bool {
336
+ uncommittedAudioBytes >= minimumManualCommitBytes
337
+ }
338
+
301
339
  private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
302
340
  [
303
341
  "type": "session.update",
@@ -366,6 +404,10 @@ extension RealtimeTranscriptionClient {
366
404
  joinTranscriptParts(parts)
367
405
  }
368
406
 
407
+ public nonisolated static func shouldManuallyCommitTestHelper(uncommittedAudioBytes: Int) -> Bool {
408
+ shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
409
+ }
410
+
369
411
  public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
370
412
  var transcription: [String: Any] = [
371
413
  "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 {
@@ -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
  }