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
68 changes: 67 additions & 1 deletion openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ pub(super) fn qa_event_target() -> &'static str {
use dictation::dictation_error_code;
use dictation::{
begin_session, begin_session_as, cancel_session, end_session, handle_pressed_edge,
handle_released_edge, request_stop_during_starting,
handle_released_edge, handle_trigger_combined, request_stop_during_starting,
};
#[cfg(any(debug_assertions, test))]
use dictation::{handle_pressed, handle_released};
Expand Down Expand Up @@ -476,6 +476,10 @@ struct Inner {
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
/// 本次热键按下是否真的「开出了」一个会话。TriggerCombined(触发键被当修饰键用)
/// 只撤销这一次按下开出来的会话:若这次按下其实是 toggle 停止 / 被冷却拦下 /
/// 路由给了 QA,就没有可撤销的东西,绝不能顺手取消正在转写的上一条。
hotkey_press_began_session: AtomicBool,
/// 防抖时间戳:handle_pressed_edge 入口检查与本字段的距离,< 250ms 的边沿直接
/// 丢弃(误触双击 / 微动开关回弹 / 用户连点过快造成的空转写报错)。
/// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防
Expand Down Expand Up @@ -672,6 +676,7 @@ impl Coordinator {
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
hotkey_press_began_session: AtomicBool::new(false),
last_hotkey_dispatch_at: Mutex::new(None),
hotkey_press_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
Expand Down Expand Up @@ -774,6 +779,7 @@ impl Coordinator {
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
hotkey_press_began_session: AtomicBool::new(false),
last_hotkey_dispatch_at: Mutex::new(None),
hotkey_press_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
Expand Down Expand Up @@ -3550,6 +3556,66 @@ mod tests {
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
}

// Option+任意字母/数字键:这次按下开出来的会话必须被撤销,且随后的松手边沿不能再被当成
// Auto 短按锁存(否则录音一直开着,正是用户报的「按 Option+其他键唤起听写」)。
#[tokio::test]
async fn trigger_combined_cancels_session_started_by_this_press() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
let pressed_at = std::time::Instant::now();
*coordinator.inner.hotkey_press_at.lock() = Some(pressed_at);
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(true, Ordering::SeqCst);

handle_trigger_combined(&coordinator.inner);

assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
// 组合键误触不算「刚用完一次听写」:不留冷却,否则紧接着真想说话的按下被吞。
assert!(coordinator.inner.session_cooldown_until.lock().is_none());

handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(80),
)
.await;

assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
}

// 这次按下是 toggle 停止(没开出会话)时,组合键撤销不能顺手取消正在跑的会话 ——
// 那条录音是上一次按下锁存的,取消 = 用户白说一段。
#[tokio::test]
async fn trigger_combined_leaves_session_it_did_not_start() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(false, Ordering::SeqCst);

handle_trigger_combined(&coordinator.inner);

assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}

#[test]
fn enabling_shortcut_recording_clears_dictation_hold_latch() {
let coordinator = Coordinator::new();
Expand Down
135 changes: 132 additions & 3 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(25
/// 时长以热键事件产生时携带的时间戳计算,避免串行 bridge 的排队延迟改变用户的物理按住时长。
/// 350ms 是「点一下 vs 明显按住」的自然分界。
const AUTO_HOLD_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(350);
/// modifier-only 触发键(Option / 右 Ctrl…)按下后的「组合键仲裁窗口」。
///
/// 按下这一刻还分不清用户是想说话,还是要打 Option+任意字母/数字键:修饰键的按下边沿两者完全一样。
/// 所以先等这么久再开会话——期间监听器若报告叠加了普通键,这次按下整条作废,麦克风
/// 不开、胶囊不闪、也不烧一次 ASR 建连。代价是听写起录晚这么多,取 150ms:足以覆盖
/// 绝大多数组合键的「修饰键→普通键」间隔,又低于人从按键到开口的反应时间(>250ms),
/// 不会吃掉首字。窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab)由 TriggerCombined
/// 事后撤销兜底,见 handle_trigger_combined。
pub(super) const COMBO_ARBITRATION_GRACE: std::time::Duration =
std::time::Duration::from_millis(150);
const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12);

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -735,6 +745,12 @@ pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>, pressed_at: std::tim
return;
}

