Skip to content

Commit c70dbdf

Browse files
committed
Converge macOS Voice trigger entry points
1 parent d486e2e commit c70dbdf

14 files changed

Lines changed: 349 additions & 3 deletions

Sources/HardwareControllerApp/app_model.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,13 @@ final class AppModel {
224224
|| selectedLocalAIReadiness.state.canRun)
225225
}
226226

227+
var voiceCaptureButtonState: VoiceCaptureButtonState {
228+
VoiceCaptureButtonState(
229+
phase: localAIDictationSnapshot.phase,
230+
canBegin: canExecuteLocalAIDictation
231+
)
232+
}
233+
227234
/// Reports whether one configured Action can currently execute.
228235
func canExecute(_ kind: ActionKind) -> Bool {
229236
switch kind {
@@ -586,6 +593,17 @@ final class AppModel {
586593
}
587594
}
588595

596+
/// Starts or finishes the shared Voice session from the menu bar.
597+
func toggleVoiceCapture() {
598+
let state = voiceCaptureButtonState
599+
guard state.isEnabled, let command = state.command else {
600+
return
601+
}
602+
enqueueIntent { [runtime] in
603+
await runtime.submitVoiceCapture(command)
604+
}
605+
}
606+
589607
/// Sends one demo Control transition.
590608
func simulate(
591609
_ controlID: ControlID,

Sources/HardwareControllerApp/application_runtime.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ protocol ApplicationProcessControlling: Sendable {
210210
/// Runs a sanitized generation test without microphone or target access.
211211
func testLocalAIProvider() async -> LocalAIRefinementFailure?
212212

213+
/// Submits one app-initiated command to the shared Voice dispatcher.
214+
func submitVoiceCapture(_ command: DictationCommand) -> Bool
215+
213216
/// Runs one configured Binding without physical input.
214217
func testBinding(_ controlID: ControlID)
215218

@@ -620,6 +623,14 @@ private final class LiveApplicationProcess:
620623
return await localAIDictationController.testProvider()
621624
}
622625

626+
/// Uses the same Local AI dispatcher as Controls and the Voice chord.
627+
func submitVoiceCapture(_ command: DictationCommand) -> Bool {
628+
guard isRunning else {
629+
return false
630+
}
631+
return voiceDictationDispatcher.submit(command)
632+
}
633+
623634
private static let demoLocalAIReadiness = LocalAIReadinessSnapshot(
624635
apple: LocalAIProviderReadiness(
625636
provider: .appleOnDevice,
@@ -1449,6 +1460,24 @@ actor ApplicationRuntime {
14491460
process.testBinding(controlID)
14501461
}
14511462

1463+
/// Routes one in-app command through the process-owned Voice session.
1464+
@discardableResult
1465+
func submitVoiceCapture(_ command: DictationCommand) -> Bool {
1466+
guard isStarted, !isStopped, !isSuspended else {
1467+
return false
1468+
}
1469+
if command == .begin, !canExecuteLocalAIDictation {
1470+
return false
1471+
}
1472+
let accepted = process.submitVoiceCapture(command)
1473+
if !accepted {
1474+
snapshot.lastError =
1475+
"Voice capture is busy. Wait for the current session to finish."
1476+
publish()
1477+
}
1478+
return accepted
1479+
}
1480+
14521481
/// Sends one demo transition through the process implementation.
14531482
func simulate(
14541483
_ controlID: ControlID,

Sources/HardwareControllerApp/hardware_controller_app.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,15 @@ private struct MenuBarContent: View {
362362
)
363363
}
364364

365+
Button(
366+
model.voiceCaptureButtonState.title,
367+
systemImage: model.voiceCaptureButtonState.systemImage
368+
) {
369+
model.toggleVoiceCapture()
370+
}
371+
.disabled(!model.voiceCaptureButtonState.isEnabled)
372+
.accessibilityIdentifier("voice_capture_button")
373+
365374
Divider()
366375

367376
Picker(
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import HardwareControllerCore
2+
import HardwareControllerMac
3+
4+
/// Derives one menu action from the authoritative Voice-session phase.
5+
struct VoiceCaptureButtonState: Equatable, Sendable {
6+
let title: String
7+
let systemImage: String
8+
let isEnabled: Bool
9+
let command: DictationCommand?
10+
11+
init(
12+
title: String,
13+
systemImage: String,
14+
isEnabled: Bool,
15+
command: DictationCommand?
16+
) {
17+
self.title = title
18+
self.systemImage = systemImage
19+
self.isEnabled = isEnabled
20+
self.command = command
21+
}
22+
23+
init(
24+
phase: LocalAIDictationPhase,
25+
canBegin: Bool
26+
) {
27+
switch phase {
28+
case .idle, .completed, .failed:
29+
self.init(
30+
title: "Record Voice",
31+
systemImage: "mic.fill",
32+
isEnabled: canBegin,
33+
command: .begin
34+
)
35+
case .preparing, .listening:
36+
self.init(
37+
title: "Stop Recording",
38+
systemImage: "stop.fill",
39+
isEnabled: true,
40+
command: .finish
41+
)
42+
case .finalizing, .refining, .validating, .delivering,
43+
.canceling:
44+
self.init(
45+
title: "Finishing Voice…",
46+
systemImage: "waveform",
47+
isEnabled: false,
48+
command: nil
49+
)
50+
}
51+
}
52+
}

Tests/HardwareControllerAppTests/application_runtime_test.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,40 @@ struct ApplicationRuntimeTest {
2323
#expect(!fixture.snapshots.values.isEmpty)
2424
}
2525

26+
/// Routes app UI capture through the process Voice dispatcher while active.
27+
@Test
28+
func voiceCaptureSubmissionFollowsRuntimeLifecycle() async {
29+
let fixture = RuntimeFixture(
30+
localAIReadiness: LocalAIReadinessSnapshot(
31+
apple: LocalAIProviderReadiness(
32+
provider: .appleOnDevice,
33+
state: .ready
34+
),
35+
ollama: LocalAIProviderReadiness(
36+
provider: .ollama,
37+
state: .unavailable("Not selected.")
38+
)
39+
)
40+
)
41+
await fixture.runtime.start(
42+
snapshotHandler: fixture.snapshots.append
43+
)
44+
45+
#expect(await fixture.runtime.submitVoiceCapture(.begin))
46+
#expect(await fixture.runtime.submitVoiceCapture(.finish))
47+
await fixture.runtime.prepareForSleep()
48+
#expect(!(await fixture.runtime.submitVoiceCapture(.begin)))
49+
await fixture.runtime.resumeAfterWake()
50+
#expect(await fixture.runtime.submitVoiceCapture(.begin))
51+
await fixture.runtime.stop()
52+
#expect(!(await fixture.runtime.submitVoiceCapture(.finish)))
53+
54+
#expect(
55+
fixture.process.voiceCaptureCommands
56+
== [.begin, .finish, .begin]
57+
)
58+
}
59+
2660
/// Starts hardware before a slow optional provider readiness check returns.
2761
@Test(.timeLimit(.minutes(1)))
2862
func localAIReadinessNeverDelaysHardwareStartup() async {
@@ -898,6 +932,7 @@ private final class FakeApplicationProcess:
898932
private var retryStorage = HardwareInputStartResult.started
899933
private var preferredMicrophoneUIDStorage: [String?] = []
900934
private var voiceTriggerSettingsStorage: [VoiceTriggerSettings] = []
935+
private var voiceCaptureCommandStorage: [DictationCommand] = []
901936
private var localAIReadinessStorage = LocalAIReadinessSnapshot.checking
902937
private var localAIReadinessContinuation: CheckedContinuation<Void, Never>?
903938
private var localAIReadinessObservers: [CheckedContinuation<Void, Never>] = []
@@ -949,6 +984,10 @@ private final class FakeApplicationProcess:
949984
lock.withLock { voiceTriggerSettingsStorage }
950985
}
951986

987+
var voiceCaptureCommands: [DictationCommand] {
988+
lock.withLock { voiceCaptureCommandStorage }
989+
}
990+
952991
var retryResult: HardwareInputStartResult {
953992
get {
954993
lock.withLock { retryStorage }
@@ -1126,6 +1165,14 @@ private final class FakeApplicationProcess:
11261165
return nil
11271166
}
11281167

1168+
/// Records commands sent through the process Voice dispatcher.
1169+
func submitVoiceCapture(_ command: DictationCommand) -> Bool {
1170+
lock.withLock {
1171+
voiceCaptureCommandStorage.append(command)
1172+
}
1173+
return true
1174+
}
1175+
11291176
/// Accepts a deterministic test request.
11301177
func testBinding(_ controlID: ControlID) {}
11311178

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import HardwareControllerCore
2+
import HardwareControllerMac
3+
import Testing
4+
5+
@testable import HardwareControllerApp
6+
7+
struct VoiceCaptureButtonStateTest {
8+
@Test
9+
func idleCompletedAndFailedStartOnlyWhenLocalAIIsAvailable() {
10+
for phase in [
11+
LocalAIDictationPhase.idle,
12+
.completed,
13+
.failed,
14+
] {
15+
#expect(
16+
VoiceCaptureButtonState(
17+
phase: phase,
18+
canBegin: true
19+
)
20+
== VoiceCaptureButtonState(
21+
title: "Record Voice",
22+
systemImage: "mic.fill",
23+
isEnabled: true,
24+
command: .begin
25+
)
26+
)
27+
#expect(
28+
VoiceCaptureButtonState(
29+
phase: phase,
30+
canBegin: false
31+
).isEnabled == false
32+
)
33+
}
34+
}
35+
36+
@Test
37+
func preparingAndListeningAlwaysOfferToStopTheOwnedCapture() {
38+
for phase in [
39+
LocalAIDictationPhase.preparing,
40+
.listening,
41+
] {
42+
#expect(
43+
VoiceCaptureButtonState(
44+
phase: phase,
45+
canBegin: false
46+
)
47+
== VoiceCaptureButtonState(
48+
title: "Stop Recording",
49+
systemImage: "stop.fill",
50+
isEnabled: true,
51+
command: .finish
52+
)
53+
)
54+
}
55+
}
56+
57+
@Test
58+
func postCaptureWorkCannotStartOrFinishAnotherSession() {
59+
for phase in [
60+
LocalAIDictationPhase.finalizing,
61+
.refining,
62+
.validating,
63+
.delivering,
64+
.canceling,
65+
] {
66+
#expect(
67+
VoiceCaptureButtonState(
68+
phase: phase,
69+
canBegin: true
70+
)
71+
== VoiceCaptureButtonState(
72+
title: "Finishing Voice…",
73+
systemImage: "waveform",
74+
isEnabled: false,
75+
command: nil
76+
)
77+
)
78+
}
79+
}
80+
}

Tests/HardwareControllerMacTests/voice_keyboard_trigger_controller_test.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ struct VoiceKeyboardTriggerControllerTest {
2626
#expect(dispatcher.commands == [.begin, .finish])
2727
}
2828

29+
@Test
30+
func doublePressLatchesThenTheNextDoublePressFinishesOnce() async throws {
31+
let dispatcher = DictationCommandRecorder()
32+
let controller = try VoiceKeyboardTriggerController(
33+
settings: .default,
34+
dispatcher: dispatcher
35+
)
36+
37+
await controller.handle(phase: .pressed, timestampNanoseconds: ms(0))
38+
await controller.handle(phase: .released, timestampNanoseconds: ms(50))
39+
await controller.handle(phase: .pressed, timestampNanoseconds: ms(100))
40+
await controller.handle(phase: .released, timestampNanoseconds: ms(150))
41+
#expect(dispatcher.commands == [.begin])
42+
43+
await controller.handle(phase: .pressed, timestampNanoseconds: ms(1_000))
44+
await controller.handle(phase: .released, timestampNanoseconds: ms(1_050))
45+
await controller.handle(phase: .pressed, timestampNanoseconds: ms(1_100))
46+
await controller.handle(phase: .released, timestampNanoseconds: ms(1_150))
47+
48+
#expect(dispatcher.commands == [.begin, .finish])
49+
}
50+
2951
@Test
3052
func pendingShortPressFinishesOnlyWhenItsDecisionExpires() async throws {
3153
let dispatcher = DictationCommandRecorder()

docs/architecture.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ separate orchestration, presentation state, settings, and failure paths. Shared
246246
audio, recognition, target, writer, permission, and lifecycle services are
247247
composed rather than copied.
248248

249+
Every macOS Local AI trigger converges before that orchestration boundary:
250+
251+
| Trigger | Adapter semantics | Shared command destination |
252+
| --- | --- | --- |
253+
| Physical Control or exact Binding fallback | Binding-owned Hold or Toggle | Local AI `DictationCommand` dispatcher through the Action executor. |
254+
| Independent Voice chord | Hold or double-press latch | The same Local AI dispatcher through `VoiceKeyboardTriggerController`. |
255+
| Menu-bar record action | Phase-derived Record or Stop | The same Local AI dispatcher through the lifecycle-gated application runtime. |
256+
257+
The menu-bar action does not open or activate the main window, preserving the
258+
external application's target opportunity. Presentation derives availability
259+
from the Local AI snapshot; the serialized dispatcher and session controller
260+
own idempotence and reject overlap. No trigger owns recognition, formatting,
261+
History, retention, target validation, or delivery.
262+
249263
Local AI providers implement `TranscriptRefining`:
250264

251265
- Every adapter declares one immutable `LocalAIProviderCapability` containing

docs/decisions/0029_local_voice_platform_expansion.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 0029: Local Voice platform expansion
22

3-
- **Status:** Accepted; macOS M1–M9 implemented
3+
- **Status:** Accepted; macOS M1–M10 implemented
44
- **Date:** 2026-08-25
55
- **Amends:**
66
[`0001_native_macos_stack.md`](0001_native_macos_stack.md),
@@ -199,3 +199,9 @@ remote-capable adapter before invoking it, validates provider identity, and
199199
preserves deterministic Edited fallback. Recognition failure after capture
200200
finalizes playable audio with delivery not attempted and never mutates the
201201
target.
202+
203+
M10 converges physical Controls, Hold/latch Voice chords, and the menu-bar
204+
record action on one process-owned Local AI command dispatcher under
205+
[decision 0034](0034_voice_trigger_convergence.md). Trigger adapters retain only
206+
their interaction semantics; session lifecycle, ASR, formatting, History,
207+
retention, target validation, and delivery stay shared.

0 commit comments

Comments
 (0)