From d9482719fbe31efc0460fa15810f3d36d38e54a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 01:44:51 +0000 Subject: [PATCH] refactor: share voice wake text utils --- .../Sources/Clawdbot/VoiceWakeRuntime.swift | 44 ++++------------- .../Sources/Clawdbot/VoiceWakeSettings.swift | 40 ++-------------- .../Sources/Clawdbot/VoiceWakeTester.swift | 46 +++++------------- .../Sources/Clawdbot/VoiceWakeTextUtils.swift | 47 +++++++++++++++++++ 4 files changed, 73 insertions(+), 104 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift index 5ed48daea..06ebfb7ae 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift @@ -380,6 +380,8 @@ actor VoiceWakeRuntime { capturing: Bool) { guard !transcript.isEmpty else { return } + let level = self.logger.logLevel + guard level == .debug || level == .trace else { return } if transcript == self.lastLoggedText, !isFinal { if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { return @@ -490,16 +492,18 @@ actor VoiceWakeRuntime { triggers: [String], config: WakeWordGateConfig) -> WakeWordGateMatch? { - guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil } - guard Self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil } - let trimmed = Self.trimmedAfterTrigger(transcript, triggers: triggers) - guard trimmed.count >= config.minCommandLength else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: trimmed) + guard let command = VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: config.minCommandLength, + trimWake: Self.trimmedAfterTrigger) + else { return nil } + return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) } private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool { guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false } - guard Self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false } + guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false } return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty } @@ -742,34 +746,6 @@ actor VoiceWakeRuntime { return text } - private static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool { - let tokens = transcript - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !tokens.isEmpty else { return false } - for trigger in triggers { - let triggerTokens = trigger - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue } - if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) { - return true - } - } - return false - } - - private static func normalizeToken(_ token: String) -> String { - token - .trimmingCharacters(in: self.whitespaceAndPunctuation) - .lowercased() - } - - private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines - .union(.punctuationCharacters) - private static func commandAfterTrigger( transcript: String, segments: [WakeWordSegment], diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift index 92be13199..176980cc5 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift @@ -370,43 +370,13 @@ struct VoiceWakeSettings: View { } private static func textOnlyCommand(from transcript: String, triggers: [String]) -> String? { - guard !transcript.isEmpty else { return nil } - let normalized = self.normalizeToken(transcript) - guard !normalized.isEmpty else { return nil } - guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil } - guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil } - let trimmed = WakeWordGate.stripWake(text: transcript, triggers: triggers) - return trimmed.isEmpty ? nil : trimmed + VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: 1, + trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) } - private static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool { - let tokens = transcript - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !tokens.isEmpty else { return false } - for trigger in triggers { - let triggerTokens = trigger - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue } - if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) { - return true - } - } - return false - } - - private static func normalizeToken(_ token: String) -> String { - token - .trimmingCharacters(in: self.whitespaceAndPunctuation) - .lowercased() - } - - private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines - .union(.punctuationCharacters) - private var micPicker: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift b/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift index 34b106080..bf6a883ab 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift @@ -266,6 +266,8 @@ final class VoiceWakeTester { isFinal: Bool) { guard !transcript.isEmpty else { return } + let level = self.logger.logLevel + guard level == .debug || level == .trace else { return } if transcript == self.lastLoggedText, !isFinal { if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { return @@ -333,7 +335,7 @@ final class VoiceWakeTester { for trigger in triggers { let tokens = trigger .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } + .map { VoiceWakeTextUtils.normalizeToken(String($0)) } .filter { !$0.isEmpty } if tokens.isEmpty { continue } output.append(DebugTriggerTokens(tokens: tokens)) @@ -343,7 +345,7 @@ final class VoiceWakeTester { private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] { segments.compactMap { segment in - let normalized = self.normalizeToken(segment.text) + let normalized = VoiceWakeTextUtils.normalizeToken(segment.text) guard !normalized.isEmpty else { return nil } return DebugToken( normalized: normalized, @@ -352,44 +354,18 @@ final class VoiceWakeTester { } } - private static func normalizeToken(_ token: String) -> String { - token - .trimmingCharacters(in: self.whitespaceAndPunctuation) - .lowercased() - } - - private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines - .union(.punctuationCharacters) - private func textOnlyFallbackMatch( transcript: String, triggers: [String], config: WakeWordGateConfig) -> WakeWordGateMatch? { - guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil } - guard Self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil } - let trimmed = WakeWordGate.stripWake(text: transcript, triggers: triggers) - guard trimmed.count >= config.minCommandLength else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: trimmed) - } - - private static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool { - let tokens = transcript - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !tokens.isEmpty else { return false } - for trigger in triggers { - let triggerTokens = trigger - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue } - if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) { - return true - } - } - return false + guard let command = VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: config.minCommandLength, + trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) + else { return nil } + return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) } private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) { diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift b/apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift new file mode 100644 index 000000000..8decebab9 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift @@ -0,0 +1,47 @@ +import Foundation +import SwabbleKit + +enum VoiceWakeTextUtils { + private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines + .union(.punctuationCharacters) + + static func normalizeToken(_ token: String) -> String { + token + .trimmingCharacters(in: self.whitespaceAndPunctuation) + .lowercased() + } + + static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool { + let tokens = transcript + .split(whereSeparator: { $0.isWhitespace }) + .map { self.normalizeToken(String($0)) } + .filter { !$0.isEmpty } + guard !tokens.isEmpty else { return false } + for trigger in triggers { + let triggerTokens = trigger + .split(whereSeparator: { $0.isWhitespace }) + .map { self.normalizeToken(String($0)) } + .filter { !$0.isEmpty } + guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue } + if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) { + return true + } + } + return false + } + + static func textOnlyCommand( + transcript: String, + triggers: [String], + minCommandLength: Int, + trimWake: (String, [String]) -> String + ) -> String? { + guard !transcript.isEmpty else { return nil } + guard !self.normalizeToken(transcript).isEmpty else { return nil } + guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil } + guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil } + let trimmed = trimWake(transcript, triggers) + guard trimmed.count >= minCommandLength else { return nil } + return trimmed + } +}