mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(ios): surface Voice Wake transcript delivery failures (#130139)
This commit is contained in:
committed by
GitHub
parent
6124d47e04
commit
bb66c4c288
@@ -1030,12 +1030,7 @@ final class NodeAppModel {
|
||||
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
let sessionKey = await MainActor.run { self.mainSessionKey }
|
||||
do {
|
||||
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
try await self.sendVoiceTranscript(text: cmd, sessionKey: self.mainSessionKey)
|
||||
}
|
||||
self.voiceNoteRecorder.onRecordingActiveChanged = { [weak self] isActive in
|
||||
self?.voiceWake.setSuppressedByVoiceNote(isActive)
|
||||
|
||||
@@ -119,7 +119,7 @@ final class VoiceWakeManager: NSObject {
|
||||
private var audioSessionIsActive = false
|
||||
|
||||
private var lastDispatched: String?
|
||||
private var onCommand: (@Sendable (String) async -> Void)?
|
||||
private var onCommand: (@MainActor @Sendable (String) async throws -> Void)?
|
||||
private var userDefaultsObserver: NSObjectProtocol?
|
||||
private var suppressionReasons: Set<VoiceWakeSuppressionReason> = []
|
||||
|
||||
@@ -166,7 +166,7 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func configure(onCommand: @escaping @Sendable (String) async -> Void) {
|
||||
func configure(onCommand: @escaping @MainActor @Sendable (String) async throws -> Void) {
|
||||
self.onCommand = onCommand
|
||||
}
|
||||
|
||||
@@ -472,12 +472,21 @@ final class VoiceWakeManager: NSObject {
|
||||
self.commandTask = nil
|
||||
}
|
||||
}
|
||||
await self.onCommand?(cmd)
|
||||
do {
|
||||
try await self.onCommand?(cmd)
|
||||
} catch {
|
||||
guard !(error is CancellationError), self.isCurrentCommand(
|
||||
recognitionGeneration: recognitionGeneration,
|
||||
commandGeneration: commandGeneration)
|
||||
else { return }
|
||||
self.statusText = error.localizedDescription
|
||||
return
|
||||
}
|
||||
guard self.isCurrentCommand(
|
||||
recognitionGeneration: recognitionGeneration,
|
||||
commandGeneration: commandGeneration)
|
||||
else { return }
|
||||
await self.startIfEnabled()
|
||||
self.scheduleStart()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,10 +507,6 @@ final class VoiceWakeManager: NSObject {
|
||||
self.commandTask = nil
|
||||
}
|
||||
|
||||
private func startIfEnabled() async {
|
||||
self.scheduleStart()
|
||||
}
|
||||
|
||||
private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
|
||||
Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,30 @@ private actor VoiceWakeCommandBarrier {
|
||||
#expect(await capture.value == "hello")
|
||||
}
|
||||
|
||||
@Test @MainActor func `failed Voice Wake delivery replaces the triggered status`() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
let manager = appModel.voiceWake
|
||||
manager.triggerWords = ["openclaw"]
|
||||
manager.isEnabled = true
|
||||
|
||||
let transcript = "openclaw hello"
|
||||
let triggerRange = try #require(transcript.range(of: "openclaw"))
|
||||
let commandRange = try #require(transcript.range(of: "hello"))
|
||||
manager._test_handleRecognitionCallback(
|
||||
transcript: transcript,
|
||||
segments: [
|
||||
WakeWordSegment(text: "openclaw", start: 0, duration: 0.2, range: triggerRange),
|
||||
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: commandRange),
|
||||
],
|
||||
errorText: nil)
|
||||
|
||||
for _ in 0..<100 where manager.statusText == "Triggered" {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(manager.statusText == "Gateway not connected")
|
||||
}
|
||||
|
||||
@Test @MainActor func `suppression cancels an admitted command before dispatch`() async throws {
|
||||
let manager = VoiceWakeManager._test_withoutRestartDelays()
|
||||
let barrier = VoiceWakeCommandBarrier()
|
||||
@@ -99,7 +123,11 @@ private actor VoiceWakeCommandBarrier {
|
||||
manager.isListening = true
|
||||
manager.configure { command in
|
||||
await barrier.suspend()
|
||||
guard !Task.isCancelled else { return }
|
||||
guard !Task.isCancelled else {
|
||||
throw NSError(domain: "VoiceWakeManagerStateTests", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Stale delivery failed",
|
||||
])
|
||||
}
|
||||
await capture.set(command)
|
||||
}
|
||||
|
||||
@@ -126,6 +154,7 @@ private actor VoiceWakeCommandBarrier {
|
||||
|
||||
#expect(await barrier.observedCancellation == true)
|
||||
#expect(await capture.value == nil)
|
||||
#expect(manager.statusText == "Paused")
|
||||
}
|
||||
|
||||
@Test @MainActor func `Voice Wake deactivates only its owned audio session`() {
|
||||
|
||||
@@ -318,6 +318,7 @@ Agents can still operate the iOS app through OpenClaw by invoking node commands,
|
||||
## Voice wake + talk mode
|
||||
|
||||
- Voice wake and talk mode are available in Settings.
|
||||
- Voice wake sends recognized commands to the active session and shows Gateway delivery failures in Settings; use talk mode for spoken assistant replies.
|
||||
- OpenAI realtime Talk uses client-owned WebRTC when `talk.realtime.transport` is `webrtc`; an explicit `gateway-relay` configuration remains Gateway-owned. See [Talk mode](/nodes/talk).
|
||||
- Talk-capable iOS nodes advertise the `talk` capability and can declare `talk.ptt.start`, `talk.ptt.stop`, `talk.ptt.cancel`, and `talk.ptt.once`; the Gateway allows those push-to-talk commands by default for trusted Talk-capable nodes.
|
||||
- iOS may suspend background audio; treat voice features as best-effort when the app is not active.
|
||||
|
||||
Reference in New Issue
Block a user