@hasna/recordings 0.2.11 → 0.2.13

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.
Files changed (167) hide show
  1. package/README.md +349 -69
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1919 -98
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1973 -236
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -2,12 +2,209 @@ import Foundation
2
2
 
3
3
  // MARK: - OpenAI Realtime Transcription Client
4
4
 
5
+ public struct RealtimeFinishResult: Sendable {
6
+ public let text: String
7
+ public let settled: Bool
8
+ public let error: String?
9
+ }
10
+
11
+ enum RealtimeCommitTerminalStatus: Equatable, Sendable {
12
+ case completed
13
+ case failed
14
+ }
15
+
16
+ enum RealtimeCommitSettlementResolution: Equatable, Sendable {
17
+ case waiting
18
+ case settled
19
+ case failed
20
+ }
21
+
22
+ struct RealtimeCommitSettlementTracker: Sendable {
23
+ struct Commit: Equatable, Sendable {
24
+ let sequence: Int
25
+ var itemID: String?
26
+ var isFinal: Bool
27
+ }
28
+
29
+ private(set) var commits: [Commit] = []
30
+ private(set) var terminalStatusByItemID: [String: RealtimeCommitTerminalStatus] = [:]
31
+ private var pendingAcknowledgementSequences: [Int] = []
32
+ private var nextSequence = 0
33
+ private(set) var finalSequence: Int?
34
+
35
+ @discardableResult
36
+ mutating func queueCommit(isFinal: Bool) -> Int {
37
+ let sequence = nextSequence
38
+ nextSequence += 1
39
+ commits.append(Commit(sequence: sequence, itemID: nil, isFinal: isFinal))
40
+ pendingAcknowledgementSequences.append(sequence)
41
+ if isFinal { finalSequence = sequence }
42
+ return sequence
43
+ }
44
+
45
+ mutating func markLatestCommitFinal() -> Bool {
46
+ guard let latest = commits.indices.last else { return false }
47
+ for index in commits.indices { commits[index].isFinal = false }
48
+ commits[latest].isFinal = true
49
+ finalSequence = commits[latest].sequence
50
+ return true
51
+ }
52
+
53
+ @discardableResult
54
+ mutating func acknowledge(itemID: String) -> Commit? {
55
+ if let existing = commits.first(where: { $0.itemID == itemID }) {
56
+ return existing
57
+ }
58
+ guard !pendingAcknowledgementSequences.isEmpty else { return nil }
59
+ let sequence = pendingAcknowledgementSequences.removeFirst()
60
+ guard let index = commits.firstIndex(where: { $0.sequence == sequence }) else { return nil }
61
+ commits[index].itemID = itemID
62
+ return commits[index]
63
+ }
64
+
65
+ mutating func complete(itemID: String) {
66
+ if terminalStatusByItemID[itemID] != .failed {
67
+ terminalStatusByItemID[itemID] = .completed
68
+ }
69
+ }
70
+
71
+ mutating func fail(itemID: String) {
72
+ terminalStatusByItemID[itemID] = .failed
73
+ }
74
+
75
+ func resolution(uncommittedRealAudioBytes: Int) -> RealtimeCommitSettlementResolution {
76
+ guard uncommittedRealAudioBytes == 0,
77
+ let finalSequence,
78
+ let finalIndex = commits.firstIndex(where: { $0.sequence == finalSequence })
79
+ else { return .waiting }
80
+
81
+ let requiredCommits = commits[...finalIndex]
82
+ for commit in requiredCommits {
83
+ guard let itemID = commit.itemID,
84
+ let status = terminalStatusByItemID[itemID]
85
+ else { return .waiting }
86
+ if status == .failed { return .failed }
87
+ }
88
+ return .settled
89
+ }
90
+
91
+ var finalItemID: String? {
92
+ guard let finalSequence else { return nil }
93
+ return commits.first(where: { $0.sequence == finalSequence })?.itemID
94
+ }
95
+ }
96
+
97
+ struct RealtimeFinalCommitPlan: Equatable, Sendable {
98
+ let realAudioByteCount: Int
99
+ let paddingByteCount: Int
100
+
101
+ var committedAudioByteCount: Int { realAudioByteCount + paddingByteCount }
102
+ var realtimePadding: Data { Data(repeating: 0, count: paddingByteCount) }
103
+ }
104
+
105
+ struct RealtimePendingAudioBuffer: Sendable {
106
+ private let maximumByteCount: Int
107
+ private var chunks: [Data] = []
108
+ private(set) var byteCount = 0
109
+
110
+ init(maximumByteCount: Int) {
111
+ precondition(maximumByteCount > 0)
112
+ self.maximumByteCount = maximumByteCount
113
+ }
114
+
115
+ var chunkCount: Int { chunks.count }
116
+
117
+ mutating func append(_ data: Data) -> Bool {
118
+ guard data.count <= maximumByteCount - byteCount else { return false }
119
+ chunks.append(data)
120
+ byteCount += data.count
121
+ return true
122
+ }
123
+
124
+ mutating func drain() -> [Data] {
125
+ let drained = chunks
126
+ chunks.removeAll(keepingCapacity: true)
127
+ byteCount = 0
128
+ return drained
129
+ }
130
+
131
+ mutating func removeAll() {
132
+ chunks.removeAll(keepingCapacity: true)
133
+ byteCount = 0
134
+ }
135
+ }
136
+
137
+ enum RealtimeOutboundPayload: Equatable, Sendable {
138
+ case audio(Data)
139
+ case event(String)
140
+
141
+ var byteCount: Int {
142
+ switch self {
143
+ case .audio(let data): data.count
144
+ case .event(let text): text.utf8.count
145
+ }
146
+ }
147
+ }
148
+
149
+ struct RealtimeQueuedOutboundEvent: Equatable, Sendable {
150
+ let sequence: Int
151
+ let payload: RealtimeOutboundPayload
152
+ }
153
+
154
+ struct RealtimeOutboundEventBuffer: Sendable {
155
+ private let maximumByteCount: Int
156
+ private let maximumEventCount: Int
157
+ private var events: [RealtimeQueuedOutboundEvent] = []
158
+ private(set) var byteCount = 0
159
+
160
+ init(maximumByteCount: Int, maximumEventCount: Int) {
161
+ precondition(maximumByteCount > 0)
162
+ precondition(maximumEventCount > 0)
163
+ self.maximumByteCount = maximumByteCount
164
+ self.maximumEventCount = maximumEventCount
165
+ }
166
+
167
+ var eventCount: Int { events.count }
168
+
169
+ mutating func append(_ payload: RealtimeOutboundPayload, sequence: Int) -> Bool {
170
+ guard events.count < maximumEventCount,
171
+ payload.byteCount <= maximumByteCount - byteCount else { return false }
172
+ events.append(RealtimeQueuedOutboundEvent(sequence: sequence, payload: payload))
173
+ byteCount += payload.byteCount
174
+ return true
175
+ }
176
+
177
+ mutating func popFirst() -> RealtimeQueuedOutboundEvent? {
178
+ guard !events.isEmpty else { return nil }
179
+ let event = events.removeFirst()
180
+ byteCount -= event.payload.byteCount
181
+ return event
182
+ }
183
+
184
+ mutating func removeAll() {
185
+ events.removeAll(keepingCapacity: true)
186
+ byteCount = 0
187
+ }
188
+ }
189
+
190
+ private struct RealtimeSendTimeout: LocalizedError {
191
+ var errorDescription: String? { "Realtime send timed out" }
192
+ }
193
+
194
+ @MainActor
195
+ private final class RealtimeOutboundOperationState {
196
+ var result: Result<Void, Error>?
197
+ }
198
+
5
199
  /// Streams PCM audio to OpenAI's Realtime Transcription API via WebSocket.
