iOS: configurable voice wake words
This commit is contained in:
@@ -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") {
|
||||
|
||||
69
apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift
Normal file
69
apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
29
apps/ios/Sources/Voice/VoiceWakePreferences.swift
Normal file
29
apps/ios/Sources/Voice/VoiceWakePreferences.swift
Normal 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: ", ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user