@lattices/cli 0.3.0 → 0.4.1
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 +85 -9
- package/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Package.swift +8 -1
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +45 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +189 -6
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +802 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +49 -9
- package/app/Sources/IntentEngine.swift +962 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1275 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +58 -45
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +740 -0
- package/bin/lattices-app.ts +338 -0
- package/bin/lattices-dev +208 -0
- package/bin/{lattices.js → lattices.ts} +777 -140
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agent-layer-guide.md +207 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +29 -11
- package/bin/client.js +0 -4
- package/bin/lattices-app.js +0 -221
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
// MARK: - Audio Provider Protocol
|
|
4
|
+
|
|
5
|
+
/// A provider that can capture audio and return transcriptions.
|
|
6
|
+
/// Lattices doesn't do transcription itself — it delegates to an external
|
|
7
|
+
/// service (Vox, Whisper, etc.) and maps the result to intents.
|
|
8
|
+
protocol AudioProvider: AnyObject {
|
|
9
|
+
var isAvailable: Bool { get }
|
|
10
|
+
var isListening: Bool { get }
|
|
11
|
+
|
|
12
|
+
/// Start listening. Transcription arrives via the callback.
|
|
13
|
+
func startListening(onTranscript: @escaping (Transcription) -> Void)
|
|
14
|
+
|
|
15
|
+
/// Stop listening and return the final transcription.
|
|
16
|
+
func stopListening(completion: @escaping (Transcription?) -> Void)
|
|
17
|
+
|
|
18
|
+
/// Check if the provider service is reachable.
|
|
19
|
+
func checkHealth(completion: @escaping (Bool) -> Void)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct Transcription {
|
|
23
|
+
let text: String
|
|
24
|
+
let confidence: Double
|
|
25
|
+
let source: String // "vox", "whisper", etc.
|
|
26
|
+
let isPartial: Bool // true for streaming partial results
|
|
27
|
+
let durationMs: Int?
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// MARK: - Audio Layer (coordinates provider + intent engine)
|
|
31
|
+
|
|
32
|
+
final class AudioLayer: ObservableObject {
|
|
33
|
+
static let shared = AudioLayer()
|
|
34
|
+
|
|
35
|
+
@Published var isListening = false
|
|
36
|
+
@Published var lastTranscript: String?
|
|
37
|
+
@Published var matchedIntent: String?
|
|
38
|
+
@Published var matchedSlots: [String: String] = [:]
|
|
39
|
+
@Published var matchConfidence: Double = 0
|
|
40
|
+
@Published var executionResult: String? // "ok" or error message
|
|
41
|
+
@Published var executionData: JSON? // Full result data from intent execution
|
|
42
|
+
@Published var provider: (any AudioProvider)?
|
|
43
|
+
@Published var providerName: String = "none"
|
|
44
|
+
@Published var agentResponse: AgentResponse?
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
private init() {
|
|
48
|
+
let vox = VoxAudioProvider()
|
|
49
|
+
provider = vox
|
|
50
|
+
providerName = "vox"
|
|
51
|
+
// Connection is managed by VoiceCommandWindow — not here.
|
|
52
|
+
// Connecting here would race with (and destroy) the existing WebSocket.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Start a voice command capture. Transcription is piped to the intent engine.
|
|
56
|
+
func startVoiceCommand() {
|
|
57
|
+
guard !isListening else { return }
|
|
58
|
+
|
|
59
|
+
// Clear previous state
|
|
60
|
+
lastTranscript = nil
|
|
61
|
+
matchedIntent = nil
|
|
62
|
+
matchedSlots = [:]
|
|
63
|
+
matchConfidence = 0
|
|
64
|
+
executionResult = nil
|
|
65
|
+
didExecuteIntent = false
|
|
66
|
+
|
|
67
|
+
guard let provider = provider else {
|
|
68
|
+
executionResult = "No voice provider — install Vox"
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isListening = true
|
|
73
|
+
|
|
74
|
+
provider.startListening { [weak self] transcription in
|
|
75
|
+
DispatchQueue.main.async {
|
|
76
|
+
guard let self = self else { return }
|
|
77
|
+
|
|
78
|
+
if transcription.isPartial {
|
|
79
|
+
self.lastTranscript = transcription.text
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Final transcript (e.g. from streaming providers)
|
|
84
|
+
self.lastTranscript = transcription.text
|
|
85
|
+
self.isListening = false
|
|
86
|
+
|
|
87
|
+
// Empty transcript = transcription failed, don't try to execute
|
|
88
|
+
guard !transcription.text.isEmpty else {
|
|
89
|
+
if self.executionResult == nil || self.executionResult == "Transcribing..." {
|
|
90
|
+
self.executionResult = "No speech detected"
|
|
91
|
+
}
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
EventBus.shared.post(.voiceCommand(text: transcription.text, confidence: transcription.confidence))
|
|
96
|
+
self.executeVoiceIntent(transcription)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Track whether we already executed for this recording session.
|
|
102
|
+
private var didExecuteIntent = false
|
|
103
|
+
|
|
104
|
+
func stopVoiceCommand() {
|
|
105
|
+
guard let provider = provider, isListening else { return }
|
|
106
|
+
|
|
107
|
+
isListening = false
|
|
108
|
+
executionResult = "Transcribing..."
|
|
109
|
+
|
|
110
|
+
provider.stopListening { [weak self] transcription in
|
|
111
|
+
DispatchQueue.main.async {
|
|
112
|
+
guard let self = self else { return }
|
|
113
|
+
if let t = transcription {
|
|
114
|
+
self.lastTranscript = t.text
|
|
115
|
+
// Skip if the streaming callback already executed the intent
|
|
116
|
+
guard !self.didExecuteIntent else { return }
|
|
117
|
+
EventBus.shared.post(.voiceCommand(text: t.text, confidence: t.confidence))
|
|
118
|
+
self.executeVoiceIntent(t)
|
|
119
|
+
} else if !self.didExecuteIntent {
|
|
120
|
+
self.executionResult = "No speech detected"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func executeVoiceIntent(_ transcription: Transcription) {
|
|
127
|
+
didExecuteIntent = true
|
|
128
|
+
let matcher = PhraseMatcher.shared
|
|
129
|
+
|
|
130
|
+
// Clear previous agent response
|
|
131
|
+
agentResponse = nil
|
|
132
|
+
|
|
133
|
+
if let match = matcher.match(text: transcription.text) {
|
|
134
|
+
matchedIntent = match.intentName
|
|
135
|
+
matchConfidence = match.confidence
|
|
136
|
+
matchedSlots = match.slots.reduce(into: [:]) { dict, pair in
|
|
137
|
+
dict[pair.key] = pair.value.stringValue ?? "\(pair.value)"
|
|
138
|
+
}
|
|
139
|
+
DiagnosticLog.shared.info("AudioLayer: matched '\(match.intentName)' via '\(match.matchedPhrase)' slots=\(matchedSlots)")
|
|
140
|
+
|
|
141
|
+
do {
|
|
142
|
+
let result = try matcher.execute(match)
|
|
143
|
+
executionResult = "ok"
|
|
144
|
+
executionData = result
|
|
145
|
+
DiagnosticLog.shared.info("AudioLayer: executed '\(match.intentName)' → ok")
|
|
146
|
+
} catch {
|
|
147
|
+
DiagnosticLog.shared.info("AudioLayer: intent error — \(error.localizedDescription), falling back to Claude")
|
|
148
|
+
executionResult = "thinking..."
|
|
149
|
+
executionData = nil
|
|
150
|
+
claudeFallback(transcription: transcription)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fire parallel Haiku advisor for 5+ word utterances
|
|
154
|
+
fireAdvisor(transcript: transcription.text, matched: "\(match.intentName)(\(matchedSlots))")
|
|
155
|
+
|
|
156
|
+
} else {
|
|
157
|
+
// No local match — Claude fallback
|
|
158
|
+
DiagnosticLog.shared.info("AudioLayer: no phrase match for '\(transcription.text)', falling back to Claude")
|
|
159
|
+
matchedIntent = nil
|
|
160
|
+
matchedSlots = [:]
|
|
161
|
+
executionResult = "thinking..."
|
|
162
|
+
executionData = nil
|
|
163
|
+
claudeFallback(transcription: transcription)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Fire the Haiku advisor in parallel — non-blocking, result arrives later.
|
|
168
|
+
private func fireAdvisor(transcript: String, matched: String) {
|
|
169
|
+
let haiku = AgentPool.shared.haiku
|
|
170
|
+
guard haiku.isReady else {
|
|
171
|
+
DiagnosticLog.shared.info("AudioLayer: advisor skipped (haiku not ready)")
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let message = "Transcript: \"\(transcript)\"\nMatched: \(matched)"
|
|
176
|
+
DiagnosticLog.shared.info("AudioLayer: firing haiku advisor")
|
|
177
|
+
|
|
178
|
+
haiku.send(message: message) { [weak self] response in
|
|
179
|
+
guard let self = self, let response = response else { return }
|
|
180
|
+
self.agentResponse = response
|
|
181
|
+
if let commentary = response.commentary {
|
|
182
|
+
DiagnosticLog.shared.info("AudioLayer: haiku says — \(commentary)")
|
|
183
|
+
}
|
|
184
|
+
if let suggestion = response.suggestion {
|
|
185
|
+
DiagnosticLog.shared.info("AudioLayer: haiku suggests — \(suggestion.label) → \(suggestion.intent)")
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private func claudeFallback(transcription: Transcription) {
|
|
191
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
192
|
+
guard let self else { return }
|
|
193
|
+
|
|
194
|
+
let result = ClaudeFallback.resolve(
|
|
195
|
+
transcript: transcription.text,
|
|
196
|
+
windows: DesktopModel.shared.windows.values.map { $0 },
|
|
197
|
+
intentCatalog: PhraseMatcher.shared.catalog()
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
DispatchQueue.main.async {
|
|
201
|
+
guard let resolved = result else {
|
|
202
|
+
self.executionResult = "Claude couldn't resolve intent"
|
|
203
|
+
DiagnosticLog.shared.info("AudioLayer: Claude fallback returned nil")
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
DiagnosticLog.shared.info("AudioLayer: Claude resolved → \(resolved.intent) \(resolved.slots)")
|
|
208
|
+
self.matchedIntent = resolved.intent
|
|
209
|
+
self.matchedSlots = resolved.slots.reduce(into: [:]) { dict, pair in
|
|
210
|
+
dict[pair.key] = pair.value.stringValue ?? "\(pair.value)"
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let intentMatch = IntentMatch(
|
|
214
|
+
intentName: resolved.intent,
|
|
215
|
+
slots: resolved.slots,
|
|
216
|
+
confidence: 0.8,
|
|
217
|
+
matchedPhrase: "claude-fallback"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
do {
|
|
221
|
+
let execResult = try PhraseMatcher.shared.execute(intentMatch)
|
|
222
|
+
self.executionResult = "ok"
|
|
223
|
+
self.executionData = execResult
|
|
224
|
+
DiagnosticLog.shared.info("AudioLayer: Claude-resolved executed → \(execResult)")
|
|
225
|
+
} catch {
|
|
226
|
+
self.executionResult = error.localizedDescription
|
|
227
|
+
self.executionData = nil
|
|
228
|
+
DiagnosticLog.shared.info("AudioLayer: Claude-resolved execution error — \(error.localizedDescription)")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Old IntentExtractor removed — PhraseMatcher handles all intent matching now.
|
|
236
|
+
// See app/Sources/Intents/LatticeIntent.swift
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
// MARK: - Vox Audio Provider (WebSocket JSON-RPC via VoxClient)
|
|
240
|
+
//
|
|
241
|
+
// Delegates recording and transcription entirely to the Vox daemon (voxd).
|
|
242
|
+
// Lattices never touches the mic — Vox owns the mic, recording, and
|
|
243
|
+
// transcription. We call transcribe.startSession to begin recording
|
|
244
|
+
// and transcribe.stopSession to stop and get the transcript.
|
|
245
|
+
//
|
|
246
|
+
// Session events flow on the startSession call ID:
|
|
247
|
+
// session.state: {state, sessionId, previous}
|
|
248
|
+
// session.final: {sessionId, text, words[], elapsedMs, metrics}
|
|
249
|
+
|
|
250
|
+
final class VoxAudioProvider: AudioProvider {
|
|
251
|
+
private var onTranscript: ((Transcription) -> Void)?
|
|
252
|
+
private var stopCompletion: ((Transcription?) -> Void)?
|
|
253
|
+
private var _isListening = false
|
|
254
|
+
private var startTime: Date?
|
|
255
|
+
|
|
256
|
+
var isAvailable: Bool {
|
|
257
|
+
VoxClient.shared.connectionState == .connected
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
var isListening: Bool { _isListening }
|
|
261
|
+
|
|
262
|
+
func checkHealth(completion: @escaping (Bool) -> Void) {
|
|
263
|
+
let client = VoxClient.shared
|
|
264
|
+
if client.connectionState == .connected {
|
|
265
|
+
client.call(method: "health") { result in
|
|
266
|
+
switch result {
|
|
267
|
+
case .success: DispatchQueue.main.async { completion(true) }
|
|
268
|
+
case .failure: DispatchQueue.main.async { completion(false) }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
completion(false)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func startListening(onTranscript: @escaping (Transcription) -> Void) {
|
|
277
|
+
let client = VoxClient.shared
|
|
278
|
+
guard client.connectionState == .connected else {
|
|
279
|
+
DiagnosticLog.shared.warn("VoxAudioProvider: not connected to Vox")
|
|
280
|
+
onTranscript(Transcription(text: "", confidence: 0, source: "vox", isPartial: false, durationMs: nil))
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
self.onTranscript = onTranscript
|
|
285
|
+
_isListening = true
|
|
286
|
+
startTime = Date()
|
|
287
|
+
|
|
288
|
+
DiagnosticLog.shared.info("VoxAudioProvider: starting session via Vox")
|
|
289
|
+
|
|
290
|
+
// transcribe.startSession — Vox records from mic, emits session events on this call ID
|
|
291
|
+
client.startSession(
|
|
292
|
+
onProgress: { [weak self] event, data in
|
|
293
|
+
guard let self else { return }
|
|
294
|
+
DispatchQueue.main.async {
|
|
295
|
+
switch event {
|
|
296
|
+
case "session.state":
|
|
297
|
+
let state = data["state"] as? String ?? ""
|
|
298
|
+
DiagnosticLog.shared.info("VoxAudioProvider: session → \(state)")
|
|
299
|
+
|
|
300
|
+
case "session.final":
|
|
301
|
+
// Final transcript arrived — deliver it
|
|
302
|
+
if let text = data["text"] as? String, !text.isEmpty {
|
|
303
|
+
let elapsed = data["elapsedMs"] as? Int
|
|
304
|
+
let t = Transcription(
|
|
305
|
+
text: text, confidence: 0.95, source: "vox",
|
|
306
|
+
isPartial: false, durationMs: elapsed
|
|
307
|
+
)
|
|
308
|
+
DiagnosticLog.shared.info("VoxAudioProvider: transcribed → '\(text)' (\(elapsed ?? 0)ms)")
|
|
309
|
+
self.onTranscript?(t)
|
|
310
|
+
self.stopCompletion?(t)
|
|
311
|
+
self.stopCompletion = nil
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
default:
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
completion: { [weak self] result in
|
|
320
|
+
guard let self else { return }
|
|
321
|
+
DispatchQueue.main.async {
|
|
322
|
+
self._isListening = false
|
|
323
|
+
|
|
324
|
+
switch result {
|
|
325
|
+
case .success(let data):
|
|
326
|
+
// Final result also comes here (same data as session.final)
|
|
327
|
+
if let text = data["text"] as? String, !text.isEmpty,
|
|
328
|
+
self.stopCompletion != nil {
|
|
329
|
+
// Only deliver if session.final didn't already
|
|
330
|
+
let elapsed = data["elapsedMs"] as? Int
|
|
331
|
+
let t = Transcription(
|
|
332
|
+
text: text, confidence: 0.95, source: "vox",
|
|
333
|
+
isPartial: false, durationMs: elapsed
|
|
334
|
+
)
|
|
335
|
+
self.onTranscript?(t)
|
|
336
|
+
self.stopCompletion?(t)
|
|
337
|
+
self.stopCompletion = nil
|
|
338
|
+
} else if self.stopCompletion != nil {
|
|
339
|
+
self.stopCompletion?(nil)
|
|
340
|
+
self.stopCompletion = nil
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case .failure(let error):
|
|
344
|
+
DiagnosticLog.shared.warn("VoxAudioProvider: session error — \(error.localizedDescription)")
|
|
345
|
+
if case .sessionBusy = error {
|
|
346
|
+
AudioLayer.shared.executionResult = "Session already active"
|
|
347
|
+
} else {
|
|
348
|
+
AudioLayer.shared.executionResult = "Transcription failed"
|
|
349
|
+
}
|
|
350
|
+
self.onTranscript?(Transcription(
|
|
351
|
+
text: "", confidence: 0, source: "vox",
|
|
352
|
+
isPartial: false, durationMs: nil
|
|
353
|
+
))
|
|
354
|
+
self.stopCompletion?(nil)
|
|
355
|
+
self.stopCompletion = nil
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
func stopListening(completion: @escaping (Transcription?) -> Void) {
|
|
363
|
+
_isListening = false
|
|
364
|
+
|
|
365
|
+
let client = VoxClient.shared
|
|
366
|
+
guard client.connectionState == .connected else {
|
|
367
|
+
completion(nil)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
DiagnosticLog.shared.info("VoxAudioProvider: stopping session")
|
|
372
|
+
|
|
373
|
+
// Store completion — the startSession's session.final event delivers the transcript
|
|
374
|
+
self.stopCompletion = completion
|
|
375
|
+
|
|
376
|
+
client.stopSession { result in
|
|
377
|
+
if case .failure(let error) = result {
|
|
378
|
+
DiagnosticLog.shared.warn("VoxAudioProvider: stopSession error — \(error.localizedDescription)")
|
|
379
|
+
DispatchQueue.main.async {
|
|
380
|
+
self.stopCompletion?(nil)
|
|
381
|
+
self.stopCompletion = nil
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|