Windows steps a hardware volume knob in fixed 2% notches — the endpoint's step table, not yours. volstep intercepts every detent and substitutes its own: fine when you're trimming, a full analog sweep when you rip it. One script, one tray icon, and it gets richer when MBXHub is around.
Your chosen step — 0.5 / 1 / 2 / 4% per detent, picked from the tray menu and remembered. Spinning while muted unmutes, like the native keys.
Detents closer than 80 ms ride an absolute 5.5% step. Rip the knob and it behaves like a real pot: about 270° covers the whole range.
With MBXHub up, the click drives the hub directly and the card shows the real resulting state. Without it: a plain media key, still works everywhere.
Double-click toggles system mute. The OSD says so honestly — mute state is read back from Windows, never guessed.
A real volume pot sweeps its full range in about 270° of rotation. This knob clicks 24 detents per revolution, so 270° is 18 detents — and 100% ÷ 18 ≈ 5.5% per detent. Fast mode doesn't multiply your fine step; it targets the sweep itself, so the analog feel is identical whether you trim in 0.5s or 2s.
volstep never requires anything. It probes localhost:8080 every few seconds; when MBXHub answers, the same knob quietly gets better. When it doesn't, nothing breaks.
| var | default | what it does |
|---|---|---|
| STEP_CHOICES | [0.5, 1, 2, 4] | step sizes offered in the tray menu; edit the array, the menu follows |
| STEP_DEFAULT | 0.5 | step until picked, and the fallback when the ini is invalid |
| FAST_STEP | 5.5 | % per detent during a fast spin — the 270° pot model |
| FAST_MS | 80 | detents closer than this are “fast”; raise if analog mode won't trigger |
| DBL_MS | 400 | double-click window for mute; also the single-click delay |
| HUB_BASE | 127.0.0.1:8080 | where MBXHub is probed; unreachable just means Tier 0 |
| NP_LIFT | 40 | extra lift for the now-playing card above a taskbar that lies about its height |
| BOTTOM_GAP | 26 | gap between taskbar top and both panels — the one positioning knob |
Settings picked from the tray (step size, click remap) persist to a volstep.ini generated next to the script on first change — nothing to create by hand.
Transparent, click-through, region-rounded windows simply never composite over RDP-class display paths. The OSD is a plain opaque window on purpose — it fades by timer, not by alpha.
On some scaled/portrait driver stacks the taskbar paints taller than the rect it reports — every app that positions “just above the taskbar” lands inside it. No API tells the truth, so a tuned constant (NP_LIFT) is the correct fix, not a hack.
Relative volume events take the render endpoint's step table — 50 steps, baked into the driver. The only way past it is to swallow the keystroke and set the level yourself, which is exactly what an intercept does.
Microsoft documents this model: IAudioEndpointVolume::VolumeStepUp moves “by one step” through a step count the endpoint reports via GetVolumeStepInfo — while SetMasterVolumeLevelScalar accepts any float 0.0–1.0. volstep simply trades the first API's staircase for the second's continuum.
Mute and volume are read back from Windows. Play state is only guessed at Tier 0 — and becomes known at Tier 1, because MBXHub answers with the state that actually resulted. That difference is the whole pitch.
The whole thing. Save as volstep-osd.ahk, run with AutoHotkey v2 (required).
#Requires AutoHotkey v2.0
#SingleInstance Force
; ============================================================================
; volstep-osd.ahk โ fine volume steps + play/pause remap + a compact,
; icon-based, RDP-safe on-screen readout.
;
; Knob spin -> volume up/down by the selected step (0.5/1/2/4%).
; Knob click -> Play/Pause (single) + Mute/unmute (double). [tray-toggle]
; Tray icon (right-click): step size, toggle click remap, Exit. Persists to .ini
;
; Icons (Segoe MDL2 Assets) mutate with state:
; volume 0-3 waves by level | mute = speaker-with-line | play โถ / pause โโ
; Mute + volume are ACCURATE (read from the system). Play/pause is tracked from
; clicks (right while the knob drives playback; can drift if changed elsewhere).
;
; Plain opaque window (no transparency/layered โ those go invisible over RDP).
; ============================================================================
; ---------------------------- CONFIG ----------------------------------------
PANEL_BG := "16171b"
TRACK := "2b2e37"
ACCENT := GetAccentColor() ; bar fill = your Windows accent (or hardcode "4cc2ff")
STEP_CHOICES := [0.5, 1, 2, 4] ; step sizes offered in the tray menu (% per detent)
STEP_DEFAULT := 0.5 ; step used until picked / when the ini is invalid
; Analog-pot model: a real pot sweeps 0-100% in ~270 deg. This knob has ~24
; detents/rev, so 270 deg = ~18 detents -> fast step = 100/18 = 5.5%/detent.
; Slow turn = the fine StepPct; a rip = pot-like full-range sweep.
FAST_STEP := 5.5 ; % per detent during a fast spin (270-deg pot feel)
FAST_MS := 80 ; detents closer together than this (ms) = fast spin
TIMEOUT := 1000 ; ms visible after the last action
DBL_MS := 400 ; max ms between clicks to count as a double
HUB_BASE := "http://127.0.0.1:8080" ; MBXHub probe target โ Tier-1 enhancement.
; Reachable => rich mode (accurate play state,
; track name). Unreachable => plain system-level.
NP_TIMEOUT := 2600 ; ms the now-playing card stays up (longer than the OSD)
NP_LIFT := 40 ; extra px to lift the NP card โ TUNE THIS if it sits low
CARD_SUB := "a0a6b4" ; artist / secondary text color
; ----------------------------------------------------------------------------
iniPath := A_ScriptDir "\volstep.ini"
; Harden against a hand-edited/corrupt ini: non-numeric would throw on "+ 0",
; and a step not in the menu would throw at stepMenu.Check(). Snap to the set.
StepPct := IniRead(iniPath, "opts", "StepPct", String(STEP_DEFAULT))
StepPct := IsNumber(StepPct) ? StepPct + 0 : STEP_DEFAULT
stepOk := false
for c in STEP_CHOICES
if (StepPct = c)
stepOk := true
if !stepOk
StepPct := STEP_DEFAULT
ClickPlayPause := IniRead(iniPath, "opts", "ClickPlayPause", "1")
ClickPlayPause := IsNumber(ClickPlayPause) ? (ClickPlayPause + 0 != 0) : true
clicks := 0
playing := false
hubUp := false ; Tier-1: is MBXHub reachable?
artTmp := A_Temp "\volstep_np_art.jpg" ; downloaded album art
; Anchor panels to the TASKBAR WINDOW itself (Shell_TrayWnd): WinGetPos returns
; coords in the same space Gui.Show uses, so no DPI/monitor math can go wrong.
; Centered on the taskbar's width, sitting just above its top edge.
BOTTOM_GAP := 26 ; px between the taskbar top and the panels' bottom edge โ THE one knob
tbX := 0, tbY := 0, tbW := A_ScreenWidth, tbTop := A_ScreenHeight - 48
try {
WinGetPos(&tbX, &tbY, &tbW, &tbH, "ahk_class Shell_TrayWnd")
tbTop := tbY
}
; ---- compact icon OSD: one row โ icon | bar | % ----
g := Gui("+AlwaysOnTop -Caption +ToolWindow", "VolOSD")
g.BackColor := PANEL_BG
g.MarginX := 14
g.MarginY := 12
g.SetFont("s16", "Segoe MDL2 Assets")
g.Add("Text", "vIcon Section w28 h26", Chr(0xE994))
g.SetFont()
g.Add("Progress", "vBar x+10 ys+10 w150 h6 c" ACCENT " Background" TRACK " Range0-100", 0)
g.SetFont("s12 bold cffffff", "Segoe UI")
g.Add("Text", "vLbl x+10 ys+3 w64", "0%")
g.Show("AutoSize NoActivate")
gw := gh := 0
g.GetPos( , , &gw, &gh)
gx := tbX + (tbW - gw) // 2
gy := tbTop - gh - BOTTOM_GAP
g.Hide()
; ---- Tier-1: rich now-playing card (art + title + artist + state), fixed 340x84 ----
np := Gui("+AlwaysOnTop -Caption +ToolWindow", "NpCard")
np.BackColor := PANEL_BG
np.Add("Picture", "vNpArt x0 y0 w84 h84") ; album art, full-bleed left
np.SetFont("s13 bold cffffff", "Segoe UI")
np.Add("Text", "vNpTitle x100 y18 w228", "")
np.SetFont("s10 c" CARD_SUB, "Segoe UI")
np.Add("Text", "vNpArtist x100 y42 w228", "")
np.SetFont("s13 c" ACCENT, "Segoe MDL2 Assets")
np.Add("Text", "vNpState x100 y62 w24 h20", Chr(0xE768))
; Measure the card EXACTLY like the vol OSD is measured โ really shown, GetPos,
; hidden. Hidden-window measurement returned different numbers on this box, which
; is why the two panels disagreed about "center". (Brief startup blink, off-screen.)
np.Show("w340 h84 NoActivate x0 y-500")
npw := nph := 0
np.GetPos( , , &npw, &nph)
np.Hide()
npx := tbX + (tbW - npw) // 2
npy := gy + gh - nph - NP_LIFT ; vol-OSD-aligned bottom, lifted by NP_LIFT
; ---- tray UI ----
stepMenu := Menu()
for c in STEP_CHOICES
stepMenu.Add(c "%", SetStep)
tray := A_TrayMenu
tray.Delete()
tray.Add("Step size", stepMenu)
tray.Add("Click = Play/Pause (double = Mute)", ToggleClick)
tray.Add()
tray.Add("Exit", (*) => ExitApp())
RefreshTray()
; ---- Tier-1: detect MBXHub now + re-check periodically ----
CheckHub()
SetTimer(CheckHub, 5000)
; ---- hotkeys (no ~ = swallow the native step) ----
Volume_Up:: AdjustVol(1)
Volume_Down:: AdjustVol(-1)
Volume_Mute:: OnClick()
AdjustVol(dir) {
global StepPct, FAST_STEP, FAST_MS
static lastTick := 0
if SoundGetMute()
SoundSetMute(0) ; spinning unmutes โ matches native volume-key behavior
; Analog feel: slow detents use the fine StepPct; a rapid burst rides the
; absolute pot-sweep step (270-deg = full range), like a real volume pot.
now := A_TickCount
step := (now - lastTick < FAST_MS) ? FAST_STEP : StepPct
lastTick := now
SoundSetVolume((dir > 0 ? "+" : "-") step)
ShowVol()
}
OnClick() {
global ClickPlayPause, clicks, DBL_MS
if (!ClickPlayPause) { ; remap off -> plain mute toggle
SoundSetMute(-1)
ShowMute()
return
}
clicks++
if (clicks = 1)
SetTimer(ResolveClick, -DBL_MS)
}
ResolveClick() {
global clicks, playing, hubUp, HUB_BASE
n := clicks
clicks := 0
if (n >= 2) { ; double -> mute (always system-level)
SoundSetMute(-1)
ShowMute()
return
}
; single -> play/pause
if (hubUp) {
; Tier-1: drive MBXHub directly. Returns the REAL resulting state, so the
; card is accurate (and no media key is injected -> no SMTC double-delivery).
resp := HttpPost(HUB_BASE "/player/playpause")
st := JsonStr(resp, "state")
if (st != "") {
playing := (st = "playing") ; keep the Tier-0 fallback tracker in sync
ShowNowPlaying(playing)
return
}
}
; Tier-0 fallback: system media key + locally-tracked icon
Send("{Media_Play_Pause}")
playing := !playing
ShowPlay(playing)
}
; ---- OSD renderers ----
ShowVol() {
v := SoundGetVolume()
ShowOsd(VolGlyph(v), Round(v, 1) "%", Round(v))
}
ShowMute() {
if SoundGetMute()
ShowOsd(Chr(0xE74F), "Muted", 0) ; speaker-with-line
else {
v := SoundGetVolume()
ShowOsd(VolGlyph(v), Round(v, 1) "%", Round(v))
}
}
ShowPlay(isPlaying) {
ShowOsd(isPlaying ? Chr(0xE768) : Chr(0xE769), isPlaying ? "Playing" : "Paused", -1)
}
ShowOsd(glyph, text, barVal) {
global g, gx, gy, TIMEOUT, np
np.Hide() ; the two panels share the spot โ one at a time
g["Icon"].Value := glyph
g["Lbl"].Value := text
if (barVal < 0)
g["Bar"].Visible := false
else {
g["Bar"].Visible := true
g["Bar"].Value := barVal
}
g.Show("NoActivate x" gx " y" gy)
SetTimer(HideOsd, -TIMEOUT)
}
HideOsd() {
global g
g.Hide()
}
VolGlyph(v) {
if (v <= 0)
return Chr(0xE992) ; no waves
else if (v <= 33)
return Chr(0xE993) ; one
else if (v <= 66)
return Chr(0xE994) ; two
else
return Chr(0xE995) ; full
}
; ---- tray handlers ----
SetStep(itemName, *) {
global StepPct
StepPct := StrReplace(itemName, "%", "") + 0
SaveIni()
RefreshTray()
}
ToggleClick(*) {
global ClickPlayPause
ClickPlayPause := !ClickPlayPause
SaveIni()
RefreshTray()
}
RefreshTray() {
global stepMenu, tray, StepPct, ClickPlayPause, STEP_CHOICES
for c in STEP_CHOICES
stepMenu.Uncheck(c "%")
stepMenu.Check(StepPct "%")
if (ClickPlayPause)
tray.Check("Click = Play/Pause (double = Mute)")
else
tray.Uncheck("Click = Play/Pause (double = Mute)")
}
SaveIni() {
global iniPath, StepPct, ClickPlayPause
IniWrite(StepPct, iniPath, "opts", "StepPct")
IniWrite(ClickPlayPause, iniPath, "opts", "ClickPlayPause")
}
; ---- Tier-1: MBXHub hook (all guarded; failure silently degrades to Tier-0) ----
ShowNowPlaying(isPlaying) {
global np, g, HUB_BASE, artTmp, NP_TIMEOUT, npx, npy, gy, gh
g.Hide() ; the two panels share the spot โ one at a time
j := HttpGet(HUB_BASE "/nowplaying")
title := JsonStr(j, "title")
np["NpTitle"].Value := (title != "") ? title : "MBXHub"
np["NpArtist"].Value := JsonStr(j, "artist")
np["NpState"].Value := isPlaying ? Chr(0xE768) : Chr(0xE769) ; play / pause
if LoadArt() {
try np["NpArt"].Value := artTmp
} else {
try np["NpArt"].Value := "" ; no art for THIS track โ never show the previous track's
}
np.Show("NoActivate w340 h84 x" npx " y" npy)
SetTimer(HideNp, -NP_TIMEOUT)
}
HideNp() {
global np
np.Hide()
}
LoadArt() {
; GET /nowplaying/artwork -> temp file (ADODB.Stream). Guarded: any failure
; returns false and the card just keeps the previous art.
global HUB_BASE, artTmp
try {
r := ComObject("WinHttp.WinHttpRequest.5.1")
r.SetTimeouts(500, 500, 500, 1500)
r.Open("GET", HUB_BASE "/nowplaying/artwork", false)
r.Send()
if (r.Status = 200) {
s := ComObject("ADODB.Stream")
s.Type := 1 ; binary
s.Open()
s.Write(r.ResponseBody)
s.SaveToFile(artTmp, 2) ; adSaveCreateOverWrite
s.Close()
return true
}
}
return false
}
CheckHub() {
global hubUp, HUB_BASE
hubUp := (HttpGet(HUB_BASE "/system/version") != "")
}
HttpGet(url) {
try {
r := ComObject("WinHttp.WinHttpRequest.5.1")
r.SetTimeouts(500, 500, 500, 800) ; resolve, connect, send, receive
r.Open("GET", url, false)
r.Send()
if (r.Status = 200)
return r.ResponseText
}
return ""
}
HttpPost(url) {
try {
r := ComObject("WinHttp.WinHttpRequest.5.1")
r.SetTimeouts(500, 500, 500, 800)
r.Open("POST", url, false)
r.Send()
if (r.Status = 200)
return r.ResponseText
}
return ""
}
JsonStr(json, key) {
; POC-grade extractor for a top-level "key":"value" string field.
; Handles escaped chars inside the value (\" \\ \/), so titles with
; quotes or backslashes don't truncate at the first escape.
if RegExMatch(json, '"' key '"\s*:\s*"((?:[^"\\]|\\.)*)"', &m) {
s := m[1]
s := StrReplace(s, '\"', '"')
s := StrReplace(s, '\/', '/')
s := StrReplace(s, '\\', '\')
return s
}
return ""
}
GetAccentColor() {
try {
c := RegRead("HKCU\Software\Microsoft\Windows\CurrentVersion\DWM", "AccentColor")
return Format("{:02x}{:02x}{:02x}", c & 0xFF, (c >> 8) & 0xFF, (c >> 16) & 0xFF) ; ABGR -> RGB
}
return "4cc2ff"
}