@hasna/recordings 0.1.15 → 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.15",
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.15";
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.15",
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.15";
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.15";
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.15",
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,7 +16,8 @@ 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
+ private var outboundEventTask: Task<Void, Never>?
20
+ private var liveCommitTask: Task<Void, Never>?
20
21
  private var isConfigured = false
21
22
  private var pendingAudioChunks: [Data] = []
22
23
  private var itemOrder: [String] = []
@@ -25,10 +26,14 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
25
26
  private var committedItemIDs = Set<String>()
26
27
  private var completedItemIDs = Set<String>()
27
28
  private var completedEventCount = 0
29
+ private var queuedCommitCount = 0
28
30
  private var uncommittedAudioBytes = 0
29
31
  private var lastRealtimeEventAt = Date.distantPast
32
+ private var lastCommitQueuedAt = Date.distantPast
30
33
 
31
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
32
37
 
33
38
  private let apiKey: String
34
39
  private let homePath: String
@@ -62,8 +67,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
62
67
  committedItemIDs.removeAll(keepingCapacity: true)
63
68
  completedItemIDs.removeAll(keepingCapacity: true)
64
69
  completedEventCount = 0
70
+ queuedCommitCount = 0
65
71
  uncommittedAudioBytes = 0
66
72
  lastRealtimeEventAt = Date()
73
+ lastCommitQueuedAt = Date()
67
74
  error = nil
68
75
 
69
76
  var request = URLRequest(url: Self.transcriptionURL)
@@ -114,27 +121,29 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
114
121
  "type": "input_audio_buffer.append",
115
122
  "audio": base64,
116
123
  ]
117
- let previousSendTask = audioSendTask
118
- audioSendTask = Task { [weak self] in
119
- await previousSendTask?.value
120
- try? await self?.sendEvent(msg)
121
- }
124
+ enqueueOutboundEvent(msg)
125
+ scheduleLiveCommitIfNeeded()
122
126
  }
123
127
 
124
128
  /// Signal end of input — triggers final transcription completion.
125
129
  @discardableResult
