From c4c1cff7a799d1687be36ca0a73b2fa40cc605a0 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Mon, 25 May 2026 15:48:50 +0200 Subject: [PATCH] feat: Windows Core Audio volume control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/volume: IAudioEndpointVolume via COM (pure Go, no CGO) - Get/Set master volume (0-100%) - GetMute/SetMute - Uses WASAPI instead of deprecated MMSystem mixer API - server: /api/volume now uses system volume (0-100, was Winamp 0-255) - server: new /api/mute endpoint (GET + POST ?muted=true|false) - server: volume+mute included in /api/status response - frontend: mute button with visual state (🔊/🔇, red bar when muted) - frontend: volume display as percentage label Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 5 +- go.sum | 2 + internal/server/server.go | 50 +++++++- internal/volume/volume.go | 239 ++++++++++++++++++++++++++++++++++++++ web/static/app.js | 51 +++++--- web/static/index.html | 6 +- web/static/style.css | 14 ++- 7 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 internal/volume/volume.go diff --git a/go.mod b/go.mod index c1484a6..eb49e49 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module git.svabi.ch/jan/roadamp go 1.25.0 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + golang.org/x/sys v0.45.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..c9ff7fe 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/server/server.go b/internal/server/server.go index d6c8951..4635b73 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "git.svabi.ch/jan/roadamp/internal/killist" "git.svabi.ch/jan/roadamp/internal/resume" + "git.svabi.ch/jan/roadamp/internal/volume" "git.svabi.ch/jan/roadamp/internal/winamp" "gopkg.in/yaml.v3" ) @@ -96,7 +97,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/next", s.handleNext) s.mux.HandleFunc("/api/prev", s.handlePrev) s.mux.HandleFunc("/api/seek", s.handleSeek) // ?delta=±N (seconds) - s.mux.HandleFunc("/api/volume", s.handleVolume) // ?level=0-255 + s.mux.HandleFunc("/api/volume", s.handleVolume) // GET | POST ?level=0-100 + s.mux.HandleFunc("/api/mute", s.handleMute) // GET | POST ?muted=true|false s.mux.HandleFunc("/api/killist", s.handleKillist) s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) } @@ -112,6 +114,8 @@ type statusResponse struct { PlaylistPos int `json:"playlist_pos"` PlaylistLength int `json:"playlist_length"` Version string `json:"version"` + Volume int `json:"volume"` // system master volume 0–100 + Muted bool `json:"muted"` } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { @@ -132,6 +136,13 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { resp.PlaylistLength = s.wa.GetPlaylistLength() resp.Version = s.wa.GetVersion() } + // System volume is available regardless of whether Winamp is running. + if vol, err := volume.Get(); err == nil { + resp.Volume = vol + } + if muted, err := volume.GetMute(); err == nil { + resp.Muted = muted + } jsonOK(w, resp) } @@ -184,13 +195,44 @@ func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + vol, err := volume.Get() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jsonOK(w, map[string]int{"volume": vol}) + return + } lvl, err := strconv.Atoi(r.URL.Query().Get("level")) if err != nil { - http.Error(w, "invalid level", http.StatusBadRequest) + http.Error(w, "invalid level (0–100)", http.StatusBadRequest) return } - ok := s.wa.SetVolume(lvl) - jsonOK(w, map[string]bool{"ok": ok}) + if err := volume.Set(lvl); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jsonOK(w, map[string]int{"volume": lvl}) +} + +func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + muted, err := volume.GetMute() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jsonOK(w, map[string]bool{"muted": muted}) + return + } + val := r.URL.Query().Get("muted") + muted := val == "true" || val == "1" + if err := volume.SetMute(muted); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jsonOK(w, map[string]bool{"muted": muted}) } func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { diff --git a/internal/volume/volume.go b/internal/volume/volume.go new file mode 100644 index 0000000..9d13a65 --- /dev/null +++ b/internal/volume/volume.go @@ -0,0 +1,239 @@ +//go:build windows + +// Package volume controls the Windows system master volume via the +// Core Audio API (IAudioEndpointVolume COM interface). +// This is the modern replacement for the deprecated MMSystem mixer API. +package volume + +import ( + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// ── COM GUIDs ───────────────────────────────────────────────────────────────── + +var ( + clsidMMDeviceEnumerator = windows.GUID{ + Data1: 0xBCDE0395, Data2: 0xE52F, Data3: 0x467C, + Data4: [8]byte{0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E}, + } + iidIMMDeviceEnumerator = windows.GUID{ + Data1: 0xA95664D2, Data2: 0x9614, Data3: 0x4F35, + Data4: [8]byte{0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6}, + } + iidIAudioEndpointVolume = windows.GUID{ + Data1: 0x5CDF2C82, Data2: 0x841E, Data3: 0x4546, + Data4: [8]byte{0x97, 0x22, 0x0C, 0xF7, 0x40, 0x78, 0x22, 0x9A}, + } +) + +const ( + eRender uintptr = 0 + eConsole uintptr = 0 + clsctxAll uintptr = 0x17 +) + +// ── Helper: call a COM vtable method ───────────────────────────────────────── +// vtbl is a pointer to the vtable struct (first field of the COM object). +// idx is the method index (0 = QueryInterface, 1 = AddRef, 2 = Release, …). + +func comCall3(vtbl uintptr, idx int, a1, a2, a3 uintptr) (uintptr, error) { + proc := *(*uintptr)(unsafe.Pointer(vtbl + uintptr(idx)*unsafe.Sizeof(uintptr(0)))) + r, _, _ := syscall.Syscall6(proc, 4, a1, a2, a3, 0, 0, 0) + if r != 0 { + return 0, fmt.Errorf("COM call [%d]: HRESULT 0x%08X", idx, r) + } + return r, nil +} + +func comCall4(vtbl uintptr, idx int, a1, a2, a3, a4 uintptr) (uintptr, error) { + proc := *(*uintptr)(unsafe.Pointer(vtbl + uintptr(idx)*unsafe.Sizeof(uintptr(0)))) + r, _, _ := syscall.Syscall6(proc, 5, a1, a2, a3, a4, 0, 0) + if r != 0 { + return 0, fmt.Errorf("COM call [%d]: HRESULT 0x%08X", idx, r) + } + return r, nil +} + +func comCall5(vtbl uintptr, idx int, a1, a2, a3, a4, a5 uintptr) (uintptr, error) { + proc := *(*uintptr)(unsafe.Pointer(vtbl + uintptr(idx)*unsafe.Sizeof(uintptr(0)))) + r, _, _ := syscall.Syscall6(proc, 6, a1, a2, a3, a4, a5, 0) + if r != 0 { + return 0, fmt.Errorf("COM call [%d]: HRESULT 0x%08X", idx, r) + } + return r, nil +} + +// vtblOf returns the vtable pointer for a COM object pointer. +func vtblOf(p uintptr) uintptr { + return *(*uintptr)(unsafe.Pointer(p)) +} + +// release calls IUnknown::Release (vtable index 2). +func release(p uintptr) { + proc := *(*uintptr)(unsafe.Pointer(vtblOf(p) + 2*unsafe.Sizeof(uintptr(0)))) + syscall.Syscall(proc, 1, p, 0, 0) +} + +// ── DLL procedures ──────────────────────────────────────────────────────────── + +var ( + ole32 = windows.NewLazySystemDLL("ole32.dll") + coInitializeEx = ole32.NewProc("CoInitializeEx") + coUninitialize = ole32.NewProc("CoUninitialize") + coCreateInstance = ole32.NewProc("CoCreateInstance") +) + +// ── withEndpointVolume ──────────────────────────────────────────────────────── + +// withEndpointVolume acquires an IAudioEndpointVolume for the default render +// device, calls fn with its pointer, and releases all COM objects. +func withEndpointVolume(fn func(vol uintptr) error) error { + coInitializeEx.Call(0, 0) // COINIT_MULTITHREADED + defer coUninitialize.Call() + + // CoCreateInstance(CLSID_MMDeviceEnumerator) → IMMDeviceEnumerator + var enumerator uintptr + hr, _, _ := coCreateInstance.Call( + uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)), + 0, + clsctxAll, + uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)), + uintptr(unsafe.Pointer(&enumerator)), + ) + if hr != 0 { + return fmt.Errorf("CoCreateInstance: HRESULT 0x%08X", hr) + } + defer release(enumerator) + + // IMMDeviceEnumerator::GetDefaultAudioEndpoint (vtable index 4) + // (0=QI, 1=AddRef, 2=Release, 3=EnumAudioEndpoints, 4=GetDefaultAudioEndpoint) + var device uintptr + if _, err := comCall5(vtblOf(enumerator), 4, + enumerator, eRender, eConsole, + uintptr(unsafe.Pointer(&device)), 0); err != nil { + return fmt.Errorf("GetDefaultAudioEndpoint: %w", err) + } + defer release(device) + + // IMMDevice::Activate(IID_IAudioEndpointVolume, CLSCTX_ALL, nil, &vol) + // (0=QI, 1=AddRef, 2=Release, 3=Activate, …) + var vol uintptr + if _, err := comCall5(vtblOf(device), 3, + device, + uintptr(unsafe.Pointer(&iidIAudioEndpointVolume)), + clsctxAll, + 0, + uintptr(unsafe.Pointer(&vol))); err != nil { + return fmt.Errorf("IMMDevice::Activate: %w", err) + } + defer release(vol) + + return fn(vol) +} + +// ── IAudioEndpointVolume vtable indices ─────────────────────────────────────── +// +// 0 QueryInterface +// 1 AddRef +// 2 Release +// 3 RegisterControlChangeNotify +// 4 UnregisterControlChangeNotify +// 5 GetChannelCount +// 6 SetMasterVolumeLevel +// 7 SetMasterVolumeLevelScalar +// 8 GetMasterVolumeLevel +// 9 GetMasterVolumeLevelScalar +// 14 SetMute +// 15 GetMute + +const ( + idxSetMasterScalar = 7 + idxGetMasterScalar = 9 + idxSetMute = 14 + idxGetMute = 15 +) + +// ── Public API ──────────────────────────────────────────────────────────────── + +// Get returns the current Windows master volume as a percentage (0–100). +func Get() (int, error) { + var pct int + err := withEndpointVolume(func(vol uintptr) error { + var level float32 + proc := *(*uintptr)(unsafe.Pointer( + vtblOf(vol) + idxGetMasterScalar*unsafe.Sizeof(uintptr(0)), + )) + r, _, _ := syscall.Syscall(proc, 2, vol, uintptr(unsafe.Pointer(&level)), 0) + if r != 0 { + return fmt.Errorf("GetMasterVolumeLevelScalar: 0x%08X", r) + } + pct = int(level * 100) + return nil + }) + return pct, err +} + +// Set sets the Windows master volume to pct percent (0–100). +func Set(pct int) error { + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + level := float32(pct) / 100.0 + return withEndpointVolume(func(vol uintptr) error { + var nullGUID windows.GUID + proc := *(*uintptr)(unsafe.Pointer( + vtblOf(vol) + idxSetMasterScalar*unsafe.Sizeof(uintptr(0)), + )) + // float32 must be widened to uintptr via bit-reinterpretation + bits := *(*uint32)(unsafe.Pointer(&level)) + r, _, _ := syscall.Syscall(proc, 3, vol, uintptr(bits), uintptr(unsafe.Pointer(&nullGUID))) + if r != 0 { + return fmt.Errorf("SetMasterVolumeLevelScalar: 0x%08X", r) + } + return nil + }) +} + +// GetMute reports whether the system audio is currently muted. +func GetMute() (bool, error) { + var muted bool + err := withEndpointVolume(func(vol uintptr) error { + var val int32 + proc := *(*uintptr)(unsafe.Pointer( + vtblOf(vol) + idxGetMute*unsafe.Sizeof(uintptr(0)), + )) + r, _, _ := syscall.Syscall(proc, 2, vol, uintptr(unsafe.Pointer(&val)), 0) + if r != 0 { + return fmt.Errorf("GetMute: 0x%08X", r) + } + muted = val != 0 + return nil + }) + return muted, err +} + +// SetMute mutes or unmutes the system audio. +func SetMute(muted bool) error { + return withEndpointVolume(func(vol uintptr) error { + var nullGUID windows.GUID + val := uintptr(0) + if muted { + val = 1 + } + proc := *(*uintptr)(unsafe.Pointer( + vtblOf(vol) + idxSetMute*unsafe.Sizeof(uintptr(0)), + )) + r, _, _ := syscall.Syscall(proc, 3, vol, val, uintptr(unsafe.Pointer(&nullGUID))) + if r != 0 { + return fmt.Errorf("SetMute: 0x%08X", r) + } + return nil + }) +} diff --git a/web/static/app.js b/web/static/app.js index 2cfd3e8..f535b39 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -4,20 +4,22 @@ const api = (path, opts = {}) => fetch(path, opts).then(r => r.json()).catch(() => null); // ── State ───────────────────────────────────────────────────────────────────── -let currentVolume = 180; // 0–255 +let currentVolume = 50; // 0–100 (Windows system volume) let pollTimer = null; // ── DOM refs ────────────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); -const statusDot = $('winamp-status'); -const stateLabel = $('state-label'); -const trackTitle = $('track-title'); -const playlistPos = $('playlist-pos'); +const statusDot = $('winamp-status'); +const stateLabel = $('state-label'); +const trackTitle = $('track-title'); +const playlistPos = $('playlist-pos'); const progressFill = $('progress-fill'); -const timeCurrent = $('time-current'); -const timeLength = $('time-length'); -const volumeFill = $('volume-fill'); -const btnPlay = $('btn-play'); +const timeCurrent = $('time-current'); +const timeLength = $('time-length'); +const volumeFill = $('volume-fill'); +const volumePct = $('volume-pct'); +const btnMute = $('btn-mute'); +const btnPlay = $('btn-play'); const killistPanel = $('killist-panel'); const killistItems = $('killist-items'); @@ -66,23 +68,37 @@ $('progress-bar').addEventListener('click', async e => { // ── Volume ──────────────────────────────────────────────────────────────────── $('btn-vol-up').addEventListener('click', async () => { - currentVolume = Math.min(255, currentVolume + 13); // ~5% + currentVolume = Math.min(100, currentVolume + 5); await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); updateVolumeFill(); }); $('btn-vol-down').addEventListener('click', async () => { - currentVolume = Math.max(0, currentVolume - 13); + currentVolume = Math.max(0, currentVolume - 5); await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); updateVolumeFill(); }); $('volume-bar').addEventListener('click', async e => { const rect = e.currentTarget.getBoundingClientRect(); - currentVolume = Math.round((e.clientX - rect.left) / rect.width * 255); + currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100); await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); updateVolumeFill(); }); -function updateVolumeFill() { - volumeFill.style.width = (currentVolume / 255 * 100) + '%'; +btnMute.addEventListener('click', async () => { + const cur = await api('/api/mute'); + const newMuted = !(cur?.muted); + await api(`/api/mute?muted=${newMuted}`, { method: 'POST' }); + updateMuteBtn(newMuted); +}); + +function updateVolumeFill(muted = false) { + volumeFill.style.width = currentVolume + '%'; + volumePct.textContent = currentVolume + ' %'; + volumeFill.classList.toggle('muted', muted); +} +function updateMuteBtn(muted) { + btnMute.textContent = muted ? '🔇' : '🔊'; + btnMute.classList.toggle('muted', muted); + volumeFill.classList.toggle('muted', muted); } // ── KillList ────────────────────────────────────────────────────────────────── @@ -154,6 +170,13 @@ async function poll() { // Reflect play/pause state on button btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; + + // System volume (always present in status response) + if (typeof st.volume === 'number') { + currentVolume = st.volume; + updateVolumeFill(st.muted); + updateMuteBtn(st.muted); + } } function startPolling(intervalMs = 2000) { diff --git a/web/static/index.html b/web/static/index.html index fa9c83c..0e49edd 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -43,13 +43,15 @@
- + +
+
- +
diff --git a/web/static/style.css b/web/static/style.css index d1d2346..aec163a 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -134,10 +134,12 @@ html, body { #volume-row { display: flex; align-items: center; - gap: 12px; + gap: 10px; } -.btn-vol { width: 52px; height: 52px; flex-shrink: 0; } -#volume-bar-wrap { flex: 1; } +.btn-vol { width: 48px; height: 48px; flex-shrink: 0; font-size: 18px; } +#btn-mute { font-size: 22px; } +#btn-mute.muted { color: var(--accent); } +#volume-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; } #volume-bar { height: 8px; background: var(--accent2); @@ -152,6 +154,12 @@ html, body { transition: width 0.2s; border-radius: 4px; } +#volume-pct { + font-size: 11px; + color: var(--text-dim); + text-align: center; +} +#volume-fill.muted { background: var(--accent); } /* Killist */ #killist-row {