- 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 <noreply@anthropic.com>master
| @@ -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 | |||
| ) | |||
| @@ -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= | |||
| @@ -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) { | |||
| @@ -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 | |||
| }) | |||
| } | |||
| @@ -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) { | |||
| @@ -43,13 +43,15 @@ | |||
| </div> | |||
| <div id="volume-row"> | |||
| <button class="btn btn-vol" id="btn-vol-down">๐</button> | |||
| <button class="btn btn-vol" id="btn-mute">๐</button> | |||
| <button class="btn btn-vol" id="btn-vol-down">โ</button> | |||
| <div id="volume-bar-wrap"> | |||
| <div id="volume-bar"> | |||
| <div id="volume-fill"></div> | |||
| </div> | |||
| <div id="volume-pct">โ</div> | |||
| </div> | |||
| <button class="btn btn-vol" id="btn-vol-up">๐</button> | |||
| <button class="btn btn-vol" id="btn-vol-up">+</button> | |||
| </div> | |||
| <div id="killist-row"> | |||
| @@ -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 { | |||