126
- public func commitInput() async -> Bool {
130
+ public func commitInput(reason: String = "final") async -> Bool {
127
131
  guard isStreaming, ws != nil else { return false }
128
132
  flushPendingAudio()
129
- await audioSendTask?.value
130
- guard Self.shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes) else {
133
+ let bytesToCommit = uncommittedAudioBytes
134
+ guard Self.shouldManuallyCommit(uncommittedAudioBytes: bytesToCommit) else {
131
135
  return false
132
136
  }
133
137
  let msg: [String: Any] = [
134
138
  "type": "input_audio_buffer.commit",
135
139
  ]
140
+ uncommittedAudioBytes = 0
141
+ queuedCommitCount += 1
136
142
  lastRealtimeEventAt = Date()
137
- try? await sendEvent(msg)
143
+ lastCommitQueuedAt = Date()
144
+ NativeAppLog.write("realtime commit queued reason=\(reason) bytes=\(bytesToCommit)", homePath: homePath)
145
+ let commitTask = enqueueOutboundEvent(msg)
146
+ await commitTask?.value
138
147
  return true
139
148
  }
140
149
 
@@ -143,13 +152,15 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
143
152
  guard isStreaming else { return accumulatedText }
144
153
  let completedCountBeforeCommit = completedEventCount
145
154
  let didManualCommit = await commitInput()
155
+ let expectedCommitCount = queuedCommitCount
146
156
 
147
157
  let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
148
158
  while Date() < deadline, isStreaming {
149
159
  let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
150
160
  let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
161
+ let hasQueuedCommitCompletion = completedEventCount >= expectedCommitCount
151
162
  let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
152
- if !hasIncompleteCommittedItems, hasManualCommitCompletion, quietLongEnough {
163
+ if !hasIncompleteCommittedItems, hasManualCommitCompletion, hasQueuedCommitCompletion, quietLongEnough {
153
164
  break
154
165
  }
155
166
  try? await Task.sleep(for: .milliseconds(50))
@@ -166,10 +177,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
166
177
  isConfigured = false
167
178
  receiveTask?.cancel()
168
179
  ws?.cancel(with: .normalClosure, reason: nil)
169
- audioSendTask?.cancel()
180
+ outboundEventTask?.cancel()
181
+ liveCommitTask?.cancel()
170
182
  ws = nil
171
183
  receiveTask = nil
172
- audioSendTask = nil
184
+ outboundEventTask = nil
185
+ liveCommitTask = nil
173
186
  pendingAudioChunks.removeAll(keepingCapacity: true)
174
187
  let text = accumulatedText
175
188
  return text
@@ -214,7 +227,6 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
214
227
  registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
215
228
  committedItemIDs.insert(itemID)
216
229
  }
217
- uncommittedAudioBytes = 0
218
230
 
219
231
  case "conversation.item.input_audio_transcription.delta":
220
232
  lastRealtimeEventAt = Date()
@@ -230,15 +242,19 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
230
242
  lastRealtimeEventAt = Date()
231
243
  let itemID = json["item_id"] as? String ?? "__default__"
232
244
  registerItem(itemID, previousItemID: nil)
245
+ completedItemIDs.insert(itemID)
246
+ completedEventCount += 1
233
247
  if let text = json["transcript"] as? String, !text.isEmpty {
234
248
  completedTextByItem[itemID] = text
235
- completedItemIDs.insert(itemID)
236
- completedEventCount += 1
237
- rebuildAccumulatedText()
238
249
  }
250
+ rebuildAccumulatedText()
239
251
 
240
252
  case "conversation.item.input_audio_transcription.failed":
241
253
  lastRealtimeEventAt = Date()
254
+ if let itemID = json["item_id"] as? String {
255
+ completedItemIDs.insert(itemID)
256
+ completedEventCount += 1
257
+ }
242
258
  if let msg = json["error"] as? [String: Any],
243
259
  let message = msg["message"] as? String {
244
260
  self.error = message
@@ -257,6 +273,37 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
257
273
  }
258
274
  }
259
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
+
260
307
  private func registerItem(_ itemID: String, previousItemID: String?) {
261
308
  guard !itemOrder.contains(itemID) else { return }
262
309
  if let previousItemID, let previousIndex = itemOrder.firstIndex(of: previousItemID) {
@@ -336,6 +383,14 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
336
383
  uncommittedAudioBytes >= minimumManualCommitBytes
337
384
  }
338
385
 
386
+ private nonisolated static func shouldAutoCommitLiveInput(
387
+ uncommittedAudioBytes: Int,
388
+ secondsSinceLastCommit: TimeInterval
389
+ ) -> Bool {
390
+ uncommittedAudioBytes >= minimumLiveCommitBytes &&
391
+ secondsSinceLastCommit >= liveCommitIntervalSeconds
392
+ }
393
+
339
394
  private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
340
395
  [
341
396
  "type": "session.update",
@@ -348,12 +403,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
348
403
  "rate": 24_000,
349
404
  ],
350
405
  "transcription": transcription,
351
- "turn_detection": [
352
- "type": "server_vad",
353
- "threshold": 0.5,
354
- "prefix_padding_ms": 300,
355
- "silence_duration_ms": 350,
356
- ],
406
+ "turn_detection": NSNull(),
357
407
  "noise_reduction": [
358
408
  "type": "near_field",
359
409
  ],
@@ -408,6 +458,16 @@ extension RealtimeTranscriptionClient {
408
458
  shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
409
459
  }
410
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
+
411
471
  public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
412
472
  var transcription: [String: Any] = [
413
473
  "model": modelID,
@@ -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)
@@ -117,6 +117,22 @@ struct RealtimeTranscriptionTests {
117
117
  #expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 5_760) == true)
118
118
  }
119
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
+
120
136
  @Test("Partial realtime text falls back for longer recordings")
121
137
  func partialRealtimeFallback() {
122
138
  #expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)