社区广场 / 工具
有时有晌
有时有晌 6 天前

这个人很神秘,没有简介

用 100 行 C# 写一个常驻托盘的音量热键工具

用 100 行 C# 写一个常驻托盘的音量热键工具

一个下午的产物:10 KB 的 exe,Ctrl+Alt+↑/↓/M 全局调音量,没有安装包,没有依赖。

起因

我的台式机配的是一把 87 键的机械键盘,没有音量旋钮,也没有多媒体键。调音量只有两条路:伸手去够音箱上的旋钮,或者点任务栏右下角那个小喇叭图标再拖滑块。前者要越过键盘去摸桌角,后者每次要精确瞄准那个 16×16 的小图标——我用的是 4K 屏,鼠标 DPI 又调得低,一天下来烦躁得很。

有意思的是,这个问题在笔记本上基本不存在。笔记本键盘大多有 Fn + F1/F2/F3 的音量键,厂商在功能键区就把这事解决了。反倒是台式机成了重灾区:紧凑配列(60%、65%、75%)为了布局纯粹,几乎必然砍掉多媒体键;客制化机械键盘更是如此,键位从 60 键起跳,音量键想都别想。台式机用户要么买带旋钮的键盘,要么就得靠软件补。

市面上的方案我都试过。PowerToys 的 Keyboard Manager 其实也能做到——把某个组合键映射到系统媒体键(Volume Up / Volume Down / Mute)即可,功能上完全够用。但它是一个安装包几百 MB 的工具集,只为这一个需求装它,代价太大。AutoHotkey 一行 Send {Volume_Up} 就能解决,但要常驻一个解释器、写 .ahk 脚本、配开机自启,对只想调音量的人来说同样太重。

于是我决定自己写一个。需求很窄:

  • 全局热键,任何窗口前台时都生效
  • 常驻托盘,不占任务栏
  • 单个 exe,双击就能跑,最好能塞进 U 盘
  • 体积小、内存小、开机自启不拖慢系统

技术选型:为什么是"模拟媒体键"

调音量有两条技术路线。

路线一:Core Audio API。直接调 IAudioEndpointVolume::SetMasterVolumeLevelScalar,能拿到当前音量、能精确到 1% 步进、能指定设备。代价是要写一大坨 COM 互操作定义——IMMDeviceEnumerator、IMMDevice、IAudioEndpointVolume 三个接口,加上 CLSID、IID 的 GUID 常量,光这些样板代码就一百多行。为了"按一下减 2%"这点事,不值。

路线二:模拟系统媒体键。 系统本来就定义了 VK_VOLUME_UP、VK_VOLUME_DOWN、VK_VOLUME_MUTE 三个虚拟键码。只要用 keybd_event 模拟按下再抬起,系统的音频服务就会像你真的按了键盘上的音量键一样处理它。

系统本来就定义了 VK_VOLUME_UP、VK_VOLUME_DOWN、VK_VOLUME_MUTE 三个虚拟键码。只要用 keybd_event 模拟按下再抬起,系统的音频服务就会像你真的按了键盘上的音量键一样处理它。

先解决 90% 的需求,剩下 10% 等真需要了再说。

最终成品 100 行左右,编译出来 10 KB。

核心实现

整个程序就三个类,各管一摊。

1. Program:单实例保护

常驻托盘的程序最怕用户手滑双击两次,托盘里冒出两个图标,热键还冲突。用一个命名互斥锁解决:

csharp

bool createdNew;
using (var mutex = new Mutex(true, "VolumeHotkey.App.SingleInstance", out createdNew))
{
    if (!createdNew) return;   // 已有实例在跑,直接退出
    Application.Run(new TrayContext());
}

注意 using 包住整个 Application.Run——互斥锁必须在程序运行期间一直持有,程序退出时才释放。如果只包住创建部分,锁立刻被释放,单实例就失效了。

2. HotkeyWindow:一个看不见的窗口

RegisterHotKey 注册的全局热键,触发的 WM_HOTKEY 消息是投递给某个窗口的。托盘程序通常没有窗口,所以得凭空造一个——用 NativeWindow 创建一个不显示的句柄:

csharp

internal sealed class HotkeyWindow : NativeWindow
{
    public HotkeyWindow()
    {
        var cp = new CreateParams();
        cp.Caption = "VolumeHotkeyHiddenWindow";
        CreateHandle(cp);
    }

    protected override void WndProc(ref Message m)
    {
        if (m.Msg == WM_HOTKEY)
        {
            HotkeyPressed?.Invoke(m.WParam.ToInt32());  // WParam 就是注册时给的 id
            return;
        }
        base.WndProc(ref m);
    }
}

WM_HOTKEY 的 WParam 携带的是 RegisterHotKey 时传入的 id,这样同一个窗口就能区分是哪个热键触发的。

3. TrayContext:注册热键 + 托盘图标

csharp

RegisterHotKey(_window.Handle, ID_UP,   MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, VK_UP);
RegisterHotKey(_window.Handle, ID_DOWN, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, VK_DOWN);
RegisterHotKey(_window.Handle, ID_MUTE, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, VK_M);

