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