// 新的一次按下:先假定它什么会话都没开出来,由下面的分支在真正 begin_session
// 时置 true。TriggerCombined 靠这个标志判断有没有东西要撤销。
inner
.hotkey_press_began_session
.store(false, Ordering::SeqCst);

// 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。
// 例外:dictation session 已经在跑(Starting / Listening / Processing / Inserting),
// 即使 QA 浮窗被打开了,这条边沿也必须先走 dictation。否则 begin_qa_session 会
Expand Down Expand Up @@ -797,13 +813,13 @@ pub(super) async fn handle_pressed(inner: &Arc<Inner>, pressed_at: std::time::In
}
}
}
let _ = begin_session(inner).await;
begin_session_from_press(inner).await;
}
(HotkeyMode::Toggle, SessionPhase::Listening) => {
let _ = end_session(inner).await;
}
(HotkeyMode::Hold, SessionPhase::Idle) => {
let _ = begin_session(inner).await;
begin_session_from_press(inner).await;
}
// Toggle 模式 Starting 阶段第二次按 → 用户想停。
// 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。
Expand Down Expand Up @@ -831,7 +847,7 @@ pub(super) async fn handle_pressed(inner: &Arc<Inner>, pressed_at: std::time::In
}
}
*inner.hotkey_press_at.lock() = Some(pressed_at);
let _ = begin_session(inner).await;
begin_session_from_press(inner).await;
}
// Auto 模式已因上一次「短按」锁存为切换态,再次按下 → 用户想停。
(HotkeyMode::Auto, SessionPhase::Listening) => {
Expand All @@ -845,6 +861,76 @@ pub(super) async fn handle_pressed(inner: &Arc<Inner>, pressed_at: std::time::In
}
}

/// 由「这一次热键按下」开一条会话,并记下这个事实。TriggerCombined 只撤销带着这个
/// 标记的会话(见 handle_trigger_combined)。
///
/// 开录之前先过一遍组合键仲裁窗口:命中就当这次按下没发生过——不开麦、不弹胶囊。
async fn begin_session_from_press(inner: &Arc<Inner>) {
if press_resolves_to_combo(inner).await {
// 按住态一并清掉:随后必然到来的 Released 会被 handle_released_edge 的
// was_held 检查吞掉,不会走 Auto 短按锁存。
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
*inner.hotkey_press_at.lock() = None;
return;
}
inner
.hotkey_press_began_session
.store(true, Ordering::SeqCst);
let _ = begin_session(inner).await;
}

/// 组合键仲裁:等 COMBO_ARBITRATION_GRACE,再问监听器这次按住有没有叠加普通键。
///
/// 只对 modifier-only 触发键等待 —— 自定义组合键(Cmd+Shift+D 之类)本身就没有歧义,
/// 让它白等这一下纯粹是掉延迟。等待放在防抖 / 冷却判定之后,那些判定用的仍是未被本
/// 窗口推迟的时刻(尤其别把「排队接力」窗口挤掉,见 is_queued_chain_press)。
async fn press_resolves_to_combo(inner: &Arc<Inner>) -> bool {
let binding = inner.prefs.get().dictation_hotkey;
if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_none() {
return false;
}
tokio::time::sleep(COMBO_ARBITRATION_GRACE).await;
let combined = inner
.hotkey
.lock()
.as_ref()
.is_some_and(|monitor| monitor.trigger_combined_since_press());
if combined {
log::info!(
"[coord] 触发键在 {}ms 仲裁窗口内叠加了其他键 —— 本次按下作废,不开录音",
COMBO_ARBITRATION_GRACE.as_millis()
);
}
combined
}