6
200
  /// Receives transcription deltas in real time.
7
201
  @MainActor
8
202
  public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sendable {
203
+ /// Realtime session model slot. Transcription-only models do not belong in the URL.
204
+ public nonisolated static let sessionModelID = "gpt-realtime"
9
205
  /// Low-latency model for realtime transcript deltas.
10
- public nonisolated static let modelID = "gpt-realtime-whisper"
206
+ public nonisolated static let transcriptionModelID = "gpt-realtime-whisper"
207
+ public nonisolated static let modelID = transcriptionModelID
11
208
  public nonisolated static let transcriptionDelay = "low"
12
209
  private nonisolated static let transcriptionURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")!
13
210
 
@@ -17,18 +214,24 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
17
214
 
18
215
  private var ws: URLSessionWebSocketTask?
19
216
  private var receiveTask: Task<Void, Never>?
20
- private var outboundEventTask: Task<Void, Never>?
217
+ private var outboundDrainTask: Task<Void, Never>?
218
+ private var outboundEvents = RealtimeOutboundEventBuffer(
219
+ maximumByteCount: 262_144,
220
+ maximumEventCount: 128
221
+ )
222
+ private var nextOutboundSequence = 0
223
+ private var completedOutboundSequence: Int?
21
224
  private var isConfigured = false
22
- private var pendingAudioChunks: [Data] = []
225
+ private var pendingAudio = RealtimePendingAudioBuffer(maximumByteCount: 1_228_800)
23
226
  private var itemOrder: [String] = []
24
227
  private var deltaTextByItem: [String: String] = [:]
25
228
  private var completedTextByItem: [String: String] = [:]
26
- private var committedItemIDs = Set<String>()
27
- private var completedItemIDs = Set<String>()
28
- private var completedEventCount = 0
29
- private var queuedCommitCount = 0
229
+ private var settlementTracker = RealtimeCommitSettlementTracker()
30
230
  private var uncommittedAudioBytes = 0
31
- private var lastRealtimeEventAt = Date.distantPast
231
+ private var uncommittedRealAudioBytes = 0
232
+ private var finishPipelineID: String?
233
+ private var finishPipelineStartedUptimeMilliseconds: UInt64?
234
+ private var transportFailure: String?
32
235
 
33
236
  private nonisolated static let minimumManualCommitBytes = 5_760
34
237
 
@@ -43,11 +246,8 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
43
246
  // MARK: - Public API
44
247
 
45
248
  /// Start a streaming transcription session.
46
- /// - Parameters:
47
- /// - systemPrompt: Optional system prompt for the transcription
48
- /// - audioFormat: Audio format. Defaults to pcm16 at 24kHz (OpenAI's preferred format).
49
249
  /// - Returns: The client is now streaming. Call `sendAudio(_:)` to send chunks.
50
- public func startStreaming(systemPrompt: String = "", language: String = "") async {
250
+ public func startStreaming(language: String = "") async {
51
251
  guard !apiKey.isEmpty else {
52
252
  self.error = "OpenAI API key not configured"
53
253
  return
@@ -57,16 +257,19 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
57
257
  isStreaming = true
58
258
  isConfigured = false
59
259
  accumulatedText = ""
60
- pendingAudioChunks.removeAll(keepingCapacity: true)
260
+ pendingAudio.removeAll()
261
+ outboundEvents.removeAll()
262
+ nextOutboundSequence = 0
263
+ completedOutboundSequence = nil
61
264
  itemOrder.removeAll(keepingCapacity: true)
62
265
  deltaTextByItem.removeAll(keepingCapacity: true)
63
266
  completedTextByItem.removeAll(keepingCapacity: true)
64
- committedItemIDs.removeAll(keepingCapacity: true)
65
- completedItemIDs.removeAll(keepingCapacity: true)
66
- completedEventCount = 0
67
- queuedCommitCount = 0
267
+ settlementTracker = RealtimeCommitSettlementTracker()
68
268
  uncommittedAudioBytes = 0
69
- lastRealtimeEventAt = Date()
269
+ uncommittedRealAudioBytes = 0
270
+ finishPipelineID = nil
271
+ finishPipelineStartedUptimeMilliseconds = nil
272
+ transportFailure = nil
70
273
  error = nil
71
274
 
72
275
  var request = URLRequest(url: Self.transcriptionURL)
@@ -95,7 +298,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
95
298
  isConfigured = true
96
299
  flushPendingAudio()
97
300
  } catch {
98
- self.error = "Failed to configure session: \(error.localizedDescription)"
301
+ self.error = Self.safeError("Failed to configure session: \(error.localizedDescription)")
99
302
  stop()
100
303
  return
101
304
  }
@@ -103,64 +306,101 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
103
306
 
104
307
  /// Send a chunk of PCM audio data to the transcription session.
105
308
  public func sendAudio(_ data: Data) {
106
- guard isStreaming, !data.isEmpty else { return }
309
+ queueAudio(data, isRealAudio: true)
310
+ }
311
+
312
+ private func queueAudio(_ data: Data, isRealAudio: Bool) {
313
+ guard isStreaming, transportFailure == nil, !data.isEmpty else { return }
107
314
  guard ws != nil, isConfigured else {
108
- pendingAudioChunks.append(data)
109
- if pendingAudioChunks.count > 256 {
110
- pendingAudioChunks.removeFirst(pendingAudioChunks.count - 256)
315
+ if isRealAudio, !pendingAudio.append(data) {
316
+ recordTransportFailure(
317
+ "Realtime setup took too long; full audio will be transcribed after recording"
318
+ )
111
319
  }
112
320
  return
113
321
  }
114
- let base64 = data.base64EncodedString()
322
+ guard enqueueOutboundPayload(.audio(data)) != nil else { return }
115
323
  uncommittedAudioBytes += data.count
116
- let msg: [String: Any] = [
117
- "type": "input_audio_buffer.append",
118
- "audio": base64,
119
- ]
120
- enqueueOutboundEvent(msg)
324
+ if isRealAudio { uncommittedRealAudioBytes += data.count }
121
325
  }
122
326
 
123
327
  /// Signal end of input — triggers final transcription completion.
124
328
  @discardableResult
125
329
  public func commitInput(reason: String = "final") async -> Bool {
126
- guard isStreaming, ws != nil else { return false }
330
+ guard isStreaming, ws != nil, transportFailure == nil else { return false }
127
331
  flushPendingAudio()
128
- let bytesToCommit = uncommittedAudioBytes
129
- guard Self.shouldManuallyCommit(uncommittedAudioBytes: bytesToCommit) else {
332
+
333
+ let isFinal = reason == "final"
334
+ let realBytesToCommit = uncommittedRealAudioBytes
335
+ if isFinal, realBytesToCommit == 0 {
336
+ let reusedLatestCommit = settlementTracker.markLatestCommitFinal()
337
+ if reusedLatestCommit {
338
+ logFinishStage(
339
+ "final_commit_reused",
340
+ detail: "item_id=\(settlementTracker.finalItemID ?? "pending")"
341
+ )
342
+ }
343
+ return reusedLatestCommit
344
+ }
345
+
346
+ let plan = Self.finalCommitPlan(realAudioByteCount: realBytesToCommit)
347
+ if isFinal, plan.paddingByteCount > 0 {
348
+ queueAudio(plan.realtimePadding, isRealAudio: false)
349
+ } else if !isFinal, !Self.shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes) {
130
350
  return false
131
351
  }
352
+
353
+ let bytesToCommit = uncommittedAudioBytes
132
354
  let msg: [String: Any] = [
133
355
  "type": "input_audio_buffer.commit",
134
356
  ]
135
357
  uncommittedAudioBytes = 0
136
- queuedCommitCount += 1
137
- lastRealtimeEventAt = Date()
138
- NativeAppLog.write("realtime commit queued reason=\(reason) bytes=\(bytesToCommit)", homePath: homePath)
139
- let commitTask = enqueueOutboundEvent(msg)
140
- await commitTask?.value
141
- return true
358
+ uncommittedRealAudioBytes = 0
359
+ let sequence = settlementTracker.queueCommit(isFinal: isFinal)
360
+ NativeAppLog.write(
361
+ "realtime commit queued reason=\(reason) sequence=\(sequence) real_bytes=\(realBytesToCommit) padding_bytes=\(plan.paddingByteCount) committed_bytes=\(bytesToCommit)",
362
+ homePath: homePath
363
+ )
364
+ if isFinal { logFinishStage("final_commit_queued") }
365
+ guard let commitSequence = enqueueOutboundPayload(.event(encodeJSON(msg))) else {
366
+ return false
367
+ }
368
+ return await waitForOutboundSequence(commitSequence)
142
369
  }
143
370
 
144
371
  /// Commit buffered input, wait briefly for a final completed event, then close.
145
- public func finish(timeoutMilliseconds: UInt64 = 700) async -> String {
146
- guard isStreaming else { return accumulatedText }
147
- let completedCountBeforeCommit = completedEventCount
148
- let didManualCommit = await commitInput()
149
- let expectedCommitCount = queuedCommitCount
150
-
151
- let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
152
- while Date() < deadline, isStreaming {
153
- let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
154
- let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
155
- let hasQueuedCommitCompletion = completedEventCount >= expectedCommitCount
156
- let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
157
- if !hasIncompleteCommittedItems, hasManualCommitCompletion, hasQueuedCommitCompletion, quietLongEnough {
158
- break
159
- }
160
- try? await Task.sleep(for: .milliseconds(50))
372
+ public func finish(
373
+ timeoutMilliseconds: UInt64 = 700,
374
+ pipelineID: String? = nil,
375
+ pipelineStartedUptimeMilliseconds: UInt64? = nil
376
+ ) async -> RealtimeFinishResult {
377
+ guard isStreaming else {
378
+ return RealtimeFinishResult(text: accumulatedText, settled: true, error: error)
161
379
  }
162
-
163
- return stop()
380
+ finishPipelineID = pipelineID
381
+ finishPipelineStartedUptimeMilliseconds = pipelineStartedUptimeMilliseconds
382
+ let waitResult = await Self.waitForSettlementWhileCommitRuns(
383
+ timeoutMilliseconds: timeoutMilliseconds,
384
+ beginCommit: { [weak self] in
385
+ await self?.commitInput() ?? false
386
+ },
387
+ resolution: { [weak self] in
388
+ guard let self, self.isStreaming else { return .waiting }
389
+ return self.currentSettlementResolution()
390
+ },
391
+ nowMilliseconds: Self.monotonicMilliseconds,
392
+ sleepMilliseconds: { milliseconds in
393
+ try? await Task.sleep(for: .milliseconds(Int64(clamping: milliseconds)))
394
+ }
395
+ )
396
+ let settled = waitResult.resolution == .settled
397
+ logFinishStage(
398
+ settled ? "final_transcription_settled" : "final_transcription_fallback",
399
+ detail: "wait_ms=\(waitResult.elapsedMilliseconds) resolution=\(waitResult.resolution)"
400
+ )
401
+
402
+ let text = stop()
403
+ return RealtimeFinishResult(text: text, settled: settled, error: error)
164
404
  }
165
405
 
166
406
  /// Stop the streaming session and clean up.
@@ -169,7 +409,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
169
409
  public func stop() -> String {
170
410
  if isStreaming {
171
411
  NativeAppLog.write(
172
- "realtime session summary items=\(itemOrder.count) committed=\(committedItemIDs.count) completed=\(completedItemIDs.count) deltaChars=\(deltaTextByItem.values.map(\.count).reduce(0, +)) finalChars=\(accumulatedText.count) error=\(error ?? "none")",
412
+ "realtime session summary items=\(itemOrder.count) commits=\(settlementTracker.commits.count) terminal=\(settlementTracker.terminalStatusByItemID.count) deltaChars=\(deltaTextByItem.values.map(\.count).reduce(0, +)) finalChars=\(accumulatedText.count) error=\(error ?? "none")",
173
413
  homePath: homePath
174
414
  )
175
415
  }
@@ -177,11 +417,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
177
417
  isConfigured = false
178
418
  receiveTask?.cancel()
179
419
  ws?.cancel(with: .normalClosure, reason: nil)
180
- outboundEventTask?.cancel()
420
+ outboundDrainTask?.cancel()
181
421
  ws = nil
182
422
  receiveTask = nil
183
- outboundEventTask = nil
184
- pendingAudioChunks.removeAll(keepingCapacity: true)
423
+ outboundDrainTask = nil
424
+ outboundEvents.removeAll()
425
+ pendingAudio.removeAll()
185
426
  let text = accumulatedText
186
427
  return text
187
428
  }
@@ -204,9 +445,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
204
445
  }
205
446
  }
206
447
  } catch {
207
- // Connection closed
208
448
  if isStreaming {
209
- fputs("[RealtimeClient] Receive loop ended: \(error.localizedDescription)\n", stderr)
449
+ let safeError = Self.safeError(error.localizedDescription)
450
+ fputs("[RealtimeClient] Receive loop ended: \(safeError)\n", stderr)
451
+ recordTransportFailure("Realtime receive failed: \(safeError)")
210
452
  }
211
453
  }
212
454
  }
@@ -220,14 +462,14 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
220
462
 
221
463
  switch type {
222
464
  case "input_audio_buffer.committed":
223
- lastRealtimeEventAt = Date()
224
465
  if let itemID = json["item_id"] as? String {
225
466
  registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
226
- committedItemIDs.insert(itemID)
467
+ if let commit = settlementTracker.acknowledge(itemID: itemID), commit.isFinal {
468
+ logFinishStage("final_commit_acknowledged", detail: "item_id=\(itemID)")
469
+ }
227
470
  }
228
471
 
229
472
  case "conversation.item.input_audio_transcription.delta":
230
- lastRealtimeEventAt = Date()
231
473
  let itemID = json["item_id"] as? String ?? "__default__"
232
474
  registerItem(itemID, previousItemID: nil)
233
475
  if let delta = json["delta"] as? String {
@@ -237,38 +479,40 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
237
479
 
238
480
  case "conversation.item.input_audio_transcription.completed":
239
481
  // The server may send the complete transcript here
240
- lastRealtimeEventAt = Date()
241
482
  let itemID = json["item_id"] as? String ?? "__default__"
242
483
  registerItem(itemID, previousItemID: nil)
243
- completedItemIDs.insert(itemID)
244
- completedEventCount += 1
484
+ settlementTracker.complete(itemID: itemID)
245
485
  if let text = json["transcript"] as? String, !text.isEmpty {
246
486
  completedTextByItem[itemID] = text
247
487
  }
248
488
  rebuildAccumulatedText()
489
+ if settlementTracker.finalItemID == itemID {
490
+ logFinishStage("final_item_completed", detail: "item_id=\(itemID)")
491
+ }
249
492
 
250
493
  case "conversation.item.input_audio_transcription.failed":
251
- lastRealtimeEventAt = Date()
252
494
  if let itemID = json["item_id"] as? String {
253
- completedItemIDs.insert(itemID)
254
- completedEventCount += 1
495
+ settlementTracker.fail(itemID: itemID)
496
+ if settlementTracker.finalItemID == itemID {
497
+ logFinishStage("final_item_failed", detail: "item_id=\(itemID)")
498
+ }
255
499
  }
256
500
  if let msg = json["error"] as? [String: Any],
257
501
  let message = msg["message"] as? String {
258
- self.error = message
502
+ self.error = Self.safeError(message)
259
503
  }
260
504
 
261
505
  case "error":
262
- lastRealtimeEventAt = Date()
263
506
  if let detail = json["error"] as? [String: Any],
264
507
  let msg = detail["message"] as? String {
265
- fputs("[RealtimeClient] Error: \(msg)\n", stderr)
508
+ let safeMessage = Self.safeError(msg)
509
+ fputs("[RealtimeClient] Error: \(safeMessage)\n", stderr)
266
510
  // "buffer too small" on commit just means server VAD already consumed
267
511
  // the audio — the transcript is intact, so don't surface it as a failure.
268
512
  if msg.localizedCaseInsensitiveContains("buffer too small") {
269
- NativeAppLog.write("realtime benign commit error: \(msg)", homePath: homePath)
513
+ NativeAppLog.write("realtime benign commit error: \(safeMessage)", homePath: homePath)
270
514
  } else {
271
- self.error = msg
515
+ self.error = safeMessage
272
516
  }
273
517
  }
274
518
 
@@ -278,15 +522,68 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
278
522
  }
279
523
 
280
524
  @discardableResult
281
- private func enqueueOutboundEvent(_ obj: [String: Any]) -> Task<Void, Never>? {
282
- guard ws != nil else { return nil }
283
- let previousTask = outboundEventTask
284
- let task = Task { [weak self] in
285
- await previousTask?.value
286
- try? await self?.sendEvent(obj)
525
+ private func enqueueOutboundPayload(_ payload: RealtimeOutboundPayload) -> Int? {
526
+ guard ws != nil, transportFailure == nil else {
527
+ recordTransportFailure("Realtime send failed: connection unavailable")
528
+ return nil
529
+ }
530
+ let sequence = nextOutboundSequence
531
+ guard outboundEvents.append(payload, sequence: sequence) else {
532
+ recordTransportFailure(
533
+ "Realtime send queue exceeded its bounded capacity; full audio will be transcribed after recording"
534
+ )
535
+ return nil
536
+ }
537
+ nextOutboundSequence += 1
538
+ startOutboundDrainIfNeeded()
539
+ return sequence
540
+ }
541
+
542
+ private func startOutboundDrainIfNeeded() {
543
+ guard outboundDrainTask == nil, outboundEvents.eventCount > 0 else { return }
544
+ outboundDrainTask = Task { [weak self] in
545
+ await self?.drainOutboundEvents()
546
+ }
547
+ }
548
+
549
+ private func drainOutboundEvents() async {
550
+ while !Task.isCancelled, isStreaming, transportFailure == nil,
551
+ let event = outboundEvents.popFirst() {
552
+ let encodedEvent = switch event.payload {
553
+ case .audio(let data):
554
+ encodeJSON([
555
+ "type": "input_audio_buffer.append",
556
+ "audio": data.base64EncodedString(),
557
+ ])
558
+ case .event(let text):
559
+ text
560
+ }
561
+ do {
562
+ try await sendEncodedEvent(encodedEvent)
563
+ completedOutboundSequence = event.sequence
564
+ } catch {
565
+ if isStreaming, !Task.isCancelled {
566
+ recordTransportFailure("Realtime send failed: \(error.localizedDescription)")
567
+ }
568
+ break
569
+ }
570
+ }
571
+ outboundDrainTask = nil
572
+ if transportFailure != nil {
573
+ outboundEvents.removeAll()
574
+ } else {
575
+ startOutboundDrainIfNeeded()
576
+ }
577
+ }
578
+
579
+ private func waitForOutboundSequence(_ sequence: Int) async -> Bool {
580
+ while isStreaming, transportFailure == nil, !Task.isCancelled {
581
+ if let completedOutboundSequence, completedOutboundSequence >= sequence {
582
+ return true
583
+ }
584
+ try? await Task.sleep(for: .milliseconds(5))
287
585
  }
288
- outboundEventTask = task
289
- return task
586
+ return false
290
587
  }
291
588
 
292
589
  private func registerItem(_ itemID: String, previousItemID: String?) {
@@ -309,16 +606,69 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
309
606
 
310
607
  private func flushPendingAudio() {
311
608
  guard isConfigured else { return }
312
- let chunks = pendingAudioChunks
313
- pendingAudioChunks.removeAll(keepingCapacity: true)
314
- for chunk in chunks {
609
+ for chunk in pendingAudio.drain() {
315
610
  sendAudio(chunk)
316
611
  }
317
612
  }
318
613
 
319
614
  private func sendEvent(_ obj: [String: Any]) async throws {
320
- guard let ws else { return }
321
- try await ws.send(.string(encodeJSON(obj)))
615
+ try await sendEncodedEvent(encodeJSON(obj))
616
+ }
617
+
618
+ private func sendEncodedEvent(_ text: String) async throws {
619
+ guard let ws else { throw URLError(.networkConnectionLost) }
620
+ try await Self.runOutboundOperationWithDeadline(
621
+ timeoutMilliseconds: 500,
622
+ operation: { try await ws.send(.string(text)) },
623
+ nowMilliseconds: Self.monotonicMilliseconds,
624
+ sleepMilliseconds: { milliseconds in
625
+ try? await Task.sleep(for: .milliseconds(Int64(clamping: milliseconds)))
626
+ }
627
+ )
628
+ }
629
+
630
+ private func recordTransportFailure(_ message: String) {
631
+ guard transportFailure == nil else { return }
632
+ let safeMessage = Self.safeError(message)
633
+ transportFailure = safeMessage
634
+ error = safeMessage
635
+ NativeAppLog.write("realtime transport failure: \(safeMessage)", homePath: homePath)
636
+ }
637
+
638
+ private static func runOutboundOperationWithDeadline(
639
+ timeoutMilliseconds: UInt64,
640
+ operation: @escaping @MainActor @Sendable () async throws -> Void,
641
+ nowMilliseconds: @MainActor () -> UInt64,
642
+ sleepMilliseconds: @MainActor (UInt64) async -> Void
643
+ ) async throws {
644
+ let state = RealtimeOutboundOperationState()
645
+ let operationTask = Task {
646
+ do {
647
+ try await operation()
648
+ state.result = .success(())
649
+ } catch {
650
+ state.result = .failure(error)
651
+ }
652
+ }
653
+ defer { operationTask.cancel() }
654
+
655
+ let startedAt = nowMilliseconds()
656
+ let (candidateDeadline, overflowed) = startedAt.addingReportingOverflow(timeoutMilliseconds)
657
+ let deadline = overflowed ? UInt64.max : candidateDeadline
658
+ while state.result == nil {
659
+ try Task.checkCancellation()
660
+ let now = nowMilliseconds()
661
+ guard now < deadline else { throw RealtimeSendTimeout() }
662
+ await sleepMilliseconds(min(10, deadline - now))
663
+ }
664
+ try state.result!.get()
665
+ }
666
+
667
+ private func currentSettlementResolution() -> RealtimeCommitSettlementResolution {
668
+ if transportFailure != nil { return .failed }
669
+ return settlementTracker.resolution(
670
+ uncommittedRealAudioBytes: uncommittedRealAudioBytes
671
+ )
322
672
  }
323
673
 
324
674
  private func parseJSON(_ text: String) -> [String: Any]? {
@@ -364,10 +714,90 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
364
714
  return output
365
715
  }
366
716
 
717
+ private nonisolated static func safeError(_ message: String) -> String {
718
+ NativeErrorSanitizer.sanitize(message)
719
+ }
720
+
367
721
  private nonisolated static func shouldManuallyCommit(uncommittedAudioBytes: Int) -> Bool {
368
722
  uncommittedAudioBytes >= minimumManualCommitBytes
369
723
  }
370
724
 
725
+ private nonisolated static func finalCommitPlan(realAudioByteCount: Int) -> RealtimeFinalCommitPlan {
726
+ let paddingByteCount: Int
727
+ if realAudioByteCount > 0, realAudioByteCount < minimumManualCommitBytes {
728
+ paddingByteCount = minimumManualCommitBytes - realAudioByteCount
729
+ } else {
730
+ paddingByteCount = 0
731
+ }
732
+ return RealtimeFinalCommitPlan(
733
+ realAudioByteCount: realAudioByteCount,
734
+ paddingByteCount: paddingByteCount
735
+ )
736
+ }
737
+
738
+ private struct SettlementWaitResult: Sendable {
739
+ let resolution: RealtimeCommitSettlementResolution
740
+ let elapsedMilliseconds: UInt64
741
+ }
742
+
743
+ private static func waitForSettlement(
744
+ timeoutMilliseconds: UInt64,
745
+ resolution: @MainActor () -> RealtimeCommitSettlementResolution,
746
+ nowMilliseconds: @MainActor () -> UInt64,
747
+ sleepMilliseconds: @MainActor (UInt64) async -> Void
748
+ ) async -> SettlementWaitResult {
749
+ let startedAt = nowMilliseconds()
750
+ let (candidateDeadline, deadlineOverflowed) = startedAt.addingReportingOverflow(timeoutMilliseconds)
751
+ let deadline = deadlineOverflowed ? UInt64.max : candidateDeadline
752
+ while true {
753
+ let currentResolution = resolution()
754
+ let now = nowMilliseconds()
755
+ if currentResolution != .waiting || now >= deadline {
756
+ return SettlementWaitResult(
757
+ resolution: currentResolution,
758
+ elapsedMilliseconds: now >= startedAt ? now - startedAt : 0
759
+ )
760
+ }
761
+ await sleepMilliseconds(min(10, deadline - now))
762
+ }
763
+ }
764
+
765
+ private static func waitForSettlementWhileCommitRuns(
766
+ timeoutMilliseconds: UInt64,
767
+ beginCommit: @escaping @MainActor @Sendable () async -> Bool,
768
+ resolution: @MainActor () -> RealtimeCommitSettlementResolution,
769
+ nowMilliseconds: @MainActor () -> UInt64,
770
+ sleepMilliseconds: @MainActor (UInt64) async -> Void
771
+ ) async -> SettlementWaitResult {
772
+ let commitTask = Task { await beginCommit() }
773
+ defer { commitTask.cancel() }
774
+ return await waitForSettlement(
775
+ timeoutMilliseconds: timeoutMilliseconds,
776
+ resolution: resolution,
777
+ nowMilliseconds: nowMilliseconds,
778
+ sleepMilliseconds: sleepMilliseconds
779
+ )
780
+ }
781
+
782
+ private nonisolated static func monotonicMilliseconds() -> UInt64 {
783
+ UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
784
+ }
785
+
786
+ private func logFinishStage(_ stage: String, detail: String = "") {
787
+ guard let pipelineID = finishPipelineID,
788
+ let pipelineStartedUptimeMilliseconds = finishPipelineStartedUptimeMilliseconds
789
+ else { return }
790
+ let nowMilliseconds = Self.monotonicMilliseconds()
791
+ let elapsedMilliseconds = nowMilliseconds >= pipelineStartedUptimeMilliseconds
792
+ ? nowMilliseconds - pipelineStartedUptimeMilliseconds
793
+ : 0
794
+ let suffix = detail.isEmpty ? "" : " \(detail)"
795
+ NativeAppLog.write(
796
+ "pipeline_timing pipeline_id=\(pipelineID) stage=\(stage) elapsed_ms=\(elapsedMilliseconds)\(suffix)",
797
+ homePath: homePath
798
+ )
799
+ }
800
+
371
801
  private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
372
802
  [
373
803
  "type": "session.update",
@@ -380,6 +810,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
380
810
  "rate": 24_000,
381
811
  ],
382
812
  "transcription": transcription,
813
+ "turn_detection": NSNull(),
383
814
  ],
384
815
  ],
385
816
  ],
@@ -390,6 +821,64 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
390
821
  // MARK: - Test Helpers (expose private parsing for unit testing)
391
822
 
392
823
  extension RealtimeTranscriptionClient {
824
+ @MainActor
825
+ func enqueueOutboundOperationTestHelper(
826
+ _ operation: @escaping @MainActor @Sendable () async throws -> Void
827
+ ) async -> Bool {
828
+ await enqueueOutboundOperationTestHelper(
829
+ timeoutMilliseconds: 500,
830
+ operation: operation,
831
+ nowMilliseconds: Self.monotonicMilliseconds,
832
+ sleepMilliseconds: { milliseconds in
833
+ try? await Task.sleep(for: .milliseconds(Int64(clamping: milliseconds)))
834
+ }
835
+ )
836
+ }
837
+
838
+ @MainActor
839
+ func enqueueOutboundOperationTestHelper(
840
+ timeoutMilliseconds: UInt64,
841
+ operation: @escaping @MainActor @Sendable () async throws -> Void,
842
+ nowMilliseconds: @MainActor () -> UInt64,
843
+ sleepMilliseconds: @MainActor (UInt64) async -> Void
844
+ ) async -> Bool {
845
+ do {
846
+ try await Self.runOutboundOperationWithDeadline(
847
+ timeoutMilliseconds: timeoutMilliseconds,
848
+ operation: operation,
849
+ nowMilliseconds: nowMilliseconds,
850
+ sleepMilliseconds: sleepMilliseconds
851
+ )
852
+ return true
853
+ } catch {
854
+ recordTransportFailure("Realtime send failed: \(error.localizedDescription)")
855
+ return false
856
+ }
857
+ }
858
+
859
+ @MainActor
860
+ func settlementResolutionTestHelper() -> RealtimeCommitSettlementResolution {
861
+ currentSettlementResolution()
862
+ }
863
+
864
+ @MainActor
865
+ static func waitForSettlementWhileCommitRunsTestHelper(
866
+ timeoutMilliseconds: UInt64,
867
+ beginCommit: @escaping @MainActor @Sendable () async -> Bool,
868
+ resolution: @MainActor () -> RealtimeCommitSettlementResolution,
869
+ nowMilliseconds: @MainActor () -> UInt64,
870
+ sleepMilliseconds: @MainActor (UInt64) async -> Void
871
+ ) async -> (resolution: RealtimeCommitSettlementResolution, elapsedMilliseconds: UInt64) {
872
+ let result = await waitForSettlementWhileCommitRuns(
873
+ timeoutMilliseconds: timeoutMilliseconds,
874
+ beginCommit: beginCommit,
875
+ resolution: resolution,
876
+ nowMilliseconds: nowMilliseconds,
877
+ sleepMilliseconds: sleepMilliseconds
878
+ )
879
+ return (result.resolution, result.elapsedMilliseconds)
880
+ }
881
+
393
882
  public nonisolated static func parseDeltaTestHelper(_ text: String) -> String? {
394
883
  return _parseJSON(text).flatMap { json -> String? in
395
884
  guard let type = json["type"] as? String else { return nil }
@@ -415,7 +904,11 @@ extension RealtimeTranscriptionClient {
415
904
  guard let json = _parseJSON(text),
416
905
  let error = json["error"] as? [String: Any]
417
906
  else { return nil }
418
- return error["message"] as? String
907
+ return (error["message"] as? String).map(safeError)
908
+ }
909
+
910
+ func handleEventTestHelper(_ text: String) {
911
+ handleEvent(text)
419
912
  }
420
913
 
421
914
  public nonisolated static func buildPromptTestHelper(_ context: String) -> String {
@@ -430,6 +923,28 @@ extension RealtimeTranscriptionClient {
430
923
  shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
431
924
  }
432
925
 
926
+ nonisolated static func finalCommitPlanTestHelper(
927
+ realAudioByteCount: Int
928
+ ) -> RealtimeFinalCommitPlan {
929
+ finalCommitPlan(realAudioByteCount: realAudioByteCount)
930
+ }
931
+
932
+ @MainActor
933
+ static func waitForSettlementTestHelper(
934
+ timeoutMilliseconds: UInt64,
935
+ resolution: @MainActor () -> RealtimeCommitSettlementResolution,
936
+ nowMilliseconds: @MainActor () -> UInt64,
937
+ sleepMilliseconds: @MainActor (UInt64) async -> Void
938
+ ) async -> (resolution: RealtimeCommitSettlementResolution, elapsedMilliseconds: UInt64) {
939
+ let result = await waitForSettlement(
940
+ timeoutMilliseconds: timeoutMilliseconds,
941
+ resolution: resolution,
942
+ nowMilliseconds: nowMilliseconds,
943
+ sleepMilliseconds: sleepMilliseconds
944
+ )
945
+ return (result.resolution, result.elapsedMilliseconds)
946
+ }
947
+
433
948
  public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
434
949
  var transcription: [String: Any] = [
435
950
  "model": modelID,