iOS: configurable voice wake words

This commit is contained in:
Peter Steinberger
2025-12-13 23:49:18 +00:00
parent 3fcee21ff7
commit b508f642b2
5 changed files with 173 additions and 7 deletions

View File

@@ -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") {

View File

@@ -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<String> {
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
})
}
}

View File

@@ -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")

View File

@@ -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<String.Index>?
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 {

View File

@@ -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: ", ")
}
}