Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/research/issue-1264.md

Large diffs are not rendered by default.

284 changes: 226 additions & 58 deletions openless-all/app/crates/openless-core/src/api.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[
shape: PreferenceShape::Boolean,
reason: "local_only_microphone_capture",
},
PreferenceField {
rust_name: "voice_activation_hotkey_linked",
key: "voiceActivationHotkeyLinked",
class: PreferenceClass::Excluded,
shape: PreferenceShape::Boolean,
reason: "local_only_microphone_capture",
},
PreferenceField {
rust_name: "voice_activation_wake_enabled",
key: "voiceActivationWakeEnabled",
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/crates/openless-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub mod testing;
pub mod types;
pub mod vocabulary;
pub mod voice_activation;
mod voice_activation_hotkey;
pub mod voice_edit_session;
mod voice_session;

Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/crates/openless-core/src/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ pub struct VoiceDictationHandoff {
/// decides lifecycle and stale-event policy; the host decides how local ASR and
/// the native capture stream are implemented.
pub trait VoiceActivationRuntime: Send + Sync + 'static {
/// Apply runtime-only standby intent even before a recognizer exists.
fn set_capture_paused(&self, _revision: u64, _paused: bool) {}

fn configure(
&self,
config: VoiceActivationConfig,
Expand Down
7 changes: 7 additions & 0 deletions openless-all/app/crates/openless-core/src/shared_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,8 @@ pub struct UserPreferences {
/// Opt-in local voice command listener. Standby audio is never sent to a cloud provider.
#[serde(default)]
pub voice_activation_enabled: bool,
#[serde(default)]
pub voice_activation_hotkey_linked: bool,
#[serde(default = "default_true")]
pub voice_activation_wake_enabled: bool,
#[serde(default = "default_true")]
Expand Down Expand Up @@ -1045,6 +1047,8 @@ struct UserPreferencesWire {
microphone_device_name: String,
#[serde(default)]
voice_activation_enabled: bool,
#[serde(default)]
voice_activation_hotkey_linked: bool,
#[serde(default = "default_true")]
voice_activation_wake_enabled: bool,
#[serde(default = "default_true")]
Expand Down Expand Up @@ -1323,6 +1327,7 @@ impl Default for UserPreferencesWire {
silence_auto_stop_seconds: prefs.silence_auto_stop_seconds,
microphone_device_name: prefs.microphone_device_name,
voice_activation_enabled: prefs.voice_activation_enabled,
voice_activation_hotkey_linked: prefs.voice_activation_hotkey_linked,
voice_activation_wake_enabled: prefs.voice_activation_wake_enabled,
voice_activation_finish_enabled: prefs.voice_activation_finish_enabled,
voice_activation_wake_phrase: prefs.voice_activation_wake_phrase,
Expand Down Expand Up @@ -1544,6 +1549,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
silence_auto_stop_seconds: wire.silence_auto_stop_seconds,
microphone_device_name: wire.microphone_device_name,
voice_activation_enabled: wire.voice_activation_enabled,
voice_activation_hotkey_linked: wire.voice_activation_hotkey_linked,
voice_activation_wake_enabled: wire.voice_activation_wake_enabled,
voice_activation_finish_enabled: wire.voice_activation_finish_enabled,
voice_activation_wake_phrase: wire.voice_activation_wake_phrase,
Expand Down Expand Up @@ -1943,6 +1949,7 @@ impl Default for UserPreferences {
silence_auto_stop_seconds: default_silence_auto_stop_seconds(),
microphone_device_name: String::new(),
voice_activation_enabled: false,
voice_activation_hotkey_linked: false,
voice_activation_wake_enabled: true,
voice_activation_finish_enabled: true,
voice_activation_wake_phrase: default_voice_activation_wake_phrase(),
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/crates/openless-core/src/voice_activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ pub enum VoiceActivationSignal {
#[serde(rename_all = "snake_case")]
pub enum VoiceActivationPhase {
Disabled,
Paused,
Pausing,
Starting,
ListeningForWake,
WakeDetected,
Expand Down
123 changes: 123 additions & 0 deletions openless-all/app/crates/openless-core/src/voice_activation_hotkey.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! Runtime-only intent and physical-click arbitration for linked voice activation.
use std::collections::{HashSet, VecDeque};
use std::time::{Duration, Instant};

use crate::api::DictationHotkeyEdge;

#[derive(Default)]
pub(crate) struct VoiceActivationLink {
linked: bool,
pub(crate) requested: bool,
pub(crate) revision: u64,
pub(crate) finalize_session: Option<crate::types::SessionId>,
pub(crate) finalize_generation: Option<u64>,
pressed: Option<(u64, Instant)>,
consumed: VecDeque<u64>,
combined: HashSet<u64>,
last_click: Option<Instant>,
}

impl VoiceActivationLink {
pub(crate) fn configure(&mut self, linked: bool) {
if self.linked != linked {
self.linked = linked;
self.requested = false;
self.revision += 1;
self.pressed = None;
self.consumed.clear();
self.combined.clear();
self.last_click = None;
}
}

pub(crate) fn pause(&mut self) {
self.requested = false;
self.revision += 1;
}

pub(crate) fn edge(&mut self, edge: DictationHotkeyEdge) -> bool {
match edge {
DictationHotkeyEdge::Pressed { press_id, at } => {
if !self.consumed.contains(&press_id) {
// A fresh physical ID supersedes a release lost while the
// native shortcut recorder or registration was suspended.
if self.pressed.map(|press| press.0) != Some(press_id) {
self.pressed = Some((press_id, at));
}
}
}
DictationHotkeyEdge::Combined { press_id, .. } => {
if self.combined.len() >= 64 {
self.combined.clear();
}
self.combined.insert(press_id);
}
DictationHotkeyEdge::Released { press_id, at } => {
if self.pressed.map(|press| press.0) != Some(press_id) {
return false;
}
let (_, pressed_at) = self.pressed.take().expect("matched physical press");
self.consumed.push_back(press_id);
if self.consumed.len() > 64 {
self.consumed.pop_front();
}
if self.combined.remove(&press_id) || at < pressed_at {
return false;
}
if self.last_click.is_some_and(|last| {
at.saturating_duration_since(last) < Duration::from_millis(250)
}) {
return false;
}
self.last_click = Some(at);
self.requested = !self.requested;
self.revision += 1;
return true;
}
}
false
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_fresh_press_recovers_after_a_suppressed_release() {
let mut state = VoiceActivationLink::default();
state.configure(true);
let at = Instant::now();
state.edge(DictationHotkeyEdge::Pressed { press_id: 1, at });
state.edge(DictationHotkeyEdge::Pressed { press_id: 2, at });
assert!(state.edge(DictationHotkeyEdge::Released { press_id: 2, at }));
assert!(state.requested);
assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 1, at }));
}

#[test]
fn linked_clicks_are_paired_debounced_and_combination_safe() {
let mut state = VoiceActivationLink::default();
state.configure(true);
let now = Instant::now();
assert!(!state.requested);
assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 1, at: now }));
assert!(!state.edge(DictationHotkeyEdge::Pressed { press_id: 1, at: now }));
assert!(!state.edge(DictationHotkeyEdge::Pressed { press_id: 1, at: now }));
assert!(state.edge(DictationHotkeyEdge::Released { press_id: 1, at: now }));
assert!(state.requested);
assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 1, at: now }));
state.edge(DictationHotkeyEdge::Pressed { press_id: 2, at: now });
assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 2, at: now }));
let later = now + Duration::from_secs(1);
state.edge(DictationHotkeyEdge::Combined { press_id: 3, at: later });
state.edge(DictationHotkeyEdge::Pressed { press_id: 3, at: later });
assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 3, at: later }));
state.edge(DictationHotkeyEdge::Pressed { press_id: 4, at: later });
assert!(state.edge(DictationHotkeyEdge::Released { press_id: 4, at: later }));
assert!(!state.requested);
state.configure(false);
state.configure(true);
assert!(!state.requested);
}
}
Loading
Loading