VoiceWake: keep listening until silence, gate enable on permissions
This commit is contained in:
@@ -102,6 +102,10 @@ final class AppState: ObservableObject {
|
|||||||
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
|
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
|
||||||
self.voiceWakeForwardCommand = UserDefaults.standard
|
self.voiceWakeForwardCommand = UserDefaults.standard
|
||||||
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
|
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
|
||||||
|
|
||||||
|
if self.swabbleEnabled && !PermissionManager.voiceWakePermissionsGranted() {
|
||||||
|
self.swabbleEnabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerVoiceEars(ttl: TimeInterval = 5) {
|
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) {
|
func setWorking(_ working: Bool) {
|
||||||
self.isWorking = working
|
self.isWorking = working
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ private struct MenuContent: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: self.activeBinding) { Text("Clawdis Active") }
|
Toggle(isOn: self.activeBinding) { Text("Clawdis Active") }
|
||||||
self.relayStatusRow
|
self.relayStatusRow
|
||||||
Toggle(isOn: self.$state.swabbleEnabled) { Text("Voice Wake") }
|
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||||
.disabled(!voiceWakeSupported)
|
.disabled(!voiceWakeSupported)
|
||||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||||
Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) }
|
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 })
|
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 {
|
private func primarySessionKey() -> String {
|
||||||
// Prefer canonical main session; fall back to most recent.
|
// Prefer canonical main session; fall back to most recent.
|
||||||
let storePath = SessionLoader.defaultStorePath
|
let storePath = SessionLoader.defaultStorePath
|
||||||
|
|||||||
@@ -82,6 +82,17 @@ enum PermissionManager {
|
|||||||
return results
|
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] {
|
static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] {
|
||||||
var results: [Capability: Bool] = [:]
|
var results: [Capability: Bool] = [:]
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ final class VoiceWakeTester {
|
|||||||
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||||
private var recognitionTask: SFSpeechRecognitionTask?
|
private var recognitionTask: SFSpeechRecognitionTask?
|
||||||
private var isStopping = false
|
private var isStopping = false
|
||||||
|
private var detectionStart: Date?
|
||||||
|
private var lastHeard: Date?
|
||||||
|
private var holdingAfterDetect = false
|
||||||
|
private var detectedText: String?
|
||||||
|
|
||||||
init(locale: Locale = .current) {
|
init(locale: Locale = .current) {
|
||||||
self.recognizer = SFSpeechRecognizer(locale: locale)
|
self.recognizer = SFSpeechRecognizer(locale: locale)
|
||||||
@@ -143,6 +147,9 @@ final class VoiceWakeTester {
|
|||||||
onUpdate(.listening)
|
onUpdate(.listening)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.detectionStart = Date()
|
||||||
|
self.lastHeard = self.detectionStart
|
||||||
|
|
||||||
guard let request = recognitionRequest else { return }
|
guard let request = recognitionRequest else { return }
|
||||||
|
|
||||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
@@ -180,14 +187,19 @@ final class VoiceWakeTester {
|
|||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
|
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
|
||||||
{
|
{
|
||||||
|
if !text.isEmpty {
|
||||||
|
self.lastHeard = Date()
|
||||||
|
}
|
||||||
if matched, !text.isEmpty {
|
if matched, !text.isEmpty {
|
||||||
self.stop()
|
self.holdingAfterDetect = true
|
||||||
|
self.detectedText = text
|
||||||
AppStateStore.shared.triggerVoiceEars()
|
AppStateStore.shared.triggerVoiceEars()
|
||||||
let config = AppStateStore.shared.voiceWakeForwardConfig
|
let config = AppStateStore.shared.voiceWakeForwardConfig
|
||||||
Task.detached {
|
Task.detached {
|
||||||
await VoiceWakeForwarder.forward(transcript: text, config: config)
|
await VoiceWakeForwarder.forward(transcript: text, config: config)
|
||||||
}
|
}
|
||||||
onUpdate(.detected(text))
|
onUpdate(.detected(text))
|
||||||
|
self.holdUntilSilence(onUpdate: onUpdate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let errorMessage {
|
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?) {
|
private func configureSession(preferredMicID: String?) {
|
||||||
_ = preferredMicID
|
_ = preferredMicID
|
||||||
}
|
}
|
||||||
@@ -262,6 +296,14 @@ struct VoiceWakeSettings: View {
|
|||||||
@State private var showForwardAdvanced = false
|
@State private var showForwardAdvanced = false
|
||||||
@State private var forwardStatus: ForwardStatus = .idle
|
@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 {
|
private struct IndexedWord: Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let value: String
|
let value: String
|
||||||
@@ -274,7 +316,7 @@ struct VoiceWakeSettings: View {
|
|||||||
title: "Enable Voice Wake",
|
title: "Enable Voice Wake",
|
||||||
subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. "
|
subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. "
|
||||||
+ "Voice recognition runs fully on-device.",
|
+ "Voice recognition runs fully on-device.",
|
||||||
binding: self.$state.swabbleEnabled)
|
binding: self.voiceWakeBinding)
|
||||||
.disabled(!voiceWakeSupported)
|
.disabled(!voiceWakeSupported)
|
||||||
|
|
||||||
if !voiceWakeSupported {
|
if !voiceWakeSupported {
|
||||||
|
|||||||
Reference in New Issue
Block a user