Files
OpsLog/internal/audio/devices.go
T
2026-06-04 00:46:35 +02:00

104 lines
3.4 KiB
Go

//go:build windows
// Package audio drives Windows audio endpoints via WASAPI (through go-ole /
// go-wca) — pure Go, no CGO, the same COM stack OmniRig already uses. It
// powers the Digital Voice Keyer (record/play voice messages to the rig) and
// the QSO recorder (rolling-buffer capture saved as WAV).
package audio
import (
"fmt"
"runtime"
"sort"
"github.com/go-ole/go-ole"
"github.com/moutend/go-wca/pkg/wca"
)
// Device is one audio endpoint (a capture input or a render output).
type Device struct {
ID string `json:"id"` // stable WASAPI endpoint id (persisted)
Name string `json:"name"` // friendly name shown in dropdowns
Default bool `json:"default"` // is this the system default endpoint
}
// ListInputDevices returns the active capture endpoints — microphones,
// line-in, and the soundcard input wired to the rig's audio out ("From Radio").
func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) }
// ListOutputDevices returns the active render endpoints — speakers and the
// soundcard output wired to the rig's mic/data input ("To Radio").
func ListOutputDevices() ([]Device, error) { return listEndpoints(wca.ERender) }
// listEndpoints enumerates active endpoints for a data-flow direction. COM is
// thread-affine, so we lock the OS thread and Co(Un)Initialize around the work
// — this is a one-shot call from a Wails binding, not a long-lived session.
func listEndpoints(flow uint32) (out []Device, err error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
// 0x1 = S_FALSE → already initialised on this thread, fine.
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 {
return nil, fmt.Errorf("CoInitializeEx: %w", e)
}
}
defer ole.CoUninitialize()
var mmde *wca.IMMDeviceEnumerator
if e := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
wca.IID_IMMDeviceEnumerator, &mmde); e != nil {
return nil, fmt.Errorf("create MMDeviceEnumerator: %w", e)
}
defer mmde.Release()
// Record the default endpoint id so the UI can flag it.
var defID string
var defDev *wca.IMMDevice
if e := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &defDev); e == nil && defDev != nil {
_ = defDev.GetId(&defID)
defDev.Release()
}
var coll *wca.IMMDeviceCollection
if e := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); e != nil {
return nil, fmt.Errorf("enum endpoints: %w", e)
}
defer coll.Release()
var count uint32
if e := coll.GetCount(&count); e != nil {
return nil, fmt.Errorf("count endpoints: %w", e)
}
for i := uint32(0); i < count; i++ {
var dev *wca.IMMDevice
if coll.Item(i, &dev) != nil || dev == nil {
continue
}
var id string
_ = dev.GetId(&id)
name := endpointName(dev, id)
dev.Release()
out = append(out, Device{ID: id, Name: name, Default: id != "" && id == defID})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// endpointName reads PKEY_Device_FriendlyName, falling back to the raw id.
func endpointName(dev *wca.IMMDevice, fallback string) string {
var ps *wca.IPropertyStore
if dev.OpenPropertyStore(wca.STGM_READ, &ps) != nil || ps == nil {
return fallback
}
defer ps.Release()
var pv wca.PROPVARIANT
if ps.GetValue(&wca.PKEY_Device_FriendlyName, &pv) != nil {
return fallback
}
if s := pv.String(); s != "" {
return s
}
return fallback
}