Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
946
apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
Normal file
946
apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
Normal file
@@ -0,0 +1,946 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct VoiceWakeSettings: View {
|
||||
@Bindable var state: AppState
|
||||
let isActive: Bool
|
||||
@State private var testState: VoiceWakeTestState = .idle
|
||||
@State private var tester = VoiceWakeTester()
|
||||
@State private var isTesting = false
|
||||
@State private var testTimeoutTask: Task<Void, Never>?
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var meterLevel: Double = 0
|
||||
@State private var meterError: String?
|
||||
private let meter = MicLevelMonitor()
|
||||
@State private var micObserver = AudioInputDeviceObserver()
|
||||
@State private var micRefreshTask: Task<Void, Never>?
|
||||
@State private var meterStartupTask: Task<Void, Never>?
|
||||
@State private var availableLocales: [Locale] = []
|
||||
@State private var triggerEntries: [TriggerEntry] = []
|
||||
private let controlWidth: CGFloat = 240
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
var id: String {
|
||||
self.uid
|
||||
}
|
||||
}
|
||||
|
||||
private struct TriggerEntry: Identifiable {
|
||||
let id: UUID
|
||||
var value: String
|
||||
}
|
||||
|
||||
private var voiceWakeBinding: Binding<Bool> {
|
||||
MicRefreshSupport.voiceWakeBinding(for: self.state)
|
||||
}
|
||||
|
||||
private var voiceSummaryPanel: some View {
|
||||
let enabled = voiceWakeSupported && self.state.swabbleEnabled
|
||||
let pushToTalk = voiceWakeSupported && self.state.voicePushToTalkEnabled
|
||||
let statusColor: Color = !voiceWakeSupported ? .orange : enabled || pushToTalk ? .green : .secondary
|
||||
|
||||
return HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(statusColor.opacity(0.18))
|
||||
Image(systemName: self.voiceSummaryIconName(enabled: enabled))
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.voiceSummaryTitle(enabled: enabled, pushToTalk: pushToTalk))
|
||||
.font(.headline)
|
||||
Text(self.voiceSummarySubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 18)
|
||||
|
||||
if let meterError {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.help(meterError)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.06))
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceSummaryIconName(enabled: Bool) -> String {
|
||||
if !voiceWakeSupported {
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
return enabled ? "waveform.badge.mic" : "mic.slash"
|
||||
}
|
||||
|
||||
private func voiceSummaryTitle(enabled: Bool, pushToTalk: Bool) -> String {
|
||||
if !voiceWakeSupported {
|
||||
return "Voice Wake unavailable"
|
||||
}
|
||||
if enabled {
|
||||
return "Voice Wake active"
|
||||
}
|
||||
if pushToTalk {
|
||||
return "Push-to-talk active"
|
||||
}
|
||||
return "Voice controls idle"
|
||||
}
|
||||
|
||||
private var voiceSummarySubtitle: String {
|
||||
if !voiceWakeSupported {
|
||||
return "Voice Wake requires macOS 26 or newer."
|
||||
}
|
||||
if self.state.swabbleEnabled {
|
||||
return "Listening for \(self.sanitizedTriggers().prefix(2).joined(separator: ", "))."
|
||||
}
|
||||
if self.state.voicePushToTalkEnabled {
|
||||
return "Hold Right Option to speak without a wake phrase."
|
||||
}
|
||||
return "Enable Voice Wake or push-to-talk to start voice commands."
|
||||
}
|
||||
|
||||
private var unsupportedVoiceWakePanel: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 28)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Voice Wake requires macOS 26 or newer")
|
||||
.font(.callout.weight(.semibold))
|
||||
Text("The Voice Wake and push-to-talk controls are hidden on older macOS versions.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.orange.opacity(0.18))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsPageHeader(
|
||||
title: "Voice & Talk",
|
||||
subtitle: "Wake phrases, push-to-talk, microphone input, and Talk Mode feedback.")
|
||||
|
||||
self.voiceSummaryPanel
|
||||
|
||||
if voiceWakeSupported {
|
||||
SettingsCardGroup("Activation") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Voice Wake",
|
||||
subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.",
|
||||
binding: self.voiceWakeBinding)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Trigger Talk Mode",
|
||||
subtitle: "Start a full voice conversation when a wake phrase is detected.",
|
||||
binding: self.$state.voiceWakeTriggersTalkMode)
|
||||
.disabled(!self.state.swabbleEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Hold Right Option to talk",
|
||||
subtitle: "Start listening while you hold the key and show the preview overlay.",
|
||||
binding: self.$state.voicePushToTalkEnabled)
|
||||
|
||||
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
|
||||
SettingsCardRow(
|
||||
title: "Push-to-talk paused",
|
||||
subtitle: "Push-to-Talk resumes when Talk Mode is turned off.")
|
||||
{
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Play phase-transition sounds",
|
||||
subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
|
||||
binding: self.$state.talkPhaseSoundsEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Right Option stops speech",
|
||||
subtitle: "Tap Right Option to interrupt speech and return to listening.",
|
||||
binding: self.$state.talkShiftToStopEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
SettingsCardGroup("Recognition") {
|
||||
self.localePicker
|
||||
self.micPicker
|
||||
self.levelMeter
|
||||
}
|
||||
|
||||
SettingsCardGroup("Test") {
|
||||
VoiceWakeTestCard(
|
||||
testState: self.$testState,
|
||||
isTesting: self.$isTesting,
|
||||
onToggle: self.toggleTest)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
self.chimeSection
|
||||
|
||||
self.triggerTable
|
||||
} else {
|
||||
self.unsupportedVoiceWakePanel
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.settingsDetailContent()
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.isActive else { return }
|
||||
self.activateLivePreview()
|
||||
}
|
||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||
guard !self.isPreview else { return }
|
||||
self.updateSelectedMicName()
|
||||
guard self.isActive else { return }
|
||||
self.scheduleMeterRestart()
|
||||
}
|
||||
.onChange(of: self.isActive) { _, active in
|
||||
guard !self.isPreview else { return }
|
||||
if !active {
|
||||
self.deactivateLivePreview()
|
||||
self.syncTriggerEntriesToState()
|
||||
} else {
|
||||
self.activateLivePreview()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
guard !self.isPreview else { return }
|
||||
self.deactivateLivePreview()
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
|
||||
private func activateLivePreview() {
|
||||
self.loadTriggerEntries()
|
||||
guard voiceWakeSupported else {
|
||||
self.deactivateLivePreview()
|
||||
return
|
||||
}
|
||||
self.meterStartupTask?.cancel()
|
||||
self.startMicObserver()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
await self.loadMicsIfNeeded()
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
await self.loadLocalesIfNeeded()
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
await self.restartMeter()
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateLivePreview() {
|
||||
self.tester.stop()
|
||||
self.isTesting = false
|
||||
self.testState = .idle
|
||||
self.testTimeoutTask?.cancel()
|
||||
self.micRefreshTask?.cancel()
|
||||
self.micRefreshTask = nil
|
||||
self.meterStartupTask?.cancel()
|
||||
self.meterStartupTask = nil
|
||||
self.micObserver.stop()
|
||||
self.state.voiceWakeMeterActive = false
|
||||
Task { await self.meter.stop() }
|
||||
}
|
||||
|
||||
private func scheduleMeterRestart() {
|
||||
guard voiceWakeSupported else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
Task { await self.meter.stop() }
|
||||
return
|
||||
}
|
||||
self.meterStartupTask?.cancel()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
await self.restartMeter()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTriggerEntries() {
|
||||
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
}
|
||||
|
||||
private func syncTriggerEntriesToState() {
|
||||
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
|
||||
}
|
||||
|
||||
private var triggerTable: some View {
|
||||
SettingsCardGroup("Trigger Words") {
|
||||
SettingsCardRow(
|
||||
title: "Wake phrases",
|
||||
subtitle: "Short phrases that start voice wake detection.")
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
self.addWord()
|
||||
} label: {
|
||||
Label("Add word", systemImage: "plus")
|
||||
}
|
||||
.disabled(self.triggerEntries
|
||||
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
|
||||
Button("Reset") {
|
||||
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
self.triggerPhraseRows
|
||||
|
||||
TriggerPhraseHelpRow()
|
||||
}
|
||||
}
|
||||
|
||||
private var triggerPhraseRows: some View {
|
||||
Group {
|
||||
if self.triggerEntries.isEmpty {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "text.badge.plus")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 22)
|
||||
Text("No wake phrases configured")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(self.$triggerEntries) { $entry in
|
||||
TriggerPhraseRow(
|
||||
value: $entry.value,
|
||||
showsDivider: entry.id != self.triggerEntries.last?.id,
|
||||
onSubmit: {
|
||||
self.syncTriggerEntriesToState()
|
||||
},
|
||||
onRemove: {
|
||||
self.removeWord(id: entry.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider()
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
|
||||
private var chimeSection: some View {
|
||||
SettingsCardGroup("Sounds") {
|
||||
self.chimeRow(
|
||||
title: "Trigger sound",
|
||||
selection: self.$state.voiceWakeTriggerChime)
|
||||
|
||||
self.chimeRow(
|
||||
title: "Send sound",
|
||||
selection: self.$state.voiceWakeSendChime,
|
||||
showsDivider: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func addWord() {
|
||||
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
|
||||
}
|
||||
|
||||
private func removeWord(id: UUID) {
|
||||
self.triggerEntries.removeAll { $0.id == id }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
|
||||
private func toggleTest() {
|
||||
guard voiceWakeSupported else {
|
||||
self.testState = .failed("Voice Wake requires macOS 26 or newer.")
|
||||
return
|
||||
}
|
||||
if self.isTesting {
|
||||
self.tester.finalize()
|
||||
self.isTesting = false
|
||||
self.testState = .finalizing
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if self.testState == .finalizing {
|
||||
self.tester.stop()
|
||||
self.testState = .failed("Stopped")
|
||||
}
|
||||
}
|
||||
self.testTimeoutTask?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
let triggers = self.sanitizedTriggers()
|
||||
self.tester.stop()
|
||||
self.testTimeoutTask?.cancel()
|
||||
self.isTesting = true
|
||||
self.testState = .requesting
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await self.tester.start(
|
||||
triggers: triggers,
|
||||
micID: self.state.voiceWakeMicID.isEmpty ? nil : self.state.voiceWakeMicID,
|
||||
localeID: self.state.voiceWakeLocaleID,
|
||||
onUpdate: { newState in
|
||||
DispatchQueue.main.async { [self] in
|
||||
self.testState = newState
|
||||
if case .detected = newState { self.isTesting = false }
|
||||
if case .failed = newState { self.isTesting = false }
|
||||
if case .detected = newState { self.testTimeoutTask?.cancel() }
|
||||
if case .failed = newState { self.testTimeoutTask?.cancel() }
|
||||
}
|
||||
})
|
||||
self.testTimeoutTask?.cancel()
|
||||
self.testTimeoutTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 10 * 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
if self.isTesting {
|
||||
self.tester.stop()
|
||||
if case let .hearing(text) = self.testState,
|
||||
let command = Self.textOnlyCommand(from: text, triggers: triggers)
|
||||
{
|
||||
self.testState = .detected(command)
|
||||
} else {
|
||||
self.testState = .failed("Timeout: no trigger heard")
|
||||
}
|
||||
self.isTesting = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.tester.stop()
|
||||
self.testState = .failed(error.localizedDescription)
|
||||
self.isTesting = false
|
||||
self.testTimeoutTask?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chimeRow(
|
||||
title: String,
|
||||
selection: Binding<VoiceWakeChime>,
|
||||
showsDivider: Bool = true) -> some View
|
||||
{
|
||||
SettingsCardRow(title: title, showsDivider: showsDivider) {
|
||||
Menu {
|
||||
Button("No Sound") { self.selectChime(.none, binding: selection) }
|
||||
Divider()
|
||||
ForEach(VoiceWakeChimeCatalog.systemOptions, id: \.self) { option in
|
||||
Button(VoiceWakeChimeCatalog.displayName(for: option)) {
|
||||
self.selectChime(.system(name: option), binding: selection)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Choose file…") { self.chooseCustomChime(for: selection) }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(selection.wrappedValue.displayLabel)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(6)
|
||||
.frame(width: self.controlWidth, alignment: .leading)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
Button("Play") {
|
||||
VoiceWakeChimePlayer.play(selection.wrappedValue)
|
||||
}
|
||||
.keyboardShortcut(.space, modifiers: [.command])
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseCustomChime(for selection: Binding<VoiceWakeChime>) {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.audio]
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.resolvesAliases = true
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
do {
|
||||
let bookmark = try url.bookmarkData(
|
||||
options: [.withSecurityScope],
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil)
|
||||
let chosen = VoiceWakeChime.custom(displayName: url.lastPathComponent, bookmark: bookmark)
|
||||
selection.wrappedValue = chosen
|
||||
VoiceWakeChimePlayer.play(chosen)
|
||||
} catch {
|
||||
// Ignore failures; user can retry.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectChime(_ chime: VoiceWakeChime, binding: Binding<VoiceWakeChime>) {
|
||||
binding.wrappedValue = chime
|
||||
VoiceWakeChimePlayer.play(chime)
|
||||
}
|
||||
|
||||
private func sanitizedTriggers() -> [String] {
|
||||
sanitizeVoiceWakeTriggers(self.state.swabbleTriggerWords)
|
||||
}
|
||||
|
||||
private static func textOnlyCommand(from transcript: String, triggers: [String]) -> String? {
|
||||
VoiceWakeTextUtils.textOnlyCommand(
|
||||
transcript: transcript,
|
||||
triggers: triggers,
|
||||
minCommandLength: 1,
|
||||
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||
}
|
||||
|
||||
private var micPicker: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(title: "Microphone") {
|
||||
Picker("Microphone", selection: self.$state.voiceWakeMicID) {
|
||||
Text("System default").tag("")
|
||||
if self.isSelectedMicUnavailable {
|
||||
Text(self.state.voiceWakeMicName.isEmpty ? "Unavailable" : self.state.voiceWakeMicName)
|
||||
.tag(self.state.voiceWakeMicID)
|
||||
}
|
||||
ForEach(self.availableMics) { mic in
|
||||
Text(mic.name).tag(mic.uid)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: self.controlWidth)
|
||||
}
|
||||
if self.isSelectedMicUnavailable {
|
||||
Text("Disconnected (using System default)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
if self.loadingMics {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var localePicker: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(
|
||||
title: "Recognition language",
|
||||
subtitle: "Languages are tried in order. Models may need a first-use download on macOS 26.")
|
||||
{
|
||||
Picker("Language", selection: self.$state.voiceWakeLocaleID) {
|
||||
let current = Locale(identifier: Locale.current.identifier)
|
||||
Text("\(self.friendlyName(for: current)) (System)").tag(Locale.current.identifier)
|
||||
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
|
||||
if id != Locale.current.identifier {
|
||||
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: self.controlWidth)
|
||||
}
|
||||
|
||||
SettingsCardRow(
|
||||
title: "Additional languages",
|
||||
subtitle: self.additionalLanguagesSubtitle,
|
||||
showsDivider: !self.state.voiceWakeAdditionalLocaleIDs.isEmpty)
|
||||
{
|
||||
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
Button {
|
||||
self.addAdditionalLocale()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
self.additionalLanguageRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var additionalLanguagesSubtitle: String {
|
||||
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
return "None configured."
|
||||
}
|
||||
return "Tried after the primary language."
|
||||
}
|
||||
|
||||
private var additionalLanguageRows: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
|
||||
AdditionalLanguageRow(
|
||||
index: idx,
|
||||
selection: self.additionalLocaleBinding(index: idx, fallback: localeID),
|
||||
localeIDs: self.availableLocales.map(\.identifier),
|
||||
localeName: { id in self.friendlyName(for: Locale(identifier: id)) },
|
||||
showsDivider: true,
|
||||
onRemove: {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
|
||||
})
|
||||
}
|
||||
|
||||
SettingsCardRow(title: "Add another language", showsDivider: false) {
|
||||
Button {
|
||||
self.addAdditionalLocale()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func additionalLocaleBinding(index: Int, fallback: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return fallback }
|
||||
return self.state.voiceWakeAdditionalLocaleIDs[index]
|
||||
},
|
||||
set: { newValue in
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs[index] = newValue
|
||||
})
|
||||
}
|
||||
|
||||
private func addAdditionalLocale() {
|
||||
let selected = Set([self.state.voiceWakeLocaleID] + self.state.voiceWakeAdditionalLocaleIDs)
|
||||
let next = self.availableLocales.first { !selected.contains($0.identifier) } ?? self.availableLocales.first
|
||||
if let next {
|
||||
self.state.voiceWakeAdditionalLocaleIDs.append(next.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicsIfNeeded(force: Bool = false) async {
|
||||
guard force || self.availableMics.isEmpty, !self.loadingMics else { return }
|
||||
self.loadingMics = true
|
||||
let discovery = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .microphone],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
||||
let connectedDevices = discovery.devices.filter(\.isConnected)
|
||||
let devices = aliveUIDs.isEmpty
|
||||
? connectedDevices
|
||||
: connectedDevices.filter { aliveUIDs.contains($0.uniqueID) }
|
||||
self.availableMics = devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
self.updateSelectedMicName()
|
||||
self.loadingMics = false
|
||||
}
|
||||
|
||||
private var isSelectedMicUnavailable: Bool {
|
||||
let selected = self.state.voiceWakeMicID
|
||||
guard !selected.isEmpty else { return false }
|
||||
return !self.availableMics.contains(where: { $0.uid == selected })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateSelectedMicName() {
|
||||
self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
|
||||
selectedID: self.state.voiceWakeMicID,
|
||||
in: self.availableMics,
|
||||
uid: \.uid,
|
||||
name: \.name)
|
||||
}
|
||||
|
||||
private func startMicObserver() {
|
||||
MicRefreshSupport.startObserver(self.micObserver) {
|
||||
self.scheduleMicRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleMicRefresh() {
|
||||
guard voiceWakeSupported, self.isActive else { return }
|
||||
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
||||
await self.loadMicsIfNeeded(force: true)
|
||||
await self.restartMeter()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadLocalesIfNeeded() async {
|
||||
guard self.availableLocales.isEmpty else { return }
|
||||
self.availableLocales = Array(SFSpeechRecognizer.supportedLocales()).sorted { lhs, rhs in
|
||||
self.friendlyName(for: lhs)
|
||||
.localizedCaseInsensitiveCompare(self.friendlyName(for: rhs)) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func friendlyName(for locale: Locale) -> String {
|
||||
let cleanedID = normalizeLocaleIdentifier(locale.identifier)
|
||||
let cleanLocale = Locale(identifier: cleanedID)
|
||||
|
||||
if let langCode = cleanLocale.language.languageCode?.identifier,
|
||||
let lang = cleanLocale.localizedString(forLanguageCode: langCode),
|
||||
let regionCode = cleanLocale.region?.identifier,
|
||||
let region = cleanLocale.localizedString(forRegionCode: regionCode)
|
||||
{
|
||||
return "\(lang) (\(region))"
|
||||
}
|
||||
if let langCode = cleanLocale.language.languageCode?.identifier,
|
||||
let lang = cleanLocale.localizedString(forLanguageCode: langCode)
|
||||
{
|
||||
return lang
|
||||
}
|
||||
return cleanLocale.localizedString(forIdentifier: cleanedID) ?? cleanedID
|
||||
}
|
||||
|
||||
private var levelMeter: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(title: "Live level", showsDivider: false) {
|
||||
MicLevelBar(level: self.meterLevel)
|
||||
.frame(width: self.controlWidth, alignment: .leading)
|
||||
Text(self.levelLabel)
|
||||
.font(.callout.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
}
|
||||
if let meterError {
|
||||
Text(meterError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var levelLabel: String {
|
||||
let db = (meterLevel * 50) - 50
|
||||
return String(format: "%.0f dB", db)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func restartMeter() async {
|
||||
guard voiceWakeSupported else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
return
|
||||
}
|
||||
guard self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
return
|
||||
}
|
||||
self.meterError = nil
|
||||
await self.meter.stop()
|
||||
guard !Task.isCancelled, self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await self.meter.start { [weak state] level in
|
||||
Task { @MainActor in
|
||||
guard state != nil else { return }
|
||||
self.meterLevel = level
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled, self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
return
|
||||
}
|
||||
self.state.voiceWakeMeterActive = true
|
||||
} catch {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
self.meterError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TriggerPhraseRow: View {
|
||||
@Binding var value: String
|
||||
let showsDivider: Bool
|
||||
let onSubmit: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "quote.opening")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24)
|
||||
|
||||
TextField("Wake phrase", text: self.$value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.callout.weight(.medium))
|
||||
.frame(maxWidth: 420)
|
||||
.onSubmit(self.onSubmit)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button(action: self.onRemove) {
|
||||
Image(systemName: "trash")
|
||||
.font(.callout)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 26, height: 26)
|
||||
.contentShape(Rectangle())
|
||||
.help("Remove trigger word")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.overlay(alignment: .bottom) {
|
||||
if self.showsDivider {
|
||||
Divider()
|
||||
.padding(.leading, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AdditionalLanguageRow: View {
|
||||
let index: Int
|
||||
@Binding var selection: String
|
||||
let localeIDs: [String]
|
||||
let localeName: (String) -> String
|
||||
let showsDivider: Bool
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SettingsCardRow(
|
||||
title: "Language \(self.index + 2)",
|
||||
subtitle: "Fallback recognition language.",
|
||||
showsDivider: self.showsDivider)
|
||||
{
|
||||
HStack(spacing: 10) {
|
||||
Picker("Language \(self.index + 2)", selection: self.$selection) {
|
||||
ForEach(self.localeIDs, id: \.self) { id in
|
||||
Text(self.localeName(id)).tag(id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 220)
|
||||
|
||||
Button(action: self.onRemove) {
|
||||
Image(systemName: "trash")
|
||||
.font(.callout)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 26, height: 26)
|
||||
.contentShape(Rectangle())
|
||||
.help("Remove language")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TriggerPhraseHelpRow: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 18)
|
||||
.padding(.top, 1)
|
||||
|
||||
Text(
|
||||
"OpenClaw reacts when any trigger appears in a transcription. " +
|
||||
"Keep phrases short to avoid false positives.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct VoiceWakeSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VoiceWakeSettings(state: .preview, isActive: true)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension VoiceWakeSettings {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
state.swabbleEnabled = true
|
||||
state.voicePushToTalkEnabled = true
|
||||
state.swabbleTriggerWords = ["Claude", "Hey"]
|
||||
|
||||
let view = VoiceWakeSettings(state: state, isActive: true)
|
||||
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
||||
view.availableLocales = [Locale(identifier: "en_US")]
|
||||
view.meterLevel = 0.42
|
||||
view.meterError = "No input"
|
||||
view.testState = .detected("ok")
|
||||
view.isTesting = true
|
||||
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
|
||||
|
||||
_ = view.body
|
||||
_ = view.localePicker
|
||||
_ = view.micPicker
|
||||
_ = view.levelMeter
|
||||
_ = view.triggerTable
|
||||
_ = view.chimeSection
|
||||
_ = view.unsupportedVoiceWakePanel
|
||||
|
||||
view.addWord()
|
||||
if let entryId = view.triggerEntries.first?.id {
|
||||
view.removeWord(id: entryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user