@hasna/recordings 0.2.10 → 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.
- package/README.md +350 -35
- package/bun.lock +3 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
- package/dist/cli/index.js +1728 -320
- package/dist/cli/macos-permissions.d.ts +13 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -0
- package/dist/cli/options.d.ts +12 -0
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +2 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +5 -1
- package/dist/db/remote-storage.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -147
- package/dist/lib/bun-runtime.d.ts +11 -0
- package/dist/lib/bun-runtime.d.ts.map +1 -0
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +10 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/install-maintenance.d.ts +24 -0
- package/dist/lib/install-maintenance.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +2 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts +2 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/recording-create-identity.d.ts +13 -0
- package/dist/lib/recording-create-identity.d.ts.map +1 -0
- package/dist/lib/release-install-policy.d.ts +31 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +977 -194
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/v1.generated.d.ts +4 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +9 -0
- package/dist/server/cloud-config.d.ts.map +1 -0
- package/dist/server/cloud-readiness.d.ts +10 -0
- package/dist/server/cloud-readiness.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +9 -18
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1654 -304
- package/dist/server/migrate-command.d.ts +11 -0
- package/dist/server/migrate-command.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +22 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts +8 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage.js +515 -80
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +7 -4
- package/packaging/macos/Empty.entitlements +5 -0
- package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
- package/packaging/macos/Verifier.entitlements +10 -0
- package/packaging/macos/artifact-verifier.sb +33 -0
- package/packaging/macos/build_release_pkg.sh +872 -0
- package/packaging/macos/managed_bootstrap.sh +623 -0
- package/packaging/macos/pkgutil_fingerprint.awk +37 -0
- package/packaging/macos/release_lifecycle.ts +463 -0
- package/packaging/macos/scripts/postinstall +565 -0
- package/packaging/macos/scripts/preinstall +297 -0
- package/scripts/build_companion_cli.sh +337 -0
- package/scripts/build_native_fs_guard.sh +59 -0
- package/scripts/generate-sdk.ts +19 -1
- package/scripts/install_macos_app.sh +1922 -77
- package/scripts/macos_artifact.ts +5316 -0
- package/scripts/migrate.ts +38 -10
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +1167 -0
- package/scripts/native_fs_guard.ts +158 -0
- package/scripts/release-guard.ts +20 -1
- package/scripts/resolve_tailscale_cli.sh +389 -0
- package/scripts/smoke_macos_app.sh +573 -0
- package/src/native/Recordings/App/ContentView.swift +83 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
- package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
- package/src/native/Recordings/App/RecordingsApp.swift +367 -44
- package/src/native/Recordings/App/RecordingsListView.swift +130 -0
- package/src/native/Recordings/App/RecordingsStore.swift +206 -0
- package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
- package/src/native/Recordings/App/SidebarView.swift +212 -0
- package/src/native/Recordings/App/Theme.swift +87 -0
- package/src/native/Recordings/Package.resolved +5 -5
- package/src/native/Recordings/Package.swift +62 -2
- package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
- package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
- package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
- package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
- package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
- package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
- package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
- package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
- package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
- package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
- package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
- package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
- package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
- package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
- package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
- package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
- package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
- package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
- package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
- package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
- package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
- package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
- package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
- package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
- package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
- package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
- package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
- package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
- package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
- package/src/native/Recordings/build.sh +1990 -29
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
completedItemIDs.removeAll(keepingCapacity: true)
|
|
66
|
-
completedEventCount = 0
|
|
67
|
-
queuedCommitCount = 0
|
|
267
|
+
settlementTracker = RealtimeCommitSettlementTracker()
|
|
68
268
|
uncommittedAudioBytes = 0
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
322
|
+
guard enqueueOutboundPayload(.audio(data)) != nil else { return }
|
|
115
323
|
uncommittedAudioBytes += data.count
|
|
116
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
NativeAppLog.write(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
420
|
+
outboundDrainTask?.cancel()
|
|
181
421
|
ws = nil
|
|
182
422
|
receiveTask = nil
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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: \(
|
|
513
|
+
NativeAppLog.write("realtime benign commit error: \(safeMessage)", homePath: homePath)
|
|
270
514
|
} else {
|
|
271
|
-
self.error =
|
|
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
|
|
282
|
-
guard ws != nil
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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,
|