Много лет я пользовался Volume²: переключал колонки и наушники хоткеем, крутил громкость с оверлеем. На Windows 11 он у меня начал подлагивать — оверлей появляется с задержкой, — а в %APPDATA%\Volume2\Errors.log периодически появляются падения:

ShowOSDWindow: Access violation at address 006E0F69 in module 'Volume2.exe'. Read of address 00000000

Я завёл issue, но ждать не стал и написал себе замену под свои сценарии. Получилась утилита volume_switch: один exe в трее, ~2000 строк на Go в одном файле.

Код: https://github.com/des1roer/volume_switch, готовый exe — в релизах.

Что умеет

  • F7 / F8 — переключить устройство воспроизведения по умолчанию назад/вперёд по кругу. Меняются сразу все три роли (console, multimedia, communications), так что мессенджеры тоже переезжают на новое устройство. При переключении всплывает уведомление с именем устройства.

  • F3 / F4 — громкость −2% / +2%, как мультимедийные клавиши Windows; заодно снимают mute.

  • Оверлей громкости — большой ползунок с процентами и шкалой 0–100 в левом нижнем углу монитора, на котором сейчас курсор. Показывается при любом изменении громкости: хоткеями, колесом в микшере, кнопками на гарнитуре, сторонними программами. Гаснет сам через ~1.2 с.

  • F5 — оверлей с прогрессом MPC‑HC поверх всех окон, не забирая фокус: название файла, полоса прогресса, прошедшее и оставшееся время. Клик по полосе — пауза/продолжить (а если плеер не запущен — запустить его), клик колесом — открыть папку с файлом в проводнике с выделенным файлом.

  • Трей — список активных устройств с галочкой на текущем, переключение кликом и пункт «Запускать при входе в Windows» (автозапуск через HKCU\...\Run, без прав администратора).

  • Хоткеи срабатывают только на «голые» клавиши: Alt+F4, Ctrl+F5, Shift+F3 и прочие сочетания уходят программам как обычно.

Для оверлея MPC‑HC нужно включить веб‑интерфейс плеера: Вид → Настройки → Проигрыватель → Веб‑интерфейс, порт 7777. Утилита опрашивает http://localhost:7777/variables.html; адрес, интервал опроса и путь к плееру настраиваются через .env, переменные окружения или флаги.

Дальше — про то, как это устроено и где пришлось повозиться.

Почему Go и почему один файл

Go даёт один статический exe без рантайма и установщика: скачал — запустил. Сборка с -ldflags "-H=windowsgui" ставит в PE‑заголовок GUI subsystem, и загрузчик Windows не создаёт консольное окно вообще; для отладки есть флаг -debug, который явно выделяет консоль через AllocConsole.

Вся программа — намеренно один main.go. Утилита маленькая, а так её целиком можно скормить в контекст LLM одним куском, когда нужна помощь с доработкой.

Зависимости:

  • github.com/diegosz/go-wca + github.com/go-ole/go-ole — WASAPI/COM;

  • fyne.io/fyne/v2 — окна‑оверлеи;

  • github.com/getlantern/systray — иконка и меню в трее;

  • golang.org/x/sys/windows — всё остальное из WinAPI.

Переключение устройства: недокументированный COM

Публичного API «сделать устройство устройством по умолчанию» в Windows нет. Все такие утилиты, включая Volume², используют недокументированный интерфейс IPolicyConfigVista (CPolicyConfigVistaClient) и его метод SetDefaultEndpoint. В go‑wca он уже объявлен, так что остаётся вызвать его для каждой из трёх ролей.

Главная тонкость — потоки. COM‑объекты создаются в однопоточной квартире (COINIT_APARTMENTTHREADED) и должны вызываться из того же OS‑потока. В Go горутины прыгают между потоками, поэтому все COM‑вызовы живут в одной горутине, закреплённой через runtime.LockOSThread, а остальной код отправляет ей замыкания через канал:

func (s *Switcher) SetDefault(id string) error {
	return s.exec(func() error {
		return s.setDefaultLocked(id)
	})
}

Хоткеи, меню трея и поллер громкости работают в разных горутинах, но до COM доходят только через exec. Заодно это убирает гонки: «прочитать список устройств → выбрать следующее → переключить» в Cycle выполняется одной операцией на рабочей горутине, и одновременный клик в меню её не разорвёт.

Громкость: push‑уведомления, которые не пришли

