VoiceWake: keep listening until silence, gate enable on permissions

This commit is contained in:
Peter Steinberger
2025-12-07 02:18:37 +01:00
parent 9d0415f9e9
commit e906b87450
4 changed files with 88 additions and 3 deletions

View File

@@ -102,6 +102,10 @@ final class AppState: ObservableObject {
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
self.voiceWakeForwardCommand = UserDefaults.standard
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
if self.swabbleEnabled && !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false
}
}
func triggerVoiceEars(ttl: TimeInterval = 5) {
@@ -113,6 +117,26 @@ final class AppState: ObservableObject {
}
}
func setVoiceWakeEnabled(_ enabled: Bool) async {
guard voiceWakeSupported else {
self.swabbleEnabled = false
return
}
if !enabled {
self.swabbleEnabled = false
return
}
if PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = true
return
}
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
self.swabbleEnabled = granted
}
func setWorking(_ working: Bool) {
self.isWorking = working
}

View File

@@ -55,7 +55,7 @@ private struct MenuContent: View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) { Text("Clawdis Active") }
self.relayStatusRow
Toggle(isOn: self.$state.swabbleEnabled) { Text("Voice Wake") }
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) }
@@ -101,6 +101,14 @@ private struct MenuContent: View {
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
}
private var voiceWakeBinding: Binding<Bool> {
Binding(
get: { self.state.swabbleEnabled },
set: { newValue in
Task { await self.state.setVoiceWakeEnabled(newValue) }
})
}
private func primarySessionKey() -> String {
// Prefer canonical main session; fall back to most recent.
let storePath = SessionLoader.defaultStorePath

View File

@@ -82,6 +82,17 @@ enum PermissionManager {
return results
}
static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
return mic && speech
}
static func ensureVoiceWakePermissions(interactive: Bool) async -> Bool {
let results = await self.ensure([.microphone, .speechRecognition], interactive: interactive)
return results[.microphone] == true && results[.speechRecognition] == true
}
static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:]
for cap in caps {

View File

@@ -82,6 +82,10 @@ final class VoiceWakeTester {
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var isStopping = false
private var detectionStart: Date?
private var lastHeard: Date?
private var holdingAfterDetect = false
private var detectedText: String?
init(locale: Locale = .current) {
self.recognizer = SFSpeechRecognizer(locale: locale)
@@ -143,6 +147,9 @@ final class VoiceWakeTester {
onUpdate(.listening)
}
self.detectionStart = Date()
self.lastHeard = self.detectionStart
guard let request = recognitionRequest else { return }
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
@@ -180,14 +187,19 @@ final class VoiceWakeTester {
errorMessage: String?,
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
{
if !text.isEmpty {
self.lastHeard = Date()
}
if matched, !text.isEmpty {
self.stop()
self.holdingAfterDetect = true
self.detectedText = text
AppStateStore.shared.triggerVoiceEars()
let config = AppStateStore.shared.voiceWakeForwardConfig
Task.detached {
await VoiceWakeForwarder.forward(transcript: text, config: config)
}
onUpdate(.detected(text))
self.holdUntilSilence(onUpdate: onUpdate)
return
}
if let errorMessage {
@@ -203,6 +215,28 @@ final class VoiceWakeTester {
}
}
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
Task { @MainActor [weak self] in
guard let self else { return }
let start = self.detectionStart ?? Date()
let deadline = start.addingTimeInterval(10)
while !self.isStopping {
let now = Date()
if now >= deadline { break }
if let last = self.lastHeard, now.timeIntervalSince(last) >= 1 {
break
}
try? await Task.sleep(nanoseconds: 250_000_000)
}
if !self.isStopping {
self.stop()
if let detectedText {
onUpdate(.detected(detectedText))
}
}
}
}
private func configureSession(preferredMicID: String?) {
_ = preferredMicID
}
@@ -262,6 +296,14 @@ struct VoiceWakeSettings: View {
@State private var showForwardAdvanced = false
@State private var forwardStatus: ForwardStatus = .idle
private var voiceWakeBinding: Binding<Bool> {
Binding(
get: { self.state.swabbleEnabled },
set: { newValue in
Task { await self.state.setVoiceWakeEnabled(newValue) }
})
}
private struct IndexedWord: Identifiable {
let id: Int
let value: String
@@ -274,7 +316,7 @@ struct VoiceWakeSettings: View {
title: "Enable Voice Wake",
subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. "
+ "Voice recognition runs fully on-device.",
binding: self.$state.swabbleEnabled)
binding: self.voiceWakeBinding)
.disabled(!voiceWakeSupported)
if !voiceWakeSupported {