这里有几个细节值得说:

MOD_NOREPEAT 是关键。 不加它的话,按住 Ctrl+Alt+↑ 不放,系统会以键盘重复速率不断触发 WM_HOTKEY,音量会像坐火箭一样飙到 100%。加了之后,按住只算一次,必须松开再按才会再次触发。对音量这种"离散调节"的操作,这比连发更符合直觉。

为什么选 Ctrl+Alt+↑/↓? 这个组合冲突率低(很多编辑器用 Ctrl+Alt+方向键 做多光标,但那种场景通常不需要同时调音量),而且上下箭头和"调高调低"的语义天然对应。选 Ctrl+Shift 会和输入法切换打架,Win+ 系列会被系统保留,Alt+ 单独用又容易误触菜单。

注册失败要能感知。 RegisterHotKey 返回 bool,热键被别的程序占用时返回 false。代码里三个都注册后检查返回值,有失败就弹托盘气泡提示——总比用户按了半天没反应、以为程序坏了要好。



踩过的坑

第一个坑:keybd_event 只按下不抬起。 最开始我只调了一次 keybd_event(vk, 0, 0, ...),结果音量只动了一下就卡住,后续按键全部失效。原因是模拟按键必须成对——按下之后要有 KEYEVENTF_KEYUP 标志的抬起事件,否则系统认为这个键一直被按住。改成两行调用就正常了。

第二个坑:管理员权限的 UIPI 拦截。 热键本身是系统级的,前台窗口是不是管理员权限都能触发。但 keybd_event 注入的按键事件受 UIPI(User Interface Privilege Isolation)限制——如果前台是以管理员身份运行的程序(比如某些游戏、系统工具),普通权限进程注入的按键会被静默丢弃。所以本工具需要覆盖那种场景时,得自己也以管理员身份运行。

第三个坑:托盘 Tooltip 的长度限制。 NotifyIcon.Text 超过约 63 个字符会被截断,甚至整个不显示。最初的提示文案写了完整的使用说明,结果托盘上啥也看不到。压缩成 音量热键:Ctrl+Alt+↑ / ↓ / M 才正常。

第四个坑:热键注册要在窗口句柄创建之后。 RegisterHotKey 需要有效的 hWnd,如果在 HotkeyWindow 的构造函数里还没调 CreateHandle 就注册,会失败。顺序必须是:先创建 HotkeyWindow(内部完成 CreateHandle),再拿它的 Handle 去注册。



编译与分发

Win10 / Win11 自带 .NET Framework 4.x,系统目录下就有 C# 编译器 csc.exe。不需要装 Visual Studio,不需要装 .NET SDK,一个批处理就编完了:

bat

%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
  /nologo /target:winexe /optimize+ ^
  /out:VolumeHotkey.exe VolumeHotkey.cs

/target:winexe 很重要——它让编译产物是 Windows 程序而不是控制台程序,双击运行时不会弹出一个黑色命令行窗口。源码里用的是 Application.Run + NotifyIcon,如果漏了这个参数,程序逻辑正常但每次启动都会附带一个黑框,很难看。

编出来的 exe 约 10 KB,启动后内存占用在 10 MB 量级(主要是 .NET 运行时的开销,纯 Win32 能做到 1~2 MB,但为了代码可读性这个代价值得)。

开机自启不用改注册表,把 exe 的快捷方式丢进 shell:startup 目录(Win+R 输入这个路径回车)就行,卸载时删掉快捷方式即可,不留垃圾。



后续想做的事

现在的版本刻意保持极简,但有几个方向值得加:

音量 OSD。 系统媒体键自带右下角的音量条,但它有时会被全屏应用盖住。自己画一个屏幕中央的半透明百分比条会更直观——需要额外调用 Core Audio 拿当前音量,所以得先补上那套 COM 定义。

1% 精确步进。 目前是系统默认的 2% 一档。想要更细就得走 SetMasterVolumeLevelScalar,顺便能支持配置文件自定义步长。

可配置热键。 现在改键得改源码重新编译。加个 app.config 或者轻量的 ini 解析,就能让用户自己定义组合键——不过要注意配置解析的健壮性,别让一个格式错误导致程序起不来。

这三件事都建立在"补上 Core Audio 互操作"的基础上,属于同一个技术台阶。等哪天真的觉得 2% 太粗了,再一起做。



小结

这个工具本质上是一次克制的练习:需求只有"按快捷键调音量",那就用最短的路径实现它,不为了架构优雅或功能完备去引入 COM 互操作、不为了跨平台去套 Electron、不为了"以后可能要用"去预留扩展点。

100 行代码、三个类、一个批处理,一个下午从想法到能日常使用。有些工具就该这么小。

源码和编译脚本都在仓库里,MIT 协议,拿去改改就能用。如果你也是台式机、键盘上又没有音量键,可以试试。

🎁

觉得这篇博文有帮助?

你的支持是对 有时有晌 最直接的鼓励,可扫码或消耗积分打赏

👁 14 阅读 · 💬 0 评论

评论 (0)

登录 后参与评论
还没有评论,来抢沙发吧~