中文输入法的语音输入通常有个全局快捷键,我用的是 RAlt + Space。问题是它只在这个输入法处于激活状态时才生效。我平时用英文键盘布局写代码,想说句话就得先切到中文。
我本来就在用 AutoHotkey,比如之前分享过解决 Windows 中文输入法下 Shift + Space 快捷键的困扰。我就想加上脚本去监听 RAlt + Space,如果当前是中文键盘就跳过不处理,让快捷键直接触发输入法的语音输入。如果是英文键盘就先切换然后模拟再发送一次快捷键。但实际发现,有些输入法的快捷键是没有办法通过 AutoHotkey 的 Send 触发的。搜索了一下,原因是这些输入法在 HID 驱动层监听快捷键,软件生成的键盘事件到不了这一层。我只测试了豆包输入法和微信输入法。豆包输入法无法使用 AutoHotkey 触发语音输入,而微信输入法可以。
AutoHotkey 配合微信输入法,就可以在任意键盘布局下一键激活语音输入了。
TL;DR
先配置微信输入法,修改快捷键。我使用按住 RAlt 来语音输入,或者使用 RAlt + Space 开启和结束语音输入。
下面的 AutoHotkey v2 脚本的效果是:英文布局下按 RAlt + Space,脚本自动切到中文输入法、补发一次快捷键触发语音,语音结束后再切回英文;中文布局下脚本完全不介入。
#HotIf !ChineseIMEActive()
$>!Space::{
prev := ForegroundHKL()
ActivateIME("00000804")
deadline := A_TickCount + 1000
while !ChineseIMEActive() && A_TickCount < deadline
Sleep 20
Send "{Blind}{RAlt down}{Space}"
RestoreLayoutAfterVoice(prev)
}
#HotIf
RestoreLayoutAfterVoice(hkl) {
static panel := "语音输入 ahk_class wetype.flutter.setting"
if !WinWait(panel, , 3)
return
if !WinWaitClose(panel, , 60)
return
if ChineseIMEActive()
ApplyHKL(hkl)
}
ChineseIMEActive() => (ForegroundHKL() & 0xFFFF) = 0x0804
ForegroundHKL() {
hwnd := DllCall("GetForegroundWindow", "ptr")
tid := DllCall("GetWindowThreadProcessId", "ptr", hwnd, "ptr", 0, "uint")
return DllCall("GetKeyboardLayout", "uint", tid, "ptr")
}
ActivateIME(klid) {
if hkl := DllCall("LoadKeyboardLayout", "str", klid, "uint", 0x101, "ptr")
ApplyHKL(hkl)
}
ApplyHKL(hkl) {
hwnd := DllCall("GetForegroundWindow", "ptr")
tid := DllCall("GetWindowThreadProcessId", "ptr", hwnd, "ptr", 0, "uint")
me := DllCall("GetCurrentThreadId", "uint")
DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 1)
DllCall("ActivateKeyboardLayout", "ptr", hkl, "uint", 0)
DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 0)
PostMessage 0x0050, 0, hkl, , "ahk_id " hwnd
}
为什么英文布局下按了没反应
现代中文输入法是 TSF(Text Services Framework)的 text service。它的按键钩子只在这个输入法是当前窗口的活动输入法时才会挂上。英文布局下,输入法进程虽然在后台跑着,但它根本收不到你的按键。唯一的路是:先切输入法,再把快捷键补发一次。
中文状态下跳过
一开始我在热键里判断如果已经是中文就跳过切换,但按键依然被脚本接管再转发。更干净的做法是用 #HotIf 让这个热键在中文状态下不起作用。
#HotIf !ChineseIMEActive()
$>!Space::{ ... }
#HotIf
这样中文态走的是 100% 原生路径,零延迟。$ 前缀是必须的,它保证不会递归触发形成死循环。
切换输入法
切换用三件套。LoadKeyboardLayout 拿到 HKL,再通过 AttachThreadInput 把自己挂到前台线程上、调 ActivateKeyboardLayout,最后补一个 WM_INPUTLANGCHANGEREQUEST:
DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 1)
DllCall("ActivateKeyboardLayout", "ptr", hkl, "uint", 0)
DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 0)
PostMessage 0x0050, 0, hkl, , "ahk_id " hwnd
ActivateKeyboardLayout 单独调只对自己的线程生效,所以必须先 AttachThreadInput。
要注意 HKL 层面分不出具体是哪个中文输入法:微软拼音、微信输入法、豆包的 langid 都是 0x0804,切过去的是上次用的那个。所以判断是否已经是中文,比较低 16 位就够了:
ChineseIMEActive() => (ForegroundHKL() & 0xFFFF) = 0x0804
切换是异步的,PostMessage 发完不代表已经生效,所以要轮询等到真的切过去。
语音结束后切回英文
既然是英文布局下触发的,说完话自然应该回到英文。麻烦的是怎么知道说完了。
微信输入法的语音面板是一个独立的顶层窗口,等它关闭就行。找窗口类名的办法是写个脚本记录一段时间内新出现的所有窗口,然后去触发一次语音:
class=wetype.flutter.setting
title=语音输入
exe=wetype_update.exe
于是切回的逻辑就是等这个窗口出现、再等它消失:
RestoreLayoutAfterVoice(hkl) {
static panel := "语音输入 ahk_class wetype.flutter.setting"
if !WinWait(panel, , 3)
return
if !WinWaitClose(panel, , 60)
return
if ChineseIMEActive()
ApplyHKL(hkl)
}
三个判断的作用依次是:
- 面板没出现,此时不切回。因为分不清真的失败和检测漏了。
- 面板超过 60 秒还开着,同样不切回。避免逻辑一直挂着。
- 切回前确认当前还是中文。
类名和标题是从微信输入法 2.1.4.6 抓的,升级后可能变。真变了的表现是切回功能静默失效(停在中文),不影响触发本身。