From b508f642b2b237e4da13640f5905bfa93a35103b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 23:49:18 +0000 Subject: [PATCH] iOS: configurable voice wake words --- apps/ios/Sources/Settings/SettingsTab.swift | 9 +++ .../Settings/VoiceWakeWordsSettingsView.swift | 69 +++++++++++++++++++ apps/ios/Sources/Voice/VoiceTab.swift | 15 +++- apps/ios/Sources/Voice/VoiceWakeManager.swift | 58 ++++++++++++++-- .../Sources/Voice/VoiceWakePreferences.swift | 29 ++++++++ 5 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift create mode 100644 apps/ios/Sources/Voice/VoiceWakePreferences.swift diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 345a445b7..e10d57505 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -11,6 +11,7 @@ extension ConnectStatusStore: @unchecked Sendable {} struct SettingsTab: View { @EnvironmentObject private var appModel: NodeAppModel + @EnvironmentObject private var voiceWake: VoiceWakeManager @Environment(\.dismiss) private var dismiss @AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @@ -47,6 +48,14 @@ struct SettingsTab: View { .onChange(of: self.voiceWakeEnabled) { _, newValue in self.appModel.setVoiceWakeEnabled(newValue) } + + NavigationLink { + VoiceWakeWordsSettingsView() + } label: { + LabeledContent( + "Wake Words", + value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords)) + } } Section("Bridge") { diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift new file mode 100644 index 000000000..1f915199f --- /dev/null +++ b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct VoiceWakeWordsSettingsView: View { + @State private var triggerWords: [String] = [] + + var body: some View { + Form { + Section { + ForEach(self.triggerWords.indices, id: \.self) { index in + TextField("Wake word", text: self.binding(for: index)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + .onDelete(perform: self.removeWords) + + Button { + self.addWord() + } label: { + Label("Add word", systemImage: "plus") + } + .disabled(self.triggerWords + .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + + Button("Reset defaults") { + self.triggerWords = VoiceWakePreferences.defaultTriggerWords + } + } header: { + Text("Wake Words") + } footer: { + Text( + "Clawdis reacts when any trigger appears in a transcription. " + + "Keep them short to avoid false positives.") + } + } + .navigationTitle("Wake Words") + .toolbar { EditButton() } + .task { + if self.triggerWords.isEmpty { + self.triggerWords = VoiceWakePreferences.loadTriggerWords() + } + } + .onChange(of: self.triggerWords) { _, newValue in + VoiceWakePreferences.saveTriggerWords(newValue) + } + } + + private func addWord() { + self.triggerWords.append("") + } + + private func removeWords(at offsets: IndexSet) { + self.triggerWords.remove(atOffsets: offsets) + if self.triggerWords.isEmpty { + self.triggerWords = VoiceWakePreferences.defaultTriggerWords + } + } + + private func binding(for index: Int) -> Binding { + Binding( + get: { + guard self.triggerWords.indices.contains(index) else { return "" } + return self.triggerWords[index] + }, + set: { newValue in + guard self.triggerWords.indices.contains(index) else { return } + self.triggerWords[index] = newValue + }) + } +} diff --git a/apps/ios/Sources/Voice/VoiceTab.swift b/apps/ios/Sources/Voice/VoiceTab.swift index 040f3782d..53e762f3b 100644 --- a/apps/ios/Sources/Voice/VoiceTab.swift +++ b/apps/ios/Sources/Voice/VoiceTab.swift @@ -17,8 +17,19 @@ struct VoiceTab: View { } Section("Notes") { - Text("Say “clawdis …” to trigger.") - .foregroundStyle(.secondary) + let triggers = self.voiceWake.activeTriggerWords + Group { + if triggers.isEmpty { + Text("Add wake words in Settings.") + } else if triggers.count == 1 { + Text("Say “\(triggers[0]) …” to trigger.") + } else if triggers.count == 2 { + Text("Say “\(triggers[0]) …” or “\(triggers[1]) …” to trigger.") + } else { + Text("Say “\(triggers.joined(separator: " …”, “")) …” to trigger.") + } + } + .foregroundStyle(.secondary) } } .navigationTitle("Voice") diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index ea42da998..348d0bd78 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -80,6 +80,7 @@ final class VoiceWakeManager: NSObject, ObservableObject { @Published var isEnabled: Bool = false @Published var isListening: Bool = false @Published var statusText: String = "Off" + @Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() private let audioEngine = AVAudioEngine() private var speechRecognizer: SFSpeechRecognizer? @@ -91,6 +92,36 @@ final class VoiceWakeManager: NSObject, ObservableObject { private var lastDispatched: String? private var onCommand: (@Sendable (String) async -> Void)? + override init() { + super.init() + self.triggerWords = VoiceWakePreferences.loadTriggerWords() + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleUserDefaultsDidChange), + name: UserDefaults.didChangeNotification, + object: UserDefaults.standard) + } + + deinit { + NotificationCenter.default.removeObserver( + self, + name: UserDefaults.didChangeNotification, + object: UserDefaults.standard) + } + + var activeTriggerWords: [String] { + VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords) + } + + @objc private func handleUserDefaultsDidChange() { + let updated = VoiceWakePreferences.loadTriggerWords() + Task { @MainActor in + if updated != self.triggerWords { + self.triggerWords = updated + } + } + } + func configure(onCommand: @escaping @Sendable (String) async -> Void) { self.onCommand = onCommand } @@ -267,12 +298,29 @@ final class VoiceWakeManager: NSObject, ObservableObject { } private func extractCommand(from transcript: String) -> String? { - let lower = transcript.lowercased() - guard let range = lower.range(of: "clawdis", options: .backwards) else { return nil } - let after = lower[range.upperBound...] + Self.extractCommand(from: transcript, triggers: self.activeTriggerWords) + } + + private static func extractCommand(from transcript: String, triggers: [String]) -> String? { + var bestRange: Range? + for trigger in triggers { + let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { continue } + guard let range = transcript.range(of: token, options: [.caseInsensitive, .backwards]) else { continue } + if let currentBest = bestRange { + if range.lowerBound > currentBest.lowerBound { + bestRange = range + } + } else { + bestRange = range + } + } + + guard let bestRange else { return nil } + let after = transcript[bestRange.upperBound...] let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - return trimmed + guard !trimmed.isEmpty else { return nil } + return String(trimmed) } private static func configureAudioSession() throws { diff --git a/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/apps/ios/Sources/Voice/VoiceWakePreferences.swift new file mode 100644 index 000000000..3ee9284f9 --- /dev/null +++ b/apps/ios/Sources/Voice/VoiceWakePreferences.swift @@ -0,0 +1,29 @@ +import Foundation + +enum VoiceWakePreferences { + static let enabledKey = "voiceWake.enabled" + static let triggerWordsKey = "voiceWake.triggerWords" + + // Keep defaults aligned with the mac app. + static let defaultTriggerWords: [String] = ["clawd", "claude"] + + static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] { + defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords + } + + static func saveTriggerWords(_ words: [String], defaults: UserDefaults = .standard) { + defaults.set(words, forKey: self.triggerWordsKey) + } + + static func sanitizeTriggerWords(_ words: [String]) -> [String] { + let cleaned = words + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned + } + + static func displayString(for words: [String]) -> String { + let sanitized = self.sanitizeTriggerWords(words) + return sanitized.joined(separator: ", ") + } +}