The watchdog fired exactly as intended and said:
no audio at all from "{0.0.1.00000000}.{6a27abfd-f277-496b-b6f1-f92ec284c636}"
which tells the operator nothing they can act on. "DAX RX 1 (FlexRadio DAX)"
points straight at the DAX panel.
Endpoint ids are what gets CONFIGURED and stored, so they are what the recorder
holds; the friendly name is resolved only when something has gone wrong. When
the endpoint cannot be found at all the id is still shown — a device that has
disappeared explains an empty recording too.
131 lines
4.2 KiB
Go
131 lines
4.2 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
|
|
}
|
|
|
|
// DeviceName resolves an endpoint id to its friendly name.
|
|
//
|
|
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
|
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
|
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
|
// straight at the DAX panel.
|
|
//
|
|
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
|
// seeing: a device that has disappeared explains an empty recording too.
|
|
func DeviceName(id string) string {
|
|
if id == "" {
|
|
return "(none)"
|
|
}
|
|
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
|
devs, err := list()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, d := range devs {
|
|
if d.ID == id {
|
|
return d.Name
|
|
}
|
|
}
|
|
}
|
|
return id
|
|
}
|