Bläddra i källkod

feat: Windows Core Audio volume control

- 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
Jan Svabenik 1 månad sedan
förälder
incheckning
c4c1cff7a7
7 ändrade filer med 343 tillägg och 24 borttagningar
  1. +4
    -1
      go.mod
  2. +2
    -0
      go.sum
  3. +46
    -4
      internal/server/server.go
  4. +239
    -0
      internal/volume/volume.go
  5. +37
    -14
      web/static/app.js
  6. +4
    -2
      web/static/index.html
  7. +11
    -3
      web/static/style.css

+ 4
- 1
go.mod Visa fil

@@ -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
)

+ 2
- 0
go.sum Visa fil

@@ -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=

+ 46
- 4
internal/server/server.go Visa fil

@@ -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) {


+ 239
- 0
internal/volume/volume.go Visa fil

@@ -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
})
}

+ 37
- 14
web/static/app.js Visa fil

@@ -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) {


+ 4
- 2
web/static/index.html Visa fil

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


+ 11
- 3
web/static/style.css Visa fil

@@ -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 {


Laddar…
Avbryt
Spara