Files
OpsLog/internal/audio/engine.go
T
rouggy 5e0bb6e68e fix: recordings buried under hiss — the cheap resampler was folding it in
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.
2026-07-31 21:12:59 +02:00

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