Хотелось показывать оверлей при любом изменении громкости, а не только по своим клавишам. Правильный путь — IAudioEndpointVolume::RegisterControlChangeNotify и колбэк IAudioEndpointVolumeCallback::OnNotify.

В go‑wca IAudioEndpointVolume объявлен с правильной vtable, но Register/UnregisterControlChangeNotify — заглушки, возвращающие E_NOTIMPL, а сам колбэк‑интерфейс не реализован вовсе. Я собрал его руками по образцу IMMNotificationClient. Регистрация проходила успешно, но OnNotify не вызвался ни разу — даже после того, как я добавил в поток полноценный цикл сообщений с TranslateMessage/DispatchMessageW.

В итоге победил скучный вариант: поллинг текущего уровня каждые 120 мс. Это один дешёвый COM‑вызов, работает с любым драйвером и не требует отдельной квартиры:

func volumeChanged(prev, cur volumeReading) bool {
	return prev.DeviceID != "" && prev.DeviceID == cur.DeviceID &&
		(math.Round(prev.Level*100) != math.Round(cur.Level*100) || prev.Muted != cur.Muted)
}

Здесь две детали:

  • Уровень сравнивается только в рамках одного устройства (по ID). У разных устройств обычно разная сохранённая громкость, и без этой проверки оверлей выскакивал бы при каждом переключении F7/F8 — а это не то же самое, что «пользователь покрутил громкость».

  • Сравнение — с точностью до процента, а не на точное равенство. Устройство округляет громкость до своих аппаратных шагов: выставляешь 0.44, читаешь 0.439453125 (это 225/512).

S_FALSE, который выглядит как ошибка

F3/F4, как и мультимедийные клавиши, должны снимать mute. Первая версия просто вызывала SetMute(false) и периодически писала в лог «Неверная функция».

Оказалось, что SetMute без смены состояния возвращает S_FALSE — успешный код «ничего не изменилось», — а go‑wca считает ошибкой любой ненулевой HRESULT. Лечится проверкой перед вызовом:

var muted bool
if err := aev.GetMute(&muted); err != nil || !muted {
	return err
}
return aev.SetMute(false, nil)

Глобальные хоткеи: WH_KEYBOARD_LL

Хоткеи ловятся низкоуровневым хуком клавиатуры WH_KEYBOARD_LL: наши клавиши без модификаторов он «съедает» (F5 не обновит страницу в браузере), остальные пропускает дальше.

У такого хука свои правила:

  • Хук вызывается в потоке, который его поставил, и только если в этом потоке крутится цикл сообщений. Значит, нужен ещё один закреплённый поток с GetMessageW.

  • Процедура хука должна возвращаться быстро, иначе Windows начнёт тормозить ввод во всей системе и в итоге молча снимет хук. Поэтому обработчик запускается в отдельной горутине.

  • Клавиши с Alt/Ctrl/Shift/Win пропускаются дальше. Первая версия этого не делала и съедала Alt+F4 — окна переставали закрываться.

func hookProc(nCode int, wParam uintptr, kb *kbdllHookStruct) uintptr {
	if nCode >= 0 && (wParam == wmKeyDown || wParam == wmSysKeyDown) {
		if handler := hotkeys[kb.VkCode]; handler != nil && !modifierHeld(kb) {
			go handler()
			return 1
		}
	}
	r, _, _ := procCallNextHookEx.Call(0, uintptr(nCode), wParam, uintptr(unsafe.Pointer(kb)))
	return r
}

Приятная мелочь: syscall.NewCallback сразу отдаёт lParam типизированным указателем *kbdllHookStruct, без ручного uintptr → unsafe.Pointer.

Fyne и systray: кто владеет главным потоком

Самая неприятная ошибка была такой: F5 ничего не делал, окно не появлялось, а в логе — одно предупреждение о «неправильном потоке».

Причина в том, что github.com/getlantern/systray в своём init() безусловно вызывает runtime.LockOSThread(). init() выполняется на горутине, которая потом становится горутиной main(), — так что ещё до старта main() она навсегда закреплена за главным потоком процесса. А Fyne 2.8+ требует, чтобы app.New(), NewWindow() и Run() вызывались именно на этой горутине. Я запускал Fyne в отдельной горутине, и он не падал, а зависал насовсем внутри NewWindow().

Решение — поменять их местами: Fyne живёт в main(), а systray уезжает в свою закреплённую горутину:

