Skip to content
Merged
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
14 changes: 8 additions & 6 deletions openless-all/app/src-tauri/src/combo_hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use std::time::Instant;

use global_hotkey::{GlobalHotKeyEvent, HotKeyState};
use parking_lot::Mutex;
Expand All @@ -22,9 +23,9 @@ use crate::types::ShortcutBinding;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComboHotkeyEvent {
/// 用户按下了配置的组合键。
Pressed,
Pressed { at: Instant },
/// 用户松开了配置的组合键(用于 Hold 模式结束录音)。
Released,
Released { at: Instant },
}

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -129,9 +130,10 @@ fn forward_loop(hotkey_id: u32, rx: Receiver<GlobalHotKeyEvent>, tx: Sender<Comb
if event.id() != hotkey_id {
continue;
}
let at = Instant::now();
let combo_event = match event.state() {
HotKeyState::Pressed => ComboHotkeyEvent::Pressed,
HotKeyState::Released => ComboHotkeyEvent::Released,
HotKeyState::Pressed => ComboHotkeyEvent::Pressed { at },
HotKeyState::Released => ComboHotkeyEvent::Released { at },
};
if let Err(e) = tx.send(combo_event) {
log::warn!("[combo-hotkey] 事件投递失败: {e}");
Expand Down Expand Up @@ -263,8 +265,8 @@ mod tests {

forward_loop(8, event_rx, out_tx);

assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Released));
assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Pressed));
assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Released { .. }));
assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Pressed { .. }));
assert!(out_rx.try_recv().is_err());
}
}
97 changes: 93 additions & 4 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ struct Inner {
/// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防
/// press-release-press 三连过快。
last_hotkey_dispatch_at: Mutex<Option<std::time::Instant>>,
/// Auto 模式下这次会话「按下」的事件时刻。松手时用按下/松开的事件时间戳差值
/// 判定短按(Toggle 锁存)还是长按(Hold 松手即停)。见 dictation.rs 的
/// AUTO_HOLD_THRESHOLD。
hotkey_press_at: Mutex<Option<std::time::Instant>>,
/// end_session 成功收尾后将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。
/// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键,
/// 防止胶囊离场动画期间误激活新听写(issue #545)。
Expand Down Expand Up @@ -417,6 +421,7 @@ impl Coordinator {
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
last_hotkey_dispatch_at: Mutex::new(None),
hotkey_press_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
Expand Down Expand Up @@ -517,6 +522,7 @@ impl Coordinator {
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
last_hotkey_dispatch_at: Mutex::new(None),
hotkey_press_at: Mutex::new(None),
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
Expand Down Expand Up @@ -1484,8 +1490,8 @@ impl Coordinator {
#[cfg(any(debug_assertions, test))]
pub async fn inject_hotkey_click_for_dev(&self) -> Result<(), String> {
log::info!("[coord] dev hotkey injection started");
handle_pressed(&self.inner).await;
handle_released(&self.inner).await;
handle_pressed(&self.inner, std::time::Instant::now()).await;
handle_released(&self.inner, std::time::Instant::now()).await;
cancel_session(&self.inner);
Ok(())
}
Expand Down Expand Up @@ -2657,7 +2663,7 @@ mod tests {
state.session_id = session_id(41);
}

handle_pressed_edge(&coordinator.inner).await;
handle_pressed_edge(&coordinator.inner, std::time::Instant::now()).await;

let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Inserting);
Expand Down Expand Up @@ -2685,7 +2691,7 @@ mod tests {
.hotkey_trigger_held
.store(true, Ordering::SeqCst);

handle_pressed_edge(&coordinator.inner).await;
handle_pressed_edge(&coordinator.inner, std::time::Instant::now()).await;

assert_eq!(
coordinator.inner.state.lock().phase,
Expand All @@ -2694,6 +2700,89 @@ mod tests {
assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}

fn set_auto_mode(coordinator: &Coordinator) {
coordinator
.inner
.prefs
.set(crate::types::UserPreferences {
hotkey: crate::types::HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Auto,
keys: None,
},
..Default::default()
})
.unwrap();
}

// Auto 模式短按:松手时按住时长 < 阈值 → 锁存为切换态,保持 Listening(不结束会话)。
#[tokio::test]
async fn auto_short_tap_release_latches_recording() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
coordinator.inner.state.lock().phase = SessionPhase::Listening;
// 刚按下(elapsed ≈ 0 < 350ms)→ 短按。
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);

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

// 短按松手不结束录音,等下一次按下再停。
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
}

#[tokio::test]
async fn auto_short_tap_stays_latched_when_bridge_handles_release_late() {
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);

// 模拟上一条会话阻塞 bridge:处理发生在物理松手很久之后。
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(100),
)
.await;

assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Listening);
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
}

// Auto 模式长按:松手时按住时长 >= 阈值 → 按住说话语义,结束会话(Listening → Idle)。
#[tokio::test]
async fn auto_long_hold_release_ends_session() {
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);

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

