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

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