/// 触发键(modifier-only 热键)按住期间又按了普通键 —— 用户在打 Option+任意字母/数字键这类组合键,
/// 不是想说话。撤销这次按下:
///
/// 1. 清掉按住态。后面必然到来的 Released 会被 handle_released_edge 的 `was_held`
/// 检查吞掉,不会再走 Hold 松手结束 / Auto 短按锁存那套判定 —— 否则 Auto 模式下
/// 「Option+组合键快速松手」正是被判成短按锁存,录音一直开着停不下来。
/// 2. 只有这次按下真的开出了会话才取消它。按下时是 toggle 停止 / 被冷却拦下 /
/// 路由给 QA 的,什么都不动(尤其不能取消正在转写的上一条)。
///
/// 组合键误触不算「刚用完一次听写」,所以顺带清掉冷却与防抖时间戳:否则紧接着那次
/// 真想说话的按下会被 #545 冷却 / 250ms 防抖静默吞掉,用户以为热键坏了。
pub(super) fn handle_trigger_combined(inner: &Arc<Inner>) {
let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst);
*inner.hotkey_press_at.lock() = None;
let began_session = inner
.hotkey_press_began_session
.swap(false, Ordering::SeqCst);
if !was_held || !began_session {
log::info!("[coord] hotkey combined with another key (本次按下没开出会话,无需撤销)");
return;
}
log::info!("[coord] hotkey combined with another key —— 取消本次按下开出的会话");
cancel_session(inner);
*inner.session_cooldown_until.lock() = None;
*inner.last_hotkey_dispatch_at.lock() = None;
}

pub(super) async fn handle_released_edge(inner: &Arc<Inner>, released_at: std::time::Instant) {
let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst);
if was_held {
Expand Down Expand Up @@ -3730,6 +3816,49 @@ mod tests {
};
use uuid::Uuid;

fn coordinator_with_dictation_hotkey(
binding: crate::types::ShortcutBinding,
) -> super::super::Coordinator {
let coordinator = super::super::Coordinator::new();
coordinator
.inner
.prefs
.set(crate::types::UserPreferences {
dictation_hotkey: binding,
..Default::default()
})
.unwrap();
coordinator
}

// modifier-only 触发键:按下后必须先过仲裁窗口,才能知道这是说话还是
// Option+任意字母/数字键。
#[tokio::test]
async fn modifier_only_press_waits_out_the_arbitration_window() {
let coordinator = coordinator_with_dictation_hotkey(crate::types::ShortcutBinding {
primary: "LeftOption".into(),
modifiers: vec![],
});

let started = std::time::Instant::now();
// 测试里没装监听器(inner.hotkey = None)→ 读不到叠加标志,按「不是组合键」放行。
assert!(!super::press_resolves_to_combo(&coordinator.inner).await);
assert!(started.elapsed() >= super::COMBO_ARBITRATION_GRACE);
}

// 自定义组合键(Cmd+Shift+D)没有歧义 —— 白等这一下就是纯掉延迟。
#[tokio::test]
async fn custom_combo_press_skips_the_arbitration_window() {
let coordinator = coordinator_with_dictation_hotkey(crate::types::ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd".into(), "shift".into()],
});

let started = std::time::Instant::now();
assert!(!super::press_resolves_to_combo(&coordinator.inner).await);
assert!(started.elapsed() < super::COMBO_ARBITRATION_GRACE);
}

#[test]
fn silent_retry_replaces_initial_asr_attribution() {
let mut label = Some(super::AsrCallLabel::new(
Expand Down
22 changes: 22 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,30 @@ pub(super) fn less_computer_modifier_bridge_loop(inner: Arc<Inner>, rx: mpsc::Re
});
}
HotkeyEvent::Cancelled => cancel_session(&inner_cloned),
HotkeyEvent::TriggerCombined => cancel_less_computer_press(&inner_cloned),
HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {}
}
}
}

/// Less Computer 触发键被当修饰键用(Option+任意字母/数字键之类):撤销这次按下开出的语音会话。
/// handle_less_computer_pressed 只在 Idle 时开会话,所以此刻还在跑的 voice_agent
/// 会话必然就是这次按下开出来的;其他情况(按下被忽略)什么都不动。
fn cancel_less_computer_press(inner: &Arc<Inner>) {
let (phase, voice_agent) = {
let state = inner.state.lock();
(state.phase, state.voice_agent)
};
if !voice_agent || !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
return;
}
log::info!("[less-computer] 触发键与其他键组合按下 —— 取消本次按下开出的会话");
cancel_session(inner);
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}

pub(super) fn less_computer_combo_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
Expand Down Expand Up @@ -1039,6 +1058,9 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
HotkeyEvent::Cancelled => {
cancel_session(&inner_cloned);
}
HotkeyEvent::TriggerCombined => {
handle_trigger_combined(&inner_cloned);
}
HotkeyEvent::TranslationModifierPressed => {
let translation_hotkey = inner_cloned.prefs.get().translation_hotkey;
if is_builtin_translation_shift(&translation_hotkey)
Expand Down
Loading
Loading