A portable Windows tray app (WPF, .NET 10) that keeps the default audio output on a device that actually plays sound. It exists because the HyperX driver (NGENUITY / HyperX Virtual Audio Device) parks the output on its own virtual endpoints, so audio disappears while the headset is off, and because the same headset sometimes fails to come back from hibernation at all. Interface in English, Ukrainian and Russian.
NGENUITY registers a set of virtual output devices:
NGENUITY - Chat (HyperX Virtual Audio Device)
NGENUITY - Stream Mix (HyperX Virtual Audio Device)
NGENUITY - 8/12 Channel Spatial (HyperX Virtual Audio Device)
...
They stay alive whether or not the headset is attached. Once HyperX Cloud III goes
NotPresent, Windows picks the next "available" device — and that turns out to be an
NGENUITY endpoint routing audio into a headset that isn't there. A default device formally
exists; sound does not.
To inspect the current state: HyperXAudioGuard.exe --list.
Wireless models are harder. The dongle stays an active audio device even with the
headset powered off, so Windows events say nothing and Core Audio cannot tell whether
anyone is listening. For those models the app queries the dongle directly over HID — the
protocol was documented by
platorp/HyperX-Cloud-III-3-S-Audio-Switcher:
usage page 0x1C0, report id 0x0C, request 0C 02 03 01 00 02 …, answer in byte [6]
(2 — headset on, 0 — off). Polled every 3 seconds.
The dongle is matched by vendor id 0x03F0 (HP) and 0x0951 (Kingston, pre-2021 models),
with no model hardcoded — but the usage page alone is not enough. A wired Cloud III exposes
the same collection with zero-length reports: nothing to write, nothing to read. So the
interface is only accepted when its InputReportByteLength and OutputReportByteLength are
large enough for the exchange; otherwise the app concludes there is no dongle and relies on
Windows events, which is sufficient for wired models — unplugging the cable makes the device
physically disappear.
To see what your machine exposes: HyperXAudioGuard.exe --hid.
The app subscribes to IMMNotificationClient (Core Audio events: endpoint added, removed,
state changed) and, when a dongle is present, polls it as well. Every change re-evaluates
one rule:
- Take the active devices, drop the blocked ones (
!lines) and — if the dongle reports the headset is off — every HyperX device too. - Find the first match against the priority list in
config.json. - Set it as the default (Console, Multimedia and Communications roles) if the current device is unusable, missing, or ranks lower.
A manual choice outside the rules (say, a Bluetooth speaker that isn't listed) is left alone — otherwise the app would fight the person using it.
Recording devices go through the same rule, with their own priority and block lists
under [input]. That is not a bonus feature but the other half of the same problem: a
headset arrives with a microphone, and virtual drivers hijack that just as eagerly — on
this machine the default microphone was NGENUITY's virtual one until the app moved it back.
Bluetooth is worse. Every wireless headset shows up twice: as … Stereo (A2DP) and as
Hands-Free AG Audio (HFP) — mono, 16 kHz, the sound of a phone call. Windows switches the
output to that mono channel the moment its Hands-Free microphone becomes the default
input, which is why !Hands-Free is blocked on both sides out of the box.
Changing the default endpoint goes through the undocumented IPolicyConfig COM interface.
Windows exposes no public API for this; every known tool takes the same route (see below).
A second failure mode, and a nastier one. On waking, the headset's USB device sometimes does not return at all: the PnP node is still there, the audio endpoints are gone, replugging the cable changes nothing, and Windows parks the default output on an NGENUITY endpoint that plays into nowhere. Only a reboot used to fix it — and the app was usually not even running by then, because a logon task does not fire when a machine resumes.
The wake is noticed. SystemEvents.PowerModeChanged records whether a headset was
present before sleep, so one that was simply left at home is never "repaired". On resume the
dongle handle is dropped — it does not survive sleep — and the rule is recalculated after 5
seconds and then every 10, four times over, because USB devices come back slowly and Core
Audio may send no event at all.
The microphone counts too. A headset is one USB node — USB\VID_03F0&PID_089D&MI_00
carries both the playback and the recording endpoint — and the pair usually disappears
together. Usually: the microphone can also vanish on its own while the headphones keep
working, and that used to slip past unnoticed, because the app declared the headset "back"
as soon as the playback endpoint was there. Now it waits for both. Whether the headset even
has a microphone is not configured anywhere: Windows keeps the endpoint in its list long
after the device is gone, so the app asks it. One disabled by hand in mmsys.cpl does not
count — otherwise the repair would chase a microphone its owner switched off deliberately.
There is no separate driver to restart for a microphone, and no separate command for it: reinstalling that node brings back both halves — as long as the node is the right one, see below.
A headset still missing after that is reinstalled. The app takes the PnP nodes whose
description carries HyperX — the headset itself and the NGENUITY virtual audio driver —
and restarts them through pnputil /restart-device. If that is not enough it restarts
AudioEndpointBuilder, which rebuilds the entire endpoint list; that is the part a reboot
was doing.
The whole USB device, not one interface. Only the audio interface of the headset is
called HyperX (USB\VID_03F0&PID_089D&MI_00); the HID interface next to it
(…&MI_03, plus its collections) is named after nothing in particular — and that is the one
NGENUITY talks to. Restarting the audio interface alone therefore brought the sound back
while NGENUITY went on insisting no headset was connected, and the microphone came back
only after a few more rounds or a reboot. So the app climbs from the matched node to its
composite parent (USB\VID_03F0&PID_089D\…) and restarts that instead: every interface of
the device is re-enumerated at once, which is exactly what pulling the cable out and back
in does.
NGENUITY holds that virtual driver open, so the automatic repair takes it out of the way first — killed the way Task Manager kills it — and starts it again when the drivers are back. It has no "start minimised" switch of its own, but closing its window leaves the process running in the tray, so that is what the app does with it. If NGENUITY was not running, both steps are skipped.
On demand the choice is yours. Restore sound, in the window and in the tray menu, opens
two actions: restart the audio drivers alone, or restart NGENUITY along with them. While
NGENUITY is not running there is nothing to choose — the second action would do exactly
what the first one does — so it is not offered, and the button restarts the drivers on the
first click. From the console the same pair is --recover and --recover ngenuity.
And the ports are kept awake. "Allow the computer to turn off this device to save
power" is checked by default on USB hubs, and it is the likeliest reason the device does not
survive hibernation in the first place. The Keep the ports awake toggle unchecks it on
every USB node Windows offers the setting for — no guessing which port the headset is in,
and no re-pinning when it moves. Only nodes where the box was still checked are taken, and
their names go into config.json; turning the toggle off restores exactly those and
nothing else.
Reinstalling a device and restarting a service both need administrator rights, and the app never asks for them on its own. Enable Run at logon once from an elevated instance and the task keeps those rights; otherwise recovery writes a line into the log saying it cannot proceed.
A ready-to-run build sits in release — HyperXAudioGuard.exe, a single file with
the runtime inside (~72 MB). Nothing to install: download and run, from a flash drive if you
like.
The app is portable. Everything it creates lives next to the executable and nowhere else:
| File | Contents |
|---|---|
config.json |
everything the app remembers: rules, settings, language, USB nodes |
log.txt |
switch log, trimmed to the last two days on every start |
Uninstalling means deleting the folder. Nothing is written to the registry or the user
profile. Two toggles are the exception, because they change Windows itself: Run at logon
creates a scheduler task, since there is no other way to have autostart, and Keep the
ports awake unchecks a power setting on the USB nodes. Clearing each toggle undoes its own change,
so clear them before deleting the folder — config.json is the only record of which nodes
were touched.
On read-only media (a disc, a write-protected stick) the app falls back to defaults: rules won't persist, but audio switching still works.
Build it yourself:
dotnet build -c Release # for development
.\tools\publish.ps1 # self-contained exe into release\ (~72 MB)
.\tools\publish.ps1 -FrameworkDependent # same thing at ~300 KB, needs .NET Desktop Runtime 10The sources sit in seven places, one namespace for all of them:
| Folder | Contents |
|---|---|
| root | Program.cs — the entry point, and nothing else |
Audio/ |
the point of the app: the watcher loop, the rules, the volume |
App/ |
its own housekeeping: the settings file, the journal, the build version |
Interop/ |
the bindings: Core Audio COM interfaces, HID and SetupAPI imports |
Platform/ |
what those make the system do: PnP restart, scheduler task, USB power |
Ui/ |
the window and the tray icon |
Lang/ |
language: one dictionary per language, and the code that switches them |
No system frame: the title bar is drawn by the app and doubles as its header, with its own
settings, minimise, maximise and close buttons. Dragging, resizing and snapping stay native
(WindowChrome), and a maximised window does not cover the taskbar.
Tray icon: red when watching, grey when paused. Double-click opens the window. Both the close button and minimise send it back to the tray — the app never sits on the taskbar; exit is in the tray menu.
Transitions are animated: window entry, hover on buttons and rows, the toggle knob, rows sliding in when the list refreshes. Opening the log is deliberately not animated — the window has to grow by exactly the height of the log panel, and while that height was easing, the device list jumped up and back down.
Inside:
-
Default device — what is playing now, the dongle state (whether one was found and what it reports about the headset), and the Watching/Paused badge. The badge is the pause switch: click it and the app stops choosing devices until you click it again.
-
Output / Input — two tabs over one list: each direction has its own rules, and the buttons below act on whichever is open. Every endpoint, active ones first. On the left a badge: priority number, or a red
✕for blocked; hovering it names the rule that put it there — worth knowing, because a rule is a fragment of a name and can cover several devices. Next to the state, a chip with the level the device is set to on a switch — a dash while nothing is pinned. Clicking it opens a slider: dragging it turns the volume of that device right away, and on closing the level is written toconfig.json; Do not pin drops the rule and leaves the volume alone. Peeking at the chip and closing it again changes nothing. On the right the state with a coloured dot. The current device is highlighted and labelled. -
Buttons — Prioritise / Block / Clear rule / Up / Down. They act on the selected row, write
config.jsonimmediately and re-apply the rule; Clear rule asks first when the rule it is about to drop covers other devices too. Restore sound, apart at the right end of the row, acts on the system instead of on a device. -
Settings — the sliders button in the title bar. Everything you set once and forget is there: run at logon, USB port power, a balloon when the device changes, and hotkeys — the last one off until you ask for it, because the combinations are registered system-wide. The balloon is silent and goes out through
Shell_NotifyIcondirectly: WinForms can only show one with a sound, and while a previous balloon is still up the shell drops the next one without a word — which looked exactly like notifications arriving once and never again. Below them, apart, Check for updates: not a setting but an action, and the only time the app touches the network. It asks GitHub for the latest release and answers in place — a newer version, the current one, or an honest "could not check" when the answer never came. A newer version also leaves a link in the footer, so the news survives the popup closing; clicking it opens the releases page. Nothing is downloaded or installed: the app is a single portable exe, and replacing it is your call. -
Log — an expander with the switch history; on open it pulls the tail of
log.txt, so events from before the window was opened are visible too. The file is trimmed on every start: entries older than two days go, and no more than 500 lines are kept. The point of it is “what happened to the sound last night”, not a month of history. -
Footer — authorship, the version of the running build, the RU / UA / EN switch, and — if the check is on and a newer release exists — a link to it.
The icons (app.ico, app-paused.ico) are generated by tools/make-icons.ps1 in seven
sizes from 16 to 256. Re-run it after changing the drawing.
Normally edited in the window. Everything the app remembers lives in one config.json next
to the executable, created on first run:
{
"language": "en",
"notify": true,
"hotkeys": false,
"pauseHotkey": "Ctrl+Alt+P",
"recoverHotkey": "Ctrl+Alt+R",
"output": {
"priority": [ "HyperX", "Speakers (Realtek(R) Audio)" ],
"blocked": [ "NGENUITY", "HyperX Virtual Audio Device", "Hands-Free" ]
},
"input": {
"priority": [ "HyperX", "Microphone (Realtek(R) Audio)" ],
"blocked": [ "Hands-Free", "NGENUITY" ]
},
"volume": [ { "match": "HyperX", "percent": 25 } ],
"usbPower": false,
"usbPowerHeld": []
}The first priority is just HyperX, so any HyperX headset matches; the driver's virtual
devices carrying the same word are cut out by blocked, which outranks priorities. The
second entry is the fallback — on first run it is filled with whatever is default at that
moment, unless a virtual driver has already hijacked it, in which case the first real device
is taken instead. input is the same rule for microphones. Names come from --list output,
which prints both directions.
A missing output or input is filled with the defaults on the next run. An empty one, on
the other hand, is left empty — that is a decision, not an omission, and that direction is
then never touched.
volume is the level to set a device to at the moment the app switches to it — the first
entry whose match is part of the device name wins. The level chip in the list writes here,
and a rule added by hand can cover several devices at once, which the chip then says. Windows already remembers a volume per
endpoint, so the list is only for keeping a device at a fixed level; while it is empty the
app never touches the volume, and the level is not re-applied afterwards, so turning the
knob yourself is not fought. usbPowerHeld is written by the app itself: the USB nodes it
took the power-saving checkbox off, and the only way back.
The hotkeys can only be changed here. Modifiers are Ctrl, Alt, Shift and Win in any
combination plus one key; the names are WPF's (P, F9, Oem3, …). If the system says the
combination is taken, the log says so too, and the other one still works.
Hand edits are read as written: comments and a trailing comma are tolerated, the app rewrites
the file only when a setting changes. Versions up to 1.0.1 kept all of this in five text
files (devices.txt, settings.txt, volume.txt, language.txt, usbpower.txt) — on the
first run the app moves them into config.json, deletes them and says so in the log.
The Run at logon toggle creates one task, HyperXAudioGuard, that starts
HyperXAudioGuard.exe --tray on two triggers: logon, and the Power-Troubleshooter wake
event. A logon trigger stays silent when a machine resumes, and if the app did not survive
the sleep nothing else would bring it back; a live instance turns the duplicate launch away
through its mutex.
The task is described by XML rather than by schtasks switches, because the defaults that
command applies are fatal here: a task is not started at all while the machine runs on
battery, is killed the moment the cable is pulled, and is stopped after three days. On a
laptop that means the app quietly never runs. All three are off in the XML, and a task left
behind by an earlier version is rewritten silently at the next start, keeping the rights it
had.
The task is visible and editable in taskschd.msc; clearing the toggle deletes it.
Re-enabling rewrites it with the current executable path, which is handy after moving the
folder — and with the rights of the instance that created it, which is what gives recovery
its administrator rights. From the console:
HyperXAudioGuard.exe --autostart # is the task there or not
HyperXAudioGuard.exe --autostart on # create it
HyperXAudioGuard.exe --autostart off # remove itStrings live in Lang/Lang.en.xaml, Lang/Lang.uk.xaml and Lang/Lang.ru.xaml —
ResourceDictionary files swapped wholesale at runtime. The language is switched in the
window footer and remembered in config.json next to the executable; English is the
default. The console modes and the messages in the log follow the same
setting — no part of the program is fixed to one language. Audio device names are not
translated — Windows supplies them.
To add a language: drop a Lang.<code>.xaml beside the others with the same keys and add
the code to Localization.Codes.
| Argument | What it does |
|---|---|
| (none) | open the window, keep running in the tray |
--tray |
start in the tray without a window — used by autostart |
--once |
apply the rule once and exit (console) |
--list |
list every output device, its state and the current default |
--test |
self-check: switch to another device and switch back |
--hid |
HyperX HID interfaces and the raw dongle status reply |
--recover [ngenuity] |
repair the audio devices; ngenuity restarts NGENUITY as well |
--autostart [on|off] |
query, create or remove the scheduler task |
If the NGENUITY virtual endpoints aren't needed, disabling them makes the problem go away
with no software at all: Win+R → mmsys.cpl → right-click each NGENUITY - ... →
Disable. The app is for when NGENUITY is actually used (equaliser, mixer, spatial) or
when the driver re-enables those endpoints after an update.
All of them change the default device through the same IPolicyConfig; none addresses the
virtual-endpoint problem specifically, but they are useful references:
- platorp/HyperX-Cloud-III-3-S-Audio-Switcher — source of the dongle polling protocol: PowerShell + Python (pywinusb) + AudioDeviceCmdlets
- Belphemur/SoundSwitch — the most active project; hotkey switching, per-process profiles
- sgiurgiu/DefaultAudioChanger — C++/WTL, works the undocumented interface directly
- marcjoha/AudioSwitcher — systray utility
- BarsikV/CommunicationDeviceSwitcherService — Windows service syncing the communication device with playback
- yan0lovesha/AudioSwitch — the same in Rust
- luizbossoi/windows-audio-switcher — switching by foreground process
Apache License 2.0 — see LICENSE and NOTICE.
Copyright 2026 Dykalo Pavlo. Forks and derivative works must keep the contents of NOTICE
and mark the files they changed (sections 4b and 4d of the license).
The dongle polling protocol was reverse engineered by platorp/HyperX-Cloud-III-3-S-Audio-Switcher (MIT); none of its code is included here — the implementation is written from scratch in C# on top of the Win32 HID API.
HyperX, NGENUITY and Cloud are trademarks of HP Inc. This project is independent, not affiliated with or supported by HP; the names identify the hardware and drivers the software interoperates with.
Developed by Dykalo Pavlo, 2026.