go func() {
	runtime.LockOSThread()
	systray.Run(func() { onReady(*debug) }, onExit)
}()

runFyne(url, interval)

Все изменения UI из других горутин (поллинг MPC, хоткеи) — только через fyne.Do.

Оверлеи без кнопки на панели задач

Оверлей не должен появляться на панели задач — для этого окну ставится расширенный стиль WS_EX_TOOLWINDOW. Но Fyne/GLFW создают HWND лениво, при первом Show(), и если окно хоть раз показалось с обычным стилем, Explorer оставляет ему кнопку навсегда, даже после смены стиля.

Поэтому при старте каждое окно один раз показывается и сразу прячется вхолостую, получая WS_EX_TOOLWINDOW ещё до первого настоящего показа. Но и этого мало: оверлей громкости прячется и показывается на каждое изменение, и после очередного цикла Hide() + Show() Explorer иногда всё равно возвращал кнопку. Так что стиль переприменяется при каждом показе — это несколько дешёвых syscall. Окно ищется по заголовку через FindWindowW, поэтому заголовки у оверлеев уникальные.

Ещё несколько граблей помельче
  • Системный заголовок окна нельзя убрать через GWL_STYLE: GLFW начинает раздувать окно. Тёмный заголовок делается через DWM‑атрибут DWMWA_USE_IMMERSIVE_DARK_MODE.

  • Fyne игнорирует RequestPosition(0, 0), считая это «позиция не задана». Для левого верхнего угла монитора пришлось писать обход.

  • explorer /select,"путь" нельзя собрать через exec.Command с аргументами: Go заквотирует токен /select,"…" целиком, explorer не узнает свитч и откроет «Документы». Командная строка собирается вручную:

cmd := exec.Command("explorer")
cmd.SysProcAttr = &syscall.SysProcAttr{
	CmdLine: `explorer.exe /select,"` + clean + `"`,
}

SysProcAttr.CmdLine заменяет командную строку процесса целиком, поэтому имя программы указывается в ней же.

  • Уведомления в трее. У systray нет публичного API для баллонов, поэтому утилита находит скрытое окно systray (класс SystrayClass, ID иконки 100 — зашиты в v1.2.2) и вызывает Shell_NotifyIconW напрямую. При обновлении systray это первое, что сломается.

  • Один экземпляр — именованный мьютекс Local\volume_switch_single_instance: второй запуск молча выходит.

MPC‑HC: веб‑интерфейс на 7777

У MPC‑HC есть встроенный веб‑сервер, а страница variables.html отдаёт текущее состояние плеера: путь к файлу, позицию, длительность, статус. Каждое значение там лежит в <p id="...">, так что утилита раз в 500 мс забирает страницу и вытаскивает поля одним шаблоном регулярки <p id="ID">([^<]*)</p> — полноценный HTML‑парсер тут не нужен.

Пауза отправляется командой в тот же веб‑интерфейс. Если запрос не прошёл, плеер считается не запущенным и стартует по пути из MPC_EXE, а если такого файла нет — из HKCU\Software\MPC-HC\MPC-HC\ExePath, который MPC‑HC пишет сам.

Тесты, которые не трогают систему

Покрытие около 80% (go test -race в CI на windows-latest). Главное правило: тесты не должны менять систему пользователя и задевать уже запущенную копию программы. Для этого в коде есть точки подмены — переменные вместо констант:

  • запуск explorer и MPC‑HC идёт через переменную startDetached, которую тест заменяет записью команд;

  • ключи реестра (автозапуск, путь к MPC‑HC) — временные, удаляются после теста;

  • заголовки окон, имя мьютекса и класс окна трея — уникальные на каждый тест;

  • аудио‑тест только читает, а «переключение» вызывается лишь когда текущее устройство уже по умолчанию во всех ролях — то есть реально ничего не меняется;

  • хук клавиатуры ставится с пустой картой и сразу снимается.

Циклы опроса принимают канал тиков, так что в тесте можно сделать ровно n итераций без time.Sleep.

Итого

Volume² умеет намного больше, и если он у вас работает — пользуйтесь им. Мне же хватило трёх сценариев: переключить вывод, покрутить громкость, глянуть, сколько осталось до конца серии. На это ушёл один файл на Go, и теперь у меня ничего не лагает.

Утилита сделана для себя и проверялась на Windows 11. Код открыт: https://github.com/des1roer/volume_switch — issue и pull request'ы приветствуются.