mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: prevent Voice Wake crash after Talk audio capture (#99137)
* fix: prevent Voice Wake audio restart race * fix: handle in-flight Voice Wake suspension * test(ios): harden Voice Wake lifecycle proof * fix(ios): remove Voice Wake tap after failed start --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -8203,7 +8203,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 610,
|
||||
"line": 613,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Configured",
|
||||
"surface": "apple",
|
||||
@@ -8211,7 +8211,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 610,
|
||||
"line": 613,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Not configured",
|
||||
"surface": "apple",
|
||||
@@ -8219,7 +8219,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 675,
|
||||
"line": 678,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Connect to the gateway.",
|
||||
"surface": "apple",
|
||||
@@ -8227,7 +8227,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 675,
|
||||
"line": 678,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Gateway requests will appear here.",
|
||||
"surface": "apple",
|
||||
@@ -8235,7 +8235,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 730,
|
||||
"line": 733,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "High",
|
||||
"surface": "apple",
|
||||
@@ -8243,7 +8243,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 730,
|
||||
"line": 733,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Resolving",
|
||||
"surface": "apple",
|
||||
@@ -8251,7 +8251,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 735,
|
||||
"line": 738,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "One-time approval",
|
||||
"surface": "apple",
|
||||
@@ -8259,7 +8259,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 735,
|
||||
"line": 738,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Permission can be saved",
|
||||
"surface": "apple",
|
||||
@@ -8267,7 +8267,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 737,
|
||||
"line": 740,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Medium",
|
||||
"surface": "apple",
|
||||
@@ -8275,7 +8275,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 737,
|
||||
"line": 740,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Review",
|
||||
"surface": "apple",
|
||||
@@ -8283,7 +8283,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 762,
|
||||
"line": 765,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "\\(diagnosticsIssueCount)",
|
||||
"surface": "apple",
|
||||
@@ -8291,7 +8291,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 772,
|
||||
"line": 775,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Location \\(self.locationLabel)",
|
||||
"surface": "apple",
|
||||
@@ -8299,7 +8299,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 772,
|
||||
"line": 775,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Location off",
|
||||
"surface": "apple",
|
||||
@@ -8307,7 +8307,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 777,
|
||||
"line": 780,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Off",
|
||||
"surface": "apple",
|
||||
@@ -8315,7 +8315,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 778,
|
||||
"line": 781,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "While Using",
|
||||
"surface": "apple",
|
||||
@@ -8323,7 +8323,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 779,
|
||||
"line": 782,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Always",
|
||||
"surface": "apple",
|
||||
@@ -8331,7 +8331,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 793,
|
||||
"line": 796,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Checking iOS notification permission.",
|
||||
"surface": "apple",
|
||||
@@ -8339,7 +8339,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 795,
|
||||
"line": 798,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "OpenClaw can show approval prompts and event alerts when the app is not active.",
|
||||
"surface": "apple",
|
||||
@@ -8347,7 +8347,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 797,
|
||||
"line": 800,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Notifications have been denied. Enable them in iOS Settings.",
|
||||
"surface": "apple",
|
||||
@@ -8355,7 +8355,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 799,
|
||||
"line": 802,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "Enable notifications to receive approval prompts and event alerts outside the app.",
|
||||
"surface": "apple",
|
||||
@@ -8363,7 +8363,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 801,
|
||||
"line": 804,
|
||||
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
|
||||
"source": "OpenClaw cannot determine the current notification permission state.",
|
||||
"surface": "apple",
|
||||
@@ -11489,6 +11489,22 @@
|
||||
"surface": "apple",
|
||||
"id": "native.apple.d2db26b3bb266491"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 260,
|
||||
"path": "apps/ios/Sources/Voice/VoiceWakeManager.swift",
|
||||
"source": "Off",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.b6bc54876f20104f"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 260,
|
||||
"path": "apps/ios/Sources/Voice/VoiceWakeManager.swift",
|
||||
"source": "Paused",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.5590d661cdc81fbf"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 96,
|
||||
|
||||
@@ -80,6 +80,9 @@ extension SettingsProTab {
|
||||
detail: self.appModel.voiceWake.statusText,
|
||||
value: self.voiceWakeEnabled ? "on" : "off",
|
||||
color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("diagnostics-voice-wake-status")
|
||||
.accessibilityValue(self.appModel.voiceWake.statusText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
@@ -38,6 +38,17 @@ private final class AudioBufferQueue: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private enum VoiceWakeAudioError: LocalizedError {
|
||||
case invalidInputFormat
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidInputFormat:
|
||||
"Microphone input format unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AVAudioPCMBuffer {
|
||||
fileprivate func deepCopy() -> AVAudioPCMBuffer? {
|
||||
let format = self.format
|
||||
@@ -93,13 +104,25 @@ final class VoiceWakeManager: NSObject {
|
||||
private var recognitionTask: SFSpeechRecognitionTask?
|
||||
private var tapQueue: AudioBufferQueue?
|
||||
private var tapDrainTask: Task<Void, Never>?
|
||||
private var scheduledStartTask: Task<Void, Never>?
|
||||
private var isStarting: Bool = false
|
||||
private var isSuspendedForExternalAudio: Bool = false
|
||||
|
||||
private var lastDispatched: String?
|
||||
private var onCommand: (@Sendable (String) async -> Void)?
|
||||
private var userDefaultsObserver: NSObjectProtocol?
|
||||
private var suppressedByTalk: Bool = false
|
||||
|
||||
override init() {
|
||||
private let externalAudioResumeDelayNs: UInt64
|
||||
private let recognitionErrorRestartDelayNs: UInt64
|
||||
|
||||
override convenience init() {
|
||||
self.init(externalAudioResumeDelayNs: 350_000_000, recognitionErrorRestartDelayNs: 700_000_000)
|
||||
}
|
||||
|
||||
private init(externalAudioResumeDelayNs: UInt64, recognitionErrorRestartDelayNs: UInt64) {
|
||||
self.externalAudioResumeDelayNs = externalAudioResumeDelayNs
|
||||
self.recognitionErrorRestartDelayNs = recognitionErrorRestartDelayNs
|
||||
super.init()
|
||||
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
|
||||
self.userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
@@ -137,7 +160,7 @@ final class VoiceWakeManager: NSObject {
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.isEnabled = enabled
|
||||
if enabled {
|
||||
Task { await self.start() }
|
||||
self.scheduleStart()
|
||||
} else {
|
||||
self.stop()
|
||||
}
|
||||
@@ -146,20 +169,51 @@ final class VoiceWakeManager: NSObject {
|
||||
func setSuppressedByTalk(_ suppressed: Bool) {
|
||||
self.suppressedByTalk = suppressed
|
||||
if suppressed {
|
||||
_ = self.suspendForExternalAudioCapture()
|
||||
self.cancelScheduledStart()
|
||||
if self.isListening {
|
||||
self.isListening = false
|
||||
self.tearDownRecognitionPipeline()
|
||||
}
|
||||
if self.isEnabled {
|
||||
self.statusText = "Paused"
|
||||
}
|
||||
} else {
|
||||
if self.isEnabled {
|
||||
Task { await self.start() }
|
||||
}
|
||||
} else if self.isEnabled {
|
||||
self.scheduleStart()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleStart(after delayNs: UInt64 = 0) {
|
||||
guard self.isEnabled else { return }
|
||||
|
||||
self.scheduledStartTask?.cancel()
|
||||
self.scheduledStartTask = Task { [weak self] in
|
||||
if delayNs > 0 {
|
||||
try? await Task.sleep(nanoseconds: delayNs)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.scheduledStartTask = nil
|
||||
await self?.start()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelScheduledStart() {
|
||||
self.scheduledStartTask?.cancel()
|
||||
self.scheduledStartTask = nil
|
||||
}
|
||||
|
||||
func start() async {
|
||||
guard self.isEnabled else { return }
|
||||
if self.isListening { return }
|
||||
if self.isStarting { return }
|
||||
guard !self.isSuspendedForExternalAudio else {
|
||||
self.isListening = false
|
||||
self.statusText = "Paused"
|
||||
return
|
||||
}
|
||||
|
||||
self.isStarting = true
|
||||
defer { self.isStarting = false }
|
||||
|
||||
guard !self.suppressedByTalk else {
|
||||
self.isListening = false
|
||||
self.statusText = "Paused"
|
||||
@@ -201,6 +255,12 @@ final class VoiceWakeManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else {
|
||||
self.isListening = false
|
||||
self.statusText = self.isEnabled ? "Paused" : "Off"
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try Self.configureAudioSession()
|
||||
try self.startRecognition()
|
||||
@@ -208,6 +268,7 @@ final class VoiceWakeManager: NSObject {
|
||||
self.statusText = "Listening"
|
||||
} catch {
|
||||
self.isListening = false
|
||||
self.tearDownRecognitionPipeline()
|
||||
self.statusText = "Start failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
@@ -216,14 +277,19 @@ final class VoiceWakeManager: NSObject {
|
||||
self.isEnabled = false
|
||||
self.isListening = false
|
||||
self.statusText = "Off"
|
||||
self.isSuspendedForExternalAudio = false
|
||||
self.cancelScheduledStart()
|
||||
self.tearDownRecognitionPipeline()
|
||||
}
|
||||
|
||||
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
|
||||
/// Returns `true` when listening was active and was suspended.
|
||||
/// Returns `true` when listening, starting, or a pending restart was active and was suspended.
|
||||
func suspendForExternalAudioCapture() -> Bool {
|
||||
guard self.isEnabled, self.isListening else { return false }
|
||||
let hadPendingStart = self.scheduledStartTask != nil
|
||||
self.cancelScheduledStart()
|
||||
guard self.isEnabled, self.isListening || self.isStarting || hadPendingStart else { return false }
|
||||
|
||||
self.isSuspendedForExternalAudio = true
|
||||
self.isListening = false
|
||||
self.statusText = "Paused"
|
||||
self.tearDownRecognitionPipeline()
|
||||
@@ -232,10 +298,13 @@ final class VoiceWakeManager: NSObject {
|
||||
|
||||
func resumeAfterExternalAudioCapture(wasSuspended: Bool) {
|
||||
guard wasSuspended else { return }
|
||||
Task { await self.start() }
|
||||
self.isSuspendedForExternalAudio = false
|
||||
self.scheduleStart(after: self.externalAudioResumeDelayNs)
|
||||
}
|
||||
|
||||
private func startRecognition() throws {
|
||||
guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else { return }
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.tapDrainTask?.cancel()
|
||||
@@ -251,6 +320,9 @@ final class VoiceWakeManager: NSObject {
|
||||
inputNode.removeTap(onBus: 0)
|
||||
|
||||
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
||||
guard recordingFormat.sampleRate > 0, recordingFormat.channelCount > 0 else {
|
||||
throw VoiceWakeAudioError.invalidInputFormat
|
||||
}
|
||||
|
||||
let queue = AudioBufferQueue()
|
||||
self.tapQueue = queue
|
||||
@@ -292,8 +364,9 @@ final class VoiceWakeManager: NSObject {
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
// A tap can be installed before AVAudioEngine.start() throws.
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
@@ -317,13 +390,7 @@ final class VoiceWakeManager: NSObject {
|
||||
self.statusText = "Recognizer error: \(errorText)"
|
||||
self.isListening = false
|
||||
|
||||
let shouldRestart = self.isEnabled
|
||||
if shouldRestart {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
await self.start()
|
||||
}
|
||||
}
|
||||
self.scheduleStart(after: self.recognitionErrorRestartDelayNs)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -343,10 +410,7 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
|
||||
private func startIfEnabled() async {
|
||||
let shouldRestart = self.isEnabled
|
||||
if shouldRestart {
|
||||
await self.start()
|
||||
}
|
||||
self.scheduleStart()
|
||||
}
|
||||
|
||||
private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
|
||||
@@ -469,8 +533,21 @@ final class VoiceWakeManager: NSObject {
|
||||
|
||||
#if DEBUG
|
||||
extension VoiceWakeManager {
|
||||
static func _test_withoutRestartDelays() -> VoiceWakeManager {
|
||||
VoiceWakeManager(externalAudioResumeDelayNs: 0, recognitionErrorRestartDelayNs: 0)
|
||||
}
|
||||
|
||||
func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
|
||||
self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
|
||||
}
|
||||
|
||||
func _test_setStartInFlight(_ isStarting: Bool) {
|
||||
self.isStarting = isStarting
|
||||
}
|
||||
|
||||
func _test_waitForScheduledStart() async {
|
||||
let task = self.scheduledStartTask
|
||||
await task?.value
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -4,8 +4,8 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct VoiceWakeManagerStateTests {
|
||||
@Test @MainActor func suspendAndResumeCycleUpdatesState() async {
|
||||
let manager = VoiceWakeManager()
|
||||
@Test @MainActor func `suspend and resume cycle updates state`() async {
|
||||
let manager = VoiceWakeManager._test_withoutRestartDelays()
|
||||
manager.isEnabled = true
|
||||
manager.isListening = true
|
||||
manager.statusText = "Listening"
|
||||
@@ -16,12 +16,12 @@ import Testing
|
||||
#expect(manager.statusText == "Paused")
|
||||
|
||||
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
#expect(manager.statusText.contains("Voice Wake") == true)
|
||||
await manager._test_waitForScheduledStart()
|
||||
#expect(manager.statusText == "Voice Wake isn’t supported on Simulator")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleRecognitionCallbackRestartsOnError() async {
|
||||
let manager = VoiceWakeManager()
|
||||
@Test @MainActor func `handle recognition callback restarts on error`() async {
|
||||
let manager = VoiceWakeManager._test_withoutRestartDelays()
|
||||
manager.isEnabled = true
|
||||
manager.isListening = true
|
||||
|
||||
@@ -29,18 +29,20 @@ import Testing
|
||||
#expect(manager.statusText.contains("Recognizer error") == true)
|
||||
#expect(manager.isListening == false)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
#expect(manager.statusText.contains("Voice Wake") == true)
|
||||
await manager._test_waitForScheduledStart()
|
||||
#expect(manager.statusText == "Voice Wake isn’t supported on Simulator")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleRecognitionCallbackDispatchesCommand() async {
|
||||
@Test @MainActor func `handle recognition callback dispatches command`() async throws {
|
||||
let manager = VoiceWakeManager()
|
||||
manager.triggerWords = ["openclaw"]
|
||||
manager.isEnabled = true
|
||||
|
||||
actor CaptureBox {
|
||||
var value: String?
|
||||
func set(_ next: String) { self.value = next }
|
||||
func set(_ next: String) {
|
||||
self.value = next
|
||||
}
|
||||
}
|
||||
let capture = CaptureBox()
|
||||
manager.configure { cmd in
|
||||
@@ -48,8 +50,8 @@ import Testing
|
||||
}
|
||||
|
||||
let transcript = "openclaw hello"
|
||||
let triggerRange = transcript.range(of: "openclaw")!
|
||||
let helloRange = transcript.range(of: "hello")!
|
||||
let triggerRange = try #require(transcript.range(of: "openclaw"))
|
||||
let helloRange = try #require(transcript.range(of: "hello"))
|
||||
let segments = [
|
||||
WakeWordSegment(text: "openclaw", start: 0.0, duration: 0.2, range: triggerRange),
|
||||
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite("Voice Wake manager suppression", .serialized)
|
||||
struct VoiceWakeManagerSuppressionTests {
|
||||
@Test
|
||||
@MainActor func `clearing Talk suppression restarts after pending start was canceled`() async {
|
||||
let manager = VoiceWakeManager._test_withoutRestartDelays()
|
||||
manager.isEnabled = true
|
||||
manager.statusText = "Paused"
|
||||
|
||||
manager.setSuppressedByTalk(true)
|
||||
manager.setSuppressedByTalk(false)
|
||||
|
||||
await manager._test_waitForScheduledStart()
|
||||
#expect(manager.statusText == "Voice Wake isn’t supported on Simulator")
|
||||
#expect(manager.isListening == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor func `external audio resumes pending Voice Wake restart`() async {
|
||||
let manager = VoiceWakeManager._test_withoutRestartDelays()
|
||||
manager.isEnabled = true
|
||||
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
|
||||
|
||||
let suspended = manager.suspendForExternalAudioCapture()
|
||||
#expect(suspended == true)
|
||||
|
||||
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
|
||||
|
||||
await manager._test_waitForScheduledStart()
|
||||
#expect(manager.statusText == "Voice Wake isn’t supported on Simulator")
|
||||
#expect(manager.isListening == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor func `external audio resumes in flight Voice Wake start`() async {
|
||||
let manager = VoiceWakeManager._test_withoutRestartDelays()
|
||||
manager.isEnabled = true
|
||||
manager._test_setStartInFlight(true)
|
||||
|
||||
let suspended = manager.suspendForExternalAudioCapture()
|
||||
#expect(suspended == true)
|
||||
#expect(manager.statusText == "Paused")
|
||||
|
||||
manager._test_setStartInFlight(false)
|
||||
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
|
||||
|
||||
await manager._test_waitForScheduledStart()
|
||||
#expect(manager.statusText == "Voice Wake isn’t supported on Simulator")
|
||||
#expect(manager.isListening == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor func `Talk suppression toggle does not leave Voice Wake externally suspended`() async {
|
||||
let manager = VoiceWakeManager._test_withoutRestartDelays()
|
||||
manager.isEnabled = true
|
||||
manager.isListening = true
|
||||
|
||||
manager.setSuppressedByTalk(true)
|
||||
let suspended = manager.suspendForExternalAudioCapture()
|
||||
#expect(suspended == false)
|
||||
|
||||
manager.setSuppressedByTalk(false)
|
||||
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
|
||||
|
||||
await manager._test_waitForScheduledStart()
|
||||
#expect(manager.statusText == "Voice Wake isn’t supported on Simulator")
|
||||
#expect(manager.isListening == false)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,69 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
||||
XCTAssertTrue(self.app?.tabBars.buttons["Talk"].isSelected == true)
|
||||
}
|
||||
|
||||
func testVoiceWakeResumesAfterTalkModeToggle() throws {
|
||||
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Settings proof only")
|
||||
self.addUIInterruptionMonitor(withDescription: "Microphone and speech permissions") { alert in
|
||||
guard alert.buttons["Allow"].exists else { return false }
|
||||
alert.buttons["Allow"].tap()
|
||||
return true
|
||||
}
|
||||
self.launchApp(for: ScreenshotTarget(
|
||||
initialTab: "settings",
|
||||
initialDestination: "settings",
|
||||
name: "voice-wake-talk-lifecycle"))
|
||||
|
||||
let voiceSettings = try XCTUnwrap(
|
||||
self.app?.buttons.containing(.staticText, identifier: "Voice & Talk").firstMatch)
|
||||
XCTAssertTrue(voiceSettings.waitForExistence(timeout: 8))
|
||||
voiceSettings.tap()
|
||||
|
||||
let voiceWake = try XCTUnwrap(self.app?.switches["Voice Wake"])
|
||||
let talkMode = try XCTUnwrap(self.app?.switches["Talk Mode"])
|
||||
XCTAssertTrue(voiceWake.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(talkMode.exists)
|
||||
|
||||
if talkMode.value as? String == "1" {
|
||||
talkMode.tap()
|
||||
}
|
||||
if voiceWake.value as? String == "1" {
|
||||
voiceWake.tap()
|
||||
}
|
||||
|
||||
voiceWake.tap()
|
||||
XCTAssertEqual(voiceWake.value as? String, "1")
|
||||
talkMode.tap()
|
||||
XCTAssertEqual(talkMode.value as? String, "1")
|
||||
talkMode.tap()
|
||||
XCTAssertEqual(talkMode.value as? String, "0")
|
||||
XCTAssertEqual(voiceWake.value as? String, "1")
|
||||
XCTAssertEqual(self.app?.state, .runningForeground)
|
||||
self.attachScreenshot(named: "voice-wake-after-talk-resume")
|
||||
|
||||
let voiceNavigationBar = try XCTUnwrap(self.app?.navigationBars["Voice & Talk"])
|
||||
voiceNavigationBar.buttons["BackButton"].tap()
|
||||
let diagnostics = try XCTUnwrap(
|
||||
self.app?.buttons.containing(.staticText, identifier: "Diagnostics").firstMatch)
|
||||
XCTAssertTrue(diagnostics.waitForExistence(timeout: 5))
|
||||
diagnostics.tap()
|
||||
let voiceWakeStatus = try XCTUnwrap(
|
||||
self.app?.descendants(matching: .any)["diagnostics-voice-wake-status"])
|
||||
XCTAssertTrue(voiceWakeStatus.waitForExistence(timeout: 5))
|
||||
let resumed = expectation(
|
||||
for: NSPredicate(
|
||||
format: "value == %@",
|
||||
"Voice Wake isn’t supported on Simulator"),
|
||||
evaluatedWith: voiceWakeStatus)
|
||||
wait(for: [resumed], timeout: 5)
|
||||
|
||||
let diagnosticsNavigationBar = try XCTUnwrap(self.app?.navigationBars["Diagnostics"])
|
||||
diagnosticsNavigationBar.buttons["BackButton"].tap()
|
||||
voiceSettings.tap()
|
||||
XCTAssertTrue(voiceWake.waitForExistence(timeout: 5))
|
||||
voiceWake.tap()
|
||||
XCTAssertEqual(voiceWake.value as? String, "0")
|
||||
}
|
||||
|
||||
func testChatComposerStartsCompactAndGrowsWithDraft() throws {
|
||||
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone composer proof only")
|
||||
self.launchApp(for: ScreenshotTarget(
|
||||
@@ -126,7 +189,8 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
||||
|
||||
textField.tap()
|
||||
textField.typeText(
|
||||
"Draft a polished launch note that covers the new design, validation, rollout plan, and follow-up details for the team.")
|
||||
"Draft a polished launch note that covers the new design, validation, rollout plan, " +
|
||||
"and follow-up details for the team.")
|
||||
let composerGrew = expectation(
|
||||
for: NSPredicate { _, _ in textField.frame.height >= compactHeight + 12 },
|
||||
evaluatedWith: textField)
|
||||
|
||||
Reference in New Issue
Block a user