Reported precisely: on the air the voice is well clear of the noise; in the recording the noise is louder than the voice. Same audio, so the fault is in how it is captured, not in what is captured. AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY was set alongside AUTOCONVERTPCM. The name reads like a sensible default and is not: it selects the CHEAP sample-rate converter, for cases where quality does not matter. Taking a rig's 48 kHz stream down to our 16 kHz, it folds everything above 8 kHz back into the audio band — and receiver hiss is mostly high frequencies. The voice keeps its level, the noise arrives twice. Dropped, so Windows uses its normal converter, which filters before decimating. This affects every capture and render path in the app, the voice keyer included.
431 lines
12 KiB
Go
431 lines
12 KiB
Go
//go:build windows
|
|
|
|
package audio
|
|
|
|
import (
|
|
"fmt"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/go-ole/go-ole"
|
|
"github.com/moutend/go-wca/pkg/wca"
|
|
)
|
|
|
|
const (
|
|
// AUDCLNT_BUFFERFLAGS_SILENT — the capture packet is silent; emit zeros.
|
|
bufferFlagSilent uint32 = 0x1
|
|
// 1-second WASAPI buffer (REFERENCE_TIME is in 100-ns units).
|
|
bufferDuration100ns = 10_000_000
|
|
)
|
|
|
|
func coInit() error {
|
|
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
|
|
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 { // S_FALSE ok
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openDevice resolves an IMMDevice by endpoint id, falling back to the default
|
|
// endpoint for the flow when id is empty or not found. Caller must Release().
|
|
func openDevice(flow uint32, id string) (*wca.IMMDevice, error) {
|
|
var mmde *wca.IMMDeviceEnumerator
|
|
if err := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
|
|
wca.IID_IMMDeviceEnumerator, &mmde); err != nil {
|
|
return nil, fmt.Errorf("create enumerator: %w", err)
|
|
}
|
|
defer mmde.Release()
|
|
|
|
if id != "" {
|
|
var coll *wca.IMMDeviceCollection
|
|
if err := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); err == nil && coll != nil {
|
|
defer coll.Release()
|
|
var count uint32
|
|
coll.GetCount(&count)
|
|
for i := uint32(0); i < count; i++ {
|
|
var dev *wca.IMMDevice
|
|
if coll.Item(i, &dev) != nil || dev == nil {
|
|
continue
|
|
}
|
|
var did string
|
|
dev.GetId(&did)
|
|
if did == id {
|
|
return dev, nil // caller owns it
|
|
}
|
|
dev.Release()
|
|
}
|
|
}
|
|
}
|
|
var dev *wca.IMMDevice
|
|
if err := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &dev); err != nil {
|
|
return nil, fmt.Errorf("no audio endpoint (id %q): %w", id, err)
|
|
}
|
|
return dev, nil
|
|
}
|
|
|
|
// pcmFormat is the fixed capture format (16 kHz mono 16-bit PCM). WASAPI's
|
|
// AUTOCONVERTPCM resamples from the device's native mix format for us.
|
|
func pcmFormat() *wca.WAVEFORMATEX {
|
|
return &wca.WAVEFORMATEX{
|
|
WFormatTag: 1, // WAVE_FORMAT_PCM
|
|
NChannels: channels,
|
|
NSamplesPerSec: sampleRate,
|
|
NAvgBytesPerSec: bytesPerSec,
|
|
NBlockAlign: blockAlign,
|
|
WBitsPerSample: bitsPerSample,
|
|
CbSize: 0,
|
|
}
|
|
}
|
|
|
|
// autoConvert lets WASAPI resample between the device format and ours.
|
|
//
|
|
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
|
|
// it selects the CHEAP converter, meant for cases where quality does not matter.
|
|
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
|
|
// 8 kHz back into the audio band — and a receiver hiss is mostly high
|
|
// frequencies. The result was a recording where the noise sat ON TOP of the
|
|
// voice, while the same audio heard live had the voice well clear of it.
|
|
//
|
|
// Without the flag Windows uses its normal converter, which filters first.
|
|
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
|
|
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
|
// stop channel is closed.
|
|
func recordPCM(deviceID string, stop <-chan struct{}) ([]byte, error) {
|
|
out := make([]byte, 0, bytesPerSec*4)
|
|
err := captureStream(deviceID, stop, func(chunk []byte) { out = append(out, chunk...) })
|
|
return out, err
|
|
}
|
|
|
|
// captureStream opens a device and calls onChunk with freshly-captured 16 kHz
|
|
// mono 16-bit PCM as it arrives, until stop closes. onChunk receives a private
|
|
// copy it may retain. Runs on a COM-initialised, OS-locked thread.
|
|
func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error {
|
|
runtime.LockOSThread()
|
|
defer runtime.UnlockOSThread()
|
|
if err := coInit(); err != nil {
|
|
return fmt.Errorf("CoInitialize: %w", err)
|
|
}
|
|
defer ole.CoUninitialize()
|
|
|
|
dev, err := openDevice(wca.ECapture, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dev.Release()
|
|
|
|
var ac *wca.IAudioClient
|
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
|
return fmt.Errorf("activate capture: %w", err)
|
|
}
|
|
defer ac.Release()
|
|
|
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, pcmFormat(), nil); err != nil {
|
|
return fmt.Errorf("initialize capture: %w", err)
|
|
}
|
|
var acc *wca.IAudioCaptureClient
|
|
if err := ac.GetService(wca.IID_IAudioCaptureClient, &acc); err != nil {
|
|
return fmt.Errorf("get capture service: %w", err)
|
|
}
|
|
defer acc.Release()
|
|
|
|
if err := ac.Start(); err != nil {
|
|
return fmt.Errorf("start capture: %w", err)
|
|
}
|
|
defer ac.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return nil
|
|
default:
|
|
}
|
|
var packet uint32
|
|
if err := acc.GetNextPacketSize(&packet); err != nil {
|
|
return err
|
|
}
|
|
if packet == 0 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
continue
|
|
}
|
|
for packet > 0 {
|
|
var data *byte
|
|
var frames, flags uint32
|
|
var devpos, qpcpos uint64
|
|
if err := acc.GetBuffer(&data, &frames, &flags, &devpos, &qpcpos); err != nil {
|
|
return err
|
|
}
|
|
n := int(frames) * blockAlign
|
|
if n > 0 {
|
|
chunk := make([]byte, n)
|
|
if flags&bufferFlagSilent == 0 && data != nil {
|
|
copy(chunk, unsafe.Slice(data, n))
|
|
}
|
|
onChunk(chunk)
|
|
}
|
|
acc.ReleaseBuffer(frames)
|
|
if err := acc.GetNextPacketSize(&packet); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// playPCM renders raw PCM (with the given format) to a device, stopping early
|
|
// if the stop channel closes. Runs on a COM-initialised, OS-locked thread.
|
|
func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
|
if len(pcm) == 0 {
|
|
return nil
|
|
}
|
|
runtime.LockOSThread()
|
|
defer runtime.UnlockOSThread()
|
|
if err := coInit(); err != nil {
|
|
return fmt.Errorf("CoInitialize: %w", err)
|
|
}
|
|
defer ole.CoUninitialize()
|
|
|
|
dev, err := openDevice(wca.ERender, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dev.Release()
|
|
|
|
var ac *wca.IAudioClient
|
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
|
return fmt.Errorf("activate render: %w", err)
|
|
}
|
|
defer ac.Release()
|
|
|
|
frameBytes := ch * bits / 8
|
|
if frameBytes <= 0 {
|
|
return fmt.Errorf("bad audio format")
|
|
}
|
|
wfx := &wca.WAVEFORMATEX{
|
|
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
|
|
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
|
|
WBitsPerSample: uint16(bits), CbSize: 0,
|
|
}
|
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
|
|
return fmt.Errorf("initialize render: %w", err)
|
|
}
|
|
var bufFrames uint32
|
|
if err := ac.GetBufferSize(&bufFrames); err != nil {
|
|
return err
|
|
}
|
|
var arc *wca.IAudioRenderClient
|
|
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
|
|
return fmt.Errorf("get render service: %w", err)
|
|
}
|
|
defer arc.Release()
|
|
|
|
totalFrames := len(pcm) / frameBytes
|
|
written := 0
|
|
feed := func(maxFrames int) error {
|
|
if maxFrames <= 0 || written >= totalFrames {
|
|
return nil
|
|
}
|
|
n := totalFrames - written
|
|
if n > maxFrames {
|
|
n = maxFrames
|
|
}
|
|
var data *byte
|
|
if err := arc.GetBuffer(uint32(n), &data); err != nil {
|
|
return err
|
|
}
|
|
dst := unsafe.Slice(data, n*frameBytes)
|
|
copy(dst, pcm[written*frameBytes:(written+n)*frameBytes])
|
|
arc.ReleaseBuffer(uint32(n), 0)
|
|
written += n
|
|
return nil
|
|
}
|
|
|
|
// Pre-fill before starting to avoid an initial glitch.
|
|
if err := feed(int(bufFrames)); err != nil {
|
|
return err
|
|
}
|
|
if err := ac.Start(); err != nil {
|
|
return fmt.Errorf("start render: %w", err)
|
|
}
|
|
defer ac.Stop()
|
|
|
|
for written < totalFrames {
|
|
select {
|
|
case <-stop:
|
|
return nil
|
|
default:
|
|
}
|
|
var padding uint32
|
|
ac.GetCurrentPadding(&padding)
|
|
if err := feed(int(bufFrames - padding)); err != nil {
|
|
return err
|
|
}
|
|
time.Sleep(8 * time.Millisecond)
|
|
}
|
|
// Drain the remaining buffered audio.
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return nil
|
|
default:
|
|
}
|
|
var padding uint32
|
|
if ac.GetCurrentPadding(&padding) != nil || padding == 0 {
|
|
return nil
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
|
|
// render stream. Producers (a USB-codec capture, or a decoded network audio
|
|
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
|
|
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
|
|
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
|
|
// without touching the render side, mirroring the civTransport split on the CAT
|
|
// side. On overflow the oldest audio is dropped to keep latency bounded; on
|
|
// underrun Pull simply returns short and the render loop pads with silence.
|
|
type pcmRing struct {
|
|
mu sync.Mutex
|
|
buf []byte
|
|
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
|
|
}
|
|
|
|
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
|
|
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
|
|
func newPCMRing(maxBytes int) *pcmRing {
|
|
if maxBytes <= 0 {
|
|
maxBytes = bytesPerSec // 1 s default
|
|
}
|
|
return &pcmRing{max: maxBytes}
|
|
}
|
|
|
|
// Push appends samples, dropping the oldest audio if the backlog would exceed
|
|
// the cap (a slow/absent consumer never makes the producer block or grow without
|
|
// bound). A short glitch beats runaway latency for live monitoring.
|
|
func (r *pcmRing) Push(p []byte) {
|
|
if len(p) == 0 {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
r.buf = append(r.buf, p...)
|
|
if len(r.buf) > r.max {
|
|
drop := len(r.buf) - r.max
|
|
r.buf = append(r.buf[:0], r.buf[drop:]...)
|
|
}
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
|
|
// when empty. The render loop pads any shortfall with silence.
|
|
func (r *pcmRing) pull(maxBytes int) []byte {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if len(r.buf) == 0 || maxBytes <= 0 {
|
|
return nil
|
|
}
|
|
n := maxBytes
|
|
if n > len(r.buf) {
|
|
n = len(r.buf)
|
|
}
|
|
out := make([]byte, n)
|
|
copy(out, r.buf[:n])
|
|
r.buf = append(r.buf[:0], r.buf[n:]...)
|
|
return out
|
|
}
|
|
|
|
// renderStream continuously renders PCM pulled from src to a device until stop
|
|
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
|
// writes silence rather than glitching, keeping the WASAPI clock steady so live
|
|
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
|
|
// COM-initialised, OS-locked thread.
|
|
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
|
|
runtime.LockOSThread()
|
|
defer runtime.UnlockOSThread()
|
|
if err := coInit(); err != nil {
|
|
return fmt.Errorf("CoInitialize: %w", err)
|
|
}
|
|
defer ole.CoUninitialize()
|
|
|
|
dev, err := openDevice(wca.ERender, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dev.Release()
|
|
|
|
var ac *wca.IAudioClient
|
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
|
return fmt.Errorf("activate render: %w", err)
|
|
}
|
|
defer ac.Release()
|
|
|
|
frameBytes := ch * bits / 8
|
|
if frameBytes <= 0 {
|
|
return fmt.Errorf("bad audio format")
|
|
}
|
|
wfx := &wca.WAVEFORMATEX{
|
|
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
|
|
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
|
|
WBitsPerSample: uint16(bits), CbSize: 0,
|
|
}
|
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
|
|
return fmt.Errorf("initialize render: %w", err)
|
|
}
|
|
var bufFrames uint32
|
|
if err := ac.GetBufferSize(&bufFrames); err != nil {
|
|
return err
|
|
}
|
|
var arc *wca.IAudioRenderClient
|
|
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
|
|
return fmt.Errorf("get render service: %w", err)
|
|
}
|
|
defer arc.Release()
|
|
|
|
// feed fills up to `frames` render frames: as much real audio as the ring
|
|
// has, the remainder silence (so the buffer stays full and the clock steady).
|
|
feed := func(frames int) error {
|
|
if frames <= 0 {
|
|
return nil
|
|
}
|
|
var data *byte
|
|
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
|
|
return err
|
|
}
|
|
dst := unsafe.Slice(data, frames*frameBytes)
|
|
got := src.pull(frames * frameBytes)
|
|
n := copy(dst, got)
|
|
for i := n; i < len(dst); i++ {
|
|
dst[i] = 0 // silence-fill the shortfall
|
|
}
|
|
arc.ReleaseBuffer(uint32(frames), 0)
|
|
return nil
|
|
}
|
|
|
|
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
|
|
return err
|
|
}
|
|
if err := ac.Start(); err != nil {
|
|
return fmt.Errorf("start render: %w", err)
|
|
}
|
|
defer ac.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return nil
|
|
default:
|
|
}
|
|
var padding uint32
|
|
ac.GetCurrentPadding(&padding)
|
|
if err := feed(int(bufFrames - padding)); err != nil {
|
|
return err
|
|
}
|
|
time.Sleep(8 * time.Millisecond)
|
|
}
|
|
}
|