// 无 recorder / ASR 的测试会话下,end_session 直接收尾到 Idle。
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
}

#[test]
fn enabling_shortcut_recording_clears_dictation_hold_latch() {
let coordinator = Coordinator::new();
Expand Down
70 changes: 64 additions & 6 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ use super::*;
/// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 ——
/// 避免微动开关回弹 / 用户手抖双击造成的空转写报错和 ASR session 抢资源。
const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250);
/// Auto 模式下区分「短按 = 切换式」与「长按 = 按住说话」的按住时长阈值。
/// 松手时若按住 < 此值判为短按(锁存,保持录音),>= 此值判为长按(松手即停)。
/// 时长以热键事件产生时携带的时间戳计算,避免串行 bridge 的排队延迟改变用户的物理按住时长。
/// 350ms 是「点一下 vs 明显按住」的自然分界。
const AUTO_HOLD_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(350);
const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12);

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -667,7 +672,7 @@ fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option<Str
}
}

pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>) {
pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>, pressed_at: std::time::Instant) {
let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst);
if !was_held {
// 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。
Expand Down Expand Up @@ -701,7 +706,7 @@ pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>) {
if panel_visible && !dictation_active {
handle_qa_option_edge(inner).await;
} else {
handle_pressed(inner).await;
handle_pressed(inner, pressed_at).await;
}
}
}
Expand All @@ -726,7 +731,7 @@ fn is_queued_chain_press(now: std::time::Instant, cooldown_until: std::time::Ins
.unwrap_or(false)
}

pub(super) async fn handle_pressed(inner: &Arc<Inner>) {
pub(super) async fn handle_pressed(inner: &Arc<Inner>, pressed_at: std::time::Instant) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})");
Expand Down Expand Up @@ -765,11 +770,42 @@ pub(super) async fn handle_pressed(inner: &Arc<Inner>) {
(HotkeyMode::Toggle, SessionPhase::Starting) => {
request_stop_during_starting(inner, "toggle stop edge");
}
// Auto 模式:按下即开录(与 Hold 一样不丢首字)。是短按还是长按要到松手时才知道,
// 所以这里只负责「开始」并记下按下时刻,语义交给 handle_released 判定。
(HotkeyMode::Auto, SessionPhase::Idle) => {
// 复用 Toggle 的冷却 / 排队接力检查:#545 离场动画期间误触保护。
let now = std::time::Instant::now();
let cooldown_until = *inner.session_cooldown_until.lock();
if let Some(deadline) = cooldown_until {
if now < deadline {
if is_queued_chain_press(now, deadline) {
log::info!(
"[coord] queued-chain activation (auto): 识别中按下,会话收尾后接力开录下一条"
);
} else {
log::info!(
"[coord] auto activation blocked by cooldown (session still winding down)"
);
return;
}
}
}
*inner.hotkey_press_at.lock() = Some(pressed_at);
let _ = begin_session(inner).await;
}
// Auto 模式已因上一次「短按」锁存为切换态,再次按下 → 用户想停。
(HotkeyMode::Auto, SessionPhase::Listening) => {
let _ = end_session(inner).await;
}
// Auto 模式锁存后仍在 Starting 时第二次按 → 想停,同 Toggle 存边沿。
(HotkeyMode::Auto, SessionPhase::Starting) => {
request_stop_during_starting(inner, "auto stop edge");
}
_ => {}
}
}

pub(super) async fn handle_released_edge(inner: &Arc<Inner>) {
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 {
// QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。
Expand All @@ -781,11 +817,11 @@ pub(super) async fn handle_released_edge(inner: &Arc<Inner>) {
if panel_visible && !dictation_active {
return;
}
handle_released(inner).await;
handle_released(inner, released_at).await;
}
}

pub(super) async fn handle_released(inner: &Arc<Inner>) {
pub(super) async fn handle_released(inner: &Arc<Inner>, released_at: std::time::Instant) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})");
Expand All @@ -805,6 +841,28 @@ pub(super) async fn handle_released(inner: &Arc<Inner>) {
_ => {}
}
}
if mode == HotkeyMode::Auto {
// 使用物理按下/松开的事件时刻,避免 bridge 排队时把处理延迟误算为按住时长。
let held_long = inner.hotkey_press_at.lock().take()
.map(|pressed_at| released_at.saturating_duration_since(pressed_at) >= AUTO_HOLD_THRESHOLD)
.unwrap_or(false);
match phase {
// 长按松手 = 按住说话,松手即停;短按 = 切换式,锁存保持录音,下次按下再停。
SessionPhase::Listening if held_long => {
let _ = end_session(inner).await;
}
// 仍在握手就松手,且判为长按 → 用户按住说话想停,存边沿握手完成后再 end。
SessionPhase::Starting if held_long => {
request_stop_during_starting(inner, "auto hold release edge");
}
SessionPhase::Listening | SessionPhase::Starting => {
log::info!(
"[coord] auto short-tap latched (toggle semantics); next press stops"
);
}
_ => {}
}
}
}

/// Less Computer 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。
Expand Down
Loading
Loading