refactor: share voice wake text utils
This commit is contained in:
@@ -380,6 +380,8 @@ actor VoiceWakeRuntime {
|
|||||||
capturing: Bool)
|
capturing: Bool)
|
||||||
{
|
{
|
||||||
guard !transcript.isEmpty else { return }
|
guard !transcript.isEmpty else { return }
|
||||||
|
let level = self.logger.logLevel
|
||||||
|
guard level == .debug || level == .trace else { return }
|
||||||
if transcript == self.lastLoggedText, !isFinal {
|
if transcript == self.lastLoggedText, !isFinal {
|
||||||
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
||||||
return
|
return
|
||||||
@@ -490,16 +492,18 @@ actor VoiceWakeRuntime {
|
|||||||
triggers: [String],
|
triggers: [String],
|
||||||
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||||
{
|
{
|
||||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil }
|
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||||
guard Self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil }
|
transcript: transcript,
|
||||||
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: triggers)
|
triggers: triggers,
|
||||||
guard trimmed.count >= config.minCommandLength else { return nil }
|
minCommandLength: config.minCommandLength,
|
||||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: trimmed)
|
trimWake: Self.trimmedAfterTrigger)
|
||||||
|
else { return nil }
|
||||||
|
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
|
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
|
||||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
|
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
|
return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,34 +746,6 @@ actor VoiceWakeRuntime {
|
|||||||
return text
|
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(
|
private static func commandAfterTrigger(
|
||||||
transcript: String,
|
transcript: String,
|
||||||
segments: [WakeWordSegment],
|
segments: [WakeWordSegment],
|
||||||
|
|||||||
@@ -370,43 +370,13 @@ struct VoiceWakeSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func textOnlyCommand(from transcript: String, triggers: [String]) -> String? {
|
private static func textOnlyCommand(from transcript: String, triggers: [String]) -> String? {
|
||||||
guard !transcript.isEmpty else { return nil }
|
VoiceWakeTextUtils.textOnlyCommand(
|
||||||
let normalized = self.normalizeToken(transcript)
|
transcript: transcript,
|
||||||
guard !normalized.isEmpty else { return nil }
|
triggers: triggers,
|
||||||
guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil }
|
minCommandLength: 1,
|
||||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil }
|
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||||
let trimmed = WakeWordGate.stripWake(text: transcript, triggers: triggers)
|
|
||||||
return trimmed.isEmpty ? nil : 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private var micPicker: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
|
|||||||
@@ -266,6 +266,8 @@ final class VoiceWakeTester {
|
|||||||
isFinal: Bool)
|
isFinal: Bool)
|
||||||
{
|
{
|
||||||
guard !transcript.isEmpty else { return }
|
guard !transcript.isEmpty else { return }
|
||||||
|
let level = self.logger.logLevel
|
||||||
|
guard level == .debug || level == .trace else { return }
|
||||||
if transcript == self.lastLoggedText, !isFinal {
|
if transcript == self.lastLoggedText, !isFinal {
|
||||||
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
||||||
return
|
return
|
||||||
@@ -333,7 +335,7 @@ final class VoiceWakeTester {
|
|||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let tokens = trigger
|
let tokens = trigger
|
||||||
.split(whereSeparator: { $0.isWhitespace })
|
.split(whereSeparator: { $0.isWhitespace })
|
||||||
.map { self.normalizeToken(String($0)) }
|
.map { VoiceWakeTextUtils.normalizeToken(String($0)) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
if tokens.isEmpty { continue }
|
if tokens.isEmpty { continue }
|
||||||
output.append(DebugTriggerTokens(tokens: tokens))
|
output.append(DebugTriggerTokens(tokens: tokens))
|
||||||
@@ -343,7 +345,7 @@ final class VoiceWakeTester {
|
|||||||
|
|
||||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] {
|
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] {
|
||||||
segments.compactMap { segment in
|
segments.compactMap { segment in
|
||||||
let normalized = self.normalizeToken(segment.text)
|
let normalized = VoiceWakeTextUtils.normalizeToken(segment.text)
|
||||||
guard !normalized.isEmpty else { return nil }
|
guard !normalized.isEmpty else { return nil }
|
||||||
return DebugToken(
|
return DebugToken(
|
||||||
normalized: normalized,
|
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(
|
private func textOnlyFallbackMatch(
|
||||||
transcript: String,
|
transcript: String,
|
||||||
triggers: [String],
|
triggers: [String],
|
||||||
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||||
{
|
{
|
||||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil }
|
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||||
guard Self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil }
|
transcript: transcript,
|
||||||
let trimmed = WakeWordGate.stripWake(text: transcript, triggers: triggers)
|
triggers: triggers,
|
||||||
guard trimmed.count >= config.minCommandLength else { return nil }
|
minCommandLength: config.minCommandLength,
|
||||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: trimmed)
|
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||||
}
|
else { return nil }
|
||||||
|
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||||
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 func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
||||||
|
|||||||
47
apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift
Normal file
47
apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user