fix: accept trigger-only voice wake test

This commit is contained in:
Peter Steinberger
2026-05-02 02:28:37 +01:00
parent e9c61fba04
commit f8e16be711
6 changed files with 79 additions and 16 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash.
- macOS/Voice Wake: accept trigger-only phrases in the built-in Voice Wake test, matching the settings UI and runtime trigger-only path instead of requiring extra command text after the wake word. Fixes #64986. Thanks @zoiks65.
- Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled `[[tts]]` replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000.
- WhatsApp: save downloadable quoted image media from reply context as inbound media, so agents can inspect an image that a user replied to instead of only seeing `<media:image>`. Fixes #59174. Thanks @gaffner.
- Doctor/WhatsApp: warn when Linux crontabs still run the legacy `ensure-whatsapp.sh` health check, which can misreport `Gateway inactive` when cron lacks the systemd user-bus environment. Fixes #60204. Thanks @mySebbe.

View File

@@ -48,6 +48,23 @@ enum VoiceWakeRecognitionDebugSupport {
trigger: VoiceWakeTextUtils.matchedTriggerWord(transcript: transcript, triggers: triggers))
}
static func triggerOnlyFallbackMatch(
transcript: String,
triggers: [String],
trimWake: (String, [String]) -> String) -> WakeWordGateMatch?
{
guard VoiceWakeTextUtils.isTriggerOnly(
transcript: transcript,
triggers: triggers,
trimWake: trimWake)
else { return nil }
return WakeWordGateMatch(
triggerEndTime: 0,
postGap: 0,
command: "",
trigger: VoiceWakeTextUtils.matchedTriggerWord(transcript: transcript, triggers: triggers))
}
static func transcriptSummary(
transcript: String,
triggers: [String],

View File

@@ -517,12 +517,10 @@ actor VoiceWakeRuntime {
}
private static func isTriggerOnlyText(transcript: String, triggers: [String]) -> Bool {
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
guard
VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers)
|| VoiceWakeTextUtils.hasOnlyFillerBeforeTrigger(transcript: transcript, triggers: triggers)
else { return false }
return self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
VoiceWakeTextUtils.isTriggerOnly(
transcript: transcript,
triggers: triggers,
trimWake: self.trimmedAfterTrigger)
}
private static func matchedTriggerWordText(transcript: String, triggers: [String]) -> String? {

View File

@@ -230,15 +230,23 @@ final class VoiceWakeTester {
if self.holdingAfterDetect {
return
}
if let match, !match.command.isEmpty {
let triggerOnlyMatch = match == nil
? VoiceWakeRecognitionDebugSupport.triggerOnlyFallbackMatch(
transcript: text,
triggers: self.currentTriggers,
trimWake: WakeWordGate.stripWake)
: nil
let acceptedMatch = match.flatMap { $0.command.isEmpty ? nil : $0 } ?? triggerOnlyMatch
if let match = acceptedMatch {
self.holdingAfterDetect = true
self.detectedText = match.command
self.logger.info("voice wake detected (test) (len=\(match.command.count))")
let detectedText = match.command.isEmpty ? (match.trigger ?? text) : match.command
self.detectedText = detectedText
self.logger.info("voice wake detected (test) (len=\(detectedText.count))")
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
self.stop()
await MainActor.run {
AppStateStore.shared.stopVoiceEars()
onUpdate(.detected(match.command))
onUpdate(.detected(detectedText))
}
return
}
@@ -399,20 +407,26 @@ final class VoiceWakeTester {
guard !self.isStopping, !self.holdingAfterDetect else { return }
guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
let gateConfig = WakeWordGateConfig(triggers: triggers)
let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: lastText,
triggers: triggers,
config: WakeWordGateConfig(triggers: triggers),
config: gateConfig,
trimWake: WakeWordGate.stripWake)
else { return }
?? VoiceWakeRecognitionDebugSupport.triggerOnlyFallbackMatch(
transcript: lastText,
triggers: triggers,
trimWake: WakeWordGate.stripWake)
guard let match else { return }
self.holdingAfterDetect = true
self.detectedText = match.command
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
let detectedText = match.command.isEmpty ? (match.trigger ?? lastText) : match.command
self.detectedText = detectedText
self.logger.info("voice wake detected (test, silence) (len=\(detectedText.count))")
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
self.stop()
await MainActor.run {
AppStateStore.shared.stopVoiceEars()
onUpdate(.detected(match.command))
onUpdate(.detected(detectedText))
}
}
}

View File

@@ -149,6 +149,19 @@ enum VoiceWakeTextUtils {
return trimmed
}
static func isTriggerOnly(
transcript: String,
triggers: [String],
trimWake: TrimWake) -> Bool
{
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
guard
self.startsWithTrigger(transcript: transcript, triggers: triggers)
|| self.hasOnlyFillerBeforeTrigger(transcript: transcript, triggers: triggers)
else { return false }
return trimWake(transcript, triggers).isEmpty
}
static func hasOnlyFillerBeforeTrigger(transcript: String, triggers: [String]) -> Bool {
guard let match = self.bestRawTriggerMatch(transcript: transcript, triggers: triggers) else { return false }
let prefixTokens = transcript[..<match.range.lowerBound]

View File

@@ -1,6 +1,7 @@
import Foundation
import SwabbleKit
import Testing
@testable import OpenClaw
struct VoiceWakeTesterTests {
@Test func `match respects gap requirement`() {
@@ -30,4 +31,23 @@ struct VoiceWakeTesterTests {
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
@Test func `trigger only fallback accepts bare test trigger`() {
let match = VoiceWakeRecognitionDebugSupport.triggerOnlyFallbackMatch(
transcript: "hey openclaw",
triggers: ["openclaw"],
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
#expect(match?.command == "")
#expect(match?.trigger == "openclaw")
}
@Test func `trigger only fallback rejects trailing mention`() {
let match = VoiceWakeRecognitionDebugSupport.triggerOnlyFallbackMatch(
transcript: "tell me about openclaw",
triggers: ["openclaw"],
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
#expect(match == nil)
}
}