HALRAD Research

Volstep — Rotating Knob Step Tuning for Windows

volstep · proof of concept · autohotkey v2

Give a digital knob,
an analog soul.
Zero drivers.

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.

scroll the dial — slow = 0.5% steps rip it — fast detents jump 5.5% (analog mode) click — play / pause · double-click — mute
42.0%
fast
Cities on Flame with Rock and Roll
Blue Öyster Cult
▶ PLAYING

The input map

spin · slow

Fine trim

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.

spin · fast

Analog sweep

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.

click

Play / pause

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.

click × 2

Mute

Double-click toggles system mute. The OSD says so honestly — mute state is read back from Windows, never guessed.

Why 5.5%

The 270° pot model

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.

24
detents / rev
270°
full sweep
5.5%
per fast detent
80ms
fast threshold

Two tiers, one script

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.

Tier 0 — any Windows box

The universal dial

  • Fine volume steps the endpoint driver refuses to give you
  • Variable-speed spin — trim or sweep, one gesture
  • Play/pause + mute on the click, system-wide media keys
  • Compact OSD — icon, bar, percentage, gone in a second
Tier 1 — MBXHub detected

The rich dial

  • Accurate play state — the hub reports what actually happened
  • Now-playing card — album art, title, artist on every toggle
  • No media-key injection — REST commands, no SMTC echo
  • Same knob, same script — the upgrade is automatic

Config — the whole surface

vardefaultwhat it does
STEP_CHOICES[0.5, 1, 2, 4]step sizes offered in the tray menu; edit the array, the menu follows
STEP_DEFAULT0.5step until picked, and the fallback when the ini is invalid
FAST_STEP5.5% per detent during a fast spin — the 270° pot model
FAST_MS80detents closer than this are “fast”; raise if analog mode won't trigger
DBL_MS400double-click window for mute; also the single-click delay
HUB_BASE127.0.0.1:8080where MBXHub is probed; unreachable just means Tier 0
NP_LIFT40extra lift for the now-playing card above a taskbar that lies about its height
BOTTOM_GAP26gap 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.

Field notes

WS_EX_LAYERED renders invisible over remote sessions

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.

The taskbar lies about its own height

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.

The 2% step was never the knob's

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.

Accurate beats clever

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 script

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"
}