Skip to content

Commit c9f532b

Browse files
committed
fix: handle in-flight Voice Wake suspension
1 parent c00fb65 commit c9f532b

3 files changed

Lines changed: 74 additions & 6 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11497,6 +11497,22 @@
1149711497
"surface": "apple",
1149811498
"id": "native.apple.d2db26b3bb266491"
1149911499
},
11500+
{
11501+
"kind": "conditional-branch",
11502+
"line": 255,
11503+
"path": "apps/ios/Sources/Voice/VoiceWakeManager.swift",
11504+
"source": "Off",
11505+
"surface": "apple",
11506+
"id": "native.apple.b6bc54876f20104f"
11507+
},
11508+
{
11509+
"kind": "conditional-branch",
11510+
"line": 255,
11511+
"path": "apps/ios/Sources/Voice/VoiceWakeManager.swift",
11512+
"source": "Paused",
11513+
"surface": "apple",
11514+
"id": "native.apple.5590d661cdc81fbf"
11515+
},
1150011516
{
1150111517
"kind": "ui-named-argument",
1150211518
"line": 96,

apps/ios/Sources/Voice/VoiceWakeManager.swift

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ final class VoiceWakeManager: NSObject {
106106
private var tapDrainTask: Task<Void, Never>?
107107
private var scheduledStartTask: Task<Void, Never>?
108108
private var isStarting: Bool = false
109+
private var isSuspendedForExternalAudio: Bool = false
109110

110111
private var lastDispatched: String?
111112
private var onCommand: (@Sendable (String) async -> Void)?
@@ -162,7 +163,10 @@ final class VoiceWakeManager: NSObject {
162163
self.suppressedByTalk = suppressed
163164
if suppressed {
164165
self.cancelScheduledStart()
165-
_ = self.suspendForExternalAudioCapture()
166+
if self.isListening {
167+
self.isListening = false
168+
self.tearDownRecognitionPipeline()
169+
}
166170
if self.isEnabled {
167171
self.statusText = "Paused"
168172
}
@@ -196,6 +200,11 @@ final class VoiceWakeManager: NSObject {
196200
guard self.isEnabled else { return }
197201
if self.isListening { return }
198202
if self.isStarting { return }
203+
guard !self.isSuspendedForExternalAudio else {
204+
self.isListening = false
205+
self.statusText = "Paused"
206+
return
207+
}
199208

200209
self.isStarting = true
201210
defer { self.isStarting = false }
@@ -241,9 +250,9 @@ final class VoiceWakeManager: NSObject {
241250
return
242251
}
243252

244-
guard self.isEnabled, !self.suppressedByTalk else {
253+
guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else {
245254
self.isListening = false
246-
self.statusText = self.suppressedByTalk ? "Paused" : "Off"
255+
self.statusText = self.isEnabled ? "Paused" : "Off"
247256
return
248257
}
249258

@@ -262,17 +271,19 @@ final class VoiceWakeManager: NSObject {
262271
self.isEnabled = false
263272
self.isListening = false
264273
self.statusText = "Off"
274+
self.isSuspendedForExternalAudio = false
265275
self.cancelScheduledStart()
266276
self.tearDownRecognitionPipeline()
267277
}
268278

269279
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
270-
/// Returns `true` when listening or a pending restart was active and was suspended.
280+
/// Returns `true` when listening, starting, or a pending restart was active and was suspended.
271281
func suspendForExternalAudioCapture() -> Bool {
272282
let hadPendingStart = self.scheduledStartTask != nil
273283
self.cancelScheduledStart()
274-
guard self.isEnabled, self.isListening || hadPendingStart else { return false }
284+
guard self.isEnabled, self.isListening || self.isStarting || hadPendingStart else { return false }
275285

286+
self.isSuspendedForExternalAudio = true
276287
self.isListening = false
277288
self.statusText = "Paused"
278289
self.tearDownRecognitionPipeline()
@@ -281,11 +292,12 @@ final class VoiceWakeManager: NSObject {
281292

282293
func resumeAfterExternalAudioCapture(wasSuspended: Bool) {
283294
guard wasSuspended else { return }
295+
self.isSuspendedForExternalAudio = false
284296
self.scheduleStart(after: Self.externalAudioResumeDelayNs)
285297
}
286298

287299
private func startRecognition() throws {
288-
guard self.isEnabled, !self.suppressedByTalk else { return }
300+
guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else { return }
289301

290302
self.recognitionTask?.cancel()
291303
self.recognitionTask = nil
@@ -517,5 +529,9 @@ extension VoiceWakeManager {
517529
func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
518530
self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
519531
}
532+
533+
func _test_setStartInFlight(_ isStarting: Bool) {
534+
self.isStarting = isStarting
535+
}
520536
}
521537
#endif

apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,40 @@ struct VoiceWakeManagerSuppressionTests {
3333
#expect(manager.statusText.contains("Voice Wake") == true)
3434
#expect(manager.isListening == false)
3535
}
36+
37+
@Test
38+
@MainActor func `external audio resumes in flight Voice Wake start`() async {
39+
let manager = VoiceWakeManager()
40+
manager.isEnabled = true
41+
manager._test_setStartInFlight(true)
42+
43+
let suspended = manager.suspendForExternalAudioCapture()
44+
#expect(suspended == true)
45+
#expect(manager.statusText == "Paused")
46+
47+
manager._test_setStartInFlight(false)
48+
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
49+
50+
try? await Task.sleep(nanoseconds: 900_000_000)
51+
#expect(manager.statusText.contains("Voice Wake") == true)
52+
#expect(manager.isListening == false)
53+
}
54+
55+
@Test
56+
@MainActor func `Talk suppression toggle does not leave Voice Wake externally suspended`() async {
57+
let manager = VoiceWakeManager()
58+
manager.isEnabled = true
59+
manager.isListening = true
60+
61+
manager.setSuppressedByTalk(true)
62+
let suspended = manager.suspendForExternalAudioCapture()
63+
#expect(suspended == false)
64+
65+
manager.setSuppressedByTalk(false)
66+
manager.resumeAfterExternalAudioCapture(wasSuspended: suspended)
67+
68+
try? await Task.sleep(nanoseconds: 500_000_000)
69+
#expect(manager.statusText.contains("Voice Wake") == true)
70+
#expect(manager.isListening == false)
71+
}
3672
}

0 commit comments

Comments
 (0)