feat: added record qso dvk
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||
// one playback at a time. Device ids are passed per call so the host can route
|
||||
// recording to the mic and playback to the rig (or the preview speakers).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
playStop chan struct{}
|
||||
onChange func() // fired on any record/playback state transition
|
||||
}
|
||||
|
||||
type recResult struct {
|
||||
pcm []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// NewManager creates a DVK manager. onChange (optional) is called whenever the
|
||||
// recording/playback state changes, so the host can push an audio:status event.
|
||||
func NewManager(onChange func()) *Manager { return &Manager{onChange: onChange} }
|
||||
|
||||
func (m *Manager) notify() {
|
||||
if m.onChange != nil {
|
||||
m.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
// StartRecording begins capturing from deviceID into memory. Finish with
|
||||
// StopRecording (which writes the WAV) or CancelRecording (which discards it).
|
||||
func (m *Manager) StartRecording(deviceID string) error {
|
||||
m.mu.Lock()
|
||||
if m.recStop != nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("already recording")
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
done := make(chan recResult, 1)
|
||||
m.recStop, m.recDone = stop, done
|
||||
m.mu.Unlock() // release BEFORE notify — onChange re-enters via IsRecording()
|
||||
go func() {
|
||||
pcm, err := recordPCM(deviceID, stop)
|
||||
done <- recResult{pcm, err}
|
||||
}()
|
||||
m.notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopRecording ends the capture and writes it to path as a WAV file.
|
||||
func (m *Manager) StopRecording(path string) error {
|
||||
m.mu.Lock()
|
||||
stop, done := m.recStop, m.recDone
|
||||
m.recStop, m.recDone = nil, nil
|
||||
m.mu.Unlock()
|
||||
if stop == nil {
|
||||
return fmt.Errorf("not recording")
|
||||
}
|
||||
close(stop)
|
||||
res := <-done
|
||||
m.notify()
|
||||
if res.err != nil {
|
||||
return res.err
|
||||
}
|
||||
if len(res.pcm) == 0 {
|
||||
return fmt.Errorf("captured no audio (check the recording device)")
|
||||
}
|
||||
return writeWAV(path, res.pcm)
|
||||
}
|
||||
|
||||
// CancelRecording aborts a recording without saving.
|
||||
func (m *Manager) CancelRecording() {
|
||||
m.mu.Lock()
|
||||
stop, done := m.recStop, m.recDone
|
||||
m.recStop, m.recDone = nil, nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
<-done
|
||||
m.notify()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) IsRecording() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.recStop != nil
|
||||
}
|
||||
|
||||
func (m *Manager) IsPlaying() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.playStop != nil
|
||||
}
|
||||
|
||||
// Play renders a WAV file to deviceID. Any current playback is stopped first.
|
||||
// Returns immediately; playback runs in the background.
|
||||
func (m *Manager) Play(deviceID, path string) error {
|
||||
pcm, rate, ch, bits, err := readWAV(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.StopPlayback()
|
||||
stop := make(chan struct{})
|
||||
m.mu.Lock()
|
||||
m.playStop = stop
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
|
||||
m.mu.Lock()
|
||||
if m.playStop == stop {
|
||||
m.playStop = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.notify()
|
||||
}()
|
||||
m.notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopPlayback halts any in-progress playback.
|
||||
func (m *Manager) StopPlayback() {
|
||||
m.mu.Lock()
|
||||
stop := m.playStop
|
||||
m.playStop = nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
m.notify()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||
)
|
||||
|
||||
// mp3Rate is the encode sample rate. The capture pipeline is 16 kHz, but the
|
||||
// Shine encoder emits broken "free-format" frames at MPEG-2 rates (16/22/24
|
||||
// kHz) that most players reject. Encoding at an MPEG-1 rate (we upsample ×2 to
|
||||
// 32 kHz) produces standard, universally-playable MP3s.
|
||||
const mp3Rate = sampleRate * 2 // 32000
|
||||
|
||||
// writeMP3 encodes 16 kHz mono 16-bit PCM to a standard MP3 file using the
|
||||
// pure-Go Shine encoder (no CGO). Two quirks are worked around:
|
||||
// - 16 kHz (MPEG-2) yields broken free-format frames → upsample ×2 to 32 kHz.
|
||||
// - Shine's Write only encodes half the samples for MONO input (its loop
|
||||
// advances by samples_per_pass*2). Feeding STEREO interleaved data (the
|
||||
// encoder reads samples_per_pass*channels per pass) encodes everything, so
|
||||
// we duplicate mono → L=R stereo.
|
||||
func writeMP3(path string, pcm []byte) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
mono32 := upsample2(bytesToInt16(pcm)) // 16 kHz → 32 kHz mono
|
||||
stereo := make([]int16, len(mono32)*2) // L=R interleaved
|
||||
for i, v := range mono32 {
|
||||
stereo[2*i], stereo[2*i+1] = v, v
|
||||
}
|
||||
enc := mp3.NewEncoder(mp3Rate, 2)
|
||||
return enc.Write(f, stereo)
|
||||
}
|
||||
|
||||
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
|
||||
func upsample2(in []int16) []int16 {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]int16, len(in)*2)
|
||||
for i := range in {
|
||||
out[2*i] = in[i]
|
||||
if i+1 < len(in) {
|
||||
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
|
||||
} else {
|
||||
out[2*i+1] = in[i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
|
||||
// recording can begin a few seconds BEFORE the operator entered the callsign.
|
||||
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
|
||||
// single mono track, so both sides of the contact are captured.
|
||||
//
|
||||
// Lifecycle: Start() runs capture+mix in the background. BeginQSO() snapshots
|
||||
// the pre-roll and starts accumulating; SaveQSO() writes the WAV; DiscardQSO()
|
||||
// drops it. Stop() tears down capture.
|
||||
type Recorder struct {
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
running bool
|
||||
|
||||
prerollSamples int
|
||||
|
||||
// Per-source sample queues (guarded by srcMu), drained by the mixer.
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
twoSrc bool
|
||||
|
||||
// Mixed output state (guarded by mu).
|
||||
ring []int16 // last prerollSamples of mixed audio
|
||||
active bool
|
||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||
}
|
||||
|
||||
func NewRecorder() *Recorder { return &Recorder{} }
|
||||
|
||||
func (r *Recorder) Running() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.running
|
||||
}
|
||||
|
||||
func (r *Recorder) Active() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.active
|
||||
}
|
||||
|
||||
// Start begins continuous capture from fromDev (required) mixed with micDev
|
||||
// (optional — "" or same as fromDev → single source). prerollSec is how much
|
||||
// audio to retain ahead of BeginQSO.
|
||||
func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
r.mu.Lock()
|
||||
if r.running {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if prerollSec < 0 {
|
||||
prerollSec = 0
|
||||
}
|
||||
r.prerollSamples = prerollSec * sampleRate
|
||||
r.twoSrc = micDev != "" && micDev != fromDev
|
||||
r.stopCh = make(chan struct{})
|
||||
r.running = true
|
||||
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
||||
stop := r.stopCh
|
||||
twoSrc := r.twoSrc
|
||||
r.mu.Unlock()
|
||||
|
||||
// Capture goroutine(s) feed the per-source queues.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufA = append(r.bufA, s...)
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}()
|
||||
if twoSrc {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufB = append(r.bufB, s...)
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// Mixer goroutine.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
t := time.NewTicker(40 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
r.mixTick()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||
// ring + active accumulation.
|
||||
func (r *Recorder) mixTick() {
|
||||
r.srcMu.Lock()
|
||||
var mixed []int16
|
||||
if r.twoSrc {
|
||||
n := len(r.bufA)
|
||||
if len(r.bufB) < n {
|
||||
n = len(r.bufB)
|
||||
}
|
||||
if n > 0 {
|
||||
mixed = make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
mixed[i] = clampSum(r.bufA[i], r.bufB[i])
|
||||
}
|
||||
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||
}
|
||||
// Drift guard: if the clocks diverge, drop the excess so the two
|
||||
// sources stay roughly aligned (≤1 s skew).
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
}
|
||||
} else if len(r.bufA) > 0 {
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
copy(mixed, r.bufA)
|
||||
r.bufA = r.bufA[:0]
|
||||
}
|
||||
r.srcMu.Unlock()
|
||||
|
||||
if len(mixed) == 0 {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.ring = append(r.ring, mixed...)
|
||||
if len(r.ring) > r.prerollSamples {
|
||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||
}
|
||||
if r.active {
|
||||
r.acc = append(r.acc, mixed...)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// BeginQSO starts accumulating a recording, seeded with the current pre-roll.
|
||||
// No-op if already accumulating or not running.
|
||||
func (r *Recorder) BeginQSO() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.running || r.active {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
}
|
||||
|
||||
// SaveQSO writes the accumulated recording to path as a WAV and stops
|
||||
// accumulating. Returns an error if no recording was active.
|
||||
func (r *Recorder) SaveQSO(path string) error {
|
||||
r.mu.Lock()
|
||||
if !r.active {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("no active recording")
|
||||
}
|
||||
samples := r.acc
|
||||
r.acc, r.active = nil, false
|
||||
r.mu.Unlock()
|
||||
if len(samples) == 0 {
|
||||
return fmt.Errorf("recording was empty")
|
||||
}
|
||||
data := int16sToBytes(samples)
|
||||
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
|
||||
return writeMP3(path, data)
|
||||
}
|
||||
return writeWAV(path, data)
|
||||
}
|
||||
|
||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||
func (r *Recorder) DiscardQSO() {
|
||||
r.mu.Lock()
|
||||
r.acc, r.active = nil, false
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stop tears down capture+mix.
|
||||
func (r *Recorder) Stop() {
|
||||
r.mu.Lock()
|
||||
if !r.running {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.running = false
|
||||
stop := r.stopCh
|
||||
r.stopCh = nil
|
||||
r.mu.Unlock()
|
||||
close(stop)
|
||||
r.wg.Wait()
|
||||
r.mu.Lock()
|
||||
r.ring, r.acc, r.active = nil, nil, false
|
||||
r.mu.Unlock()
|
||||
r.srcMu.Lock()
|
||||
r.bufA, r.bufB = nil, nil
|
||||
r.srcMu.Unlock()
|
||||
}
|
||||
|
||||
func clampSum(a, b int16) int16 {
|
||||
v := int32(a) + int32(b)
|
||||
if v > 32767 {
|
||||
return 32767
|
||||
}
|
||||
if v < -32768 {
|
||||
return -32768
|
||||
}
|
||||
return int16(v)
|
||||
}
|
||||
|
||||
func bytesToInt16(b []byte) []int16 {
|
||||
out := make([]int16, len(b)/2)
|
||||
for i := range out {
|
||||
out[i] = int16(binary.LittleEndian.Uint16(b[i*2:]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func int16sToBytes(s []int16) []byte {
|
||||
b := make([]byte, len(s)*2)
|
||||
for i, v := range s {
|
||||
binary.LittleEndian.PutUint16(b[i*2:], uint16(v))
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build windows
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// The DVK/recorder pipeline uses a single fixed PCM format end-to-end: 16 kHz
|
||||
// mono 16-bit. That's plenty for SSB voice (3 kHz audio bandwidth), keeps files
|
||||
// tiny (~32 KB/s), and — fed through WASAPI's AUTOCONVERTPCM — plays/records on
|
||||
// any device regardless of its native mix format.
|
||||
const (
|
||||
sampleRate = 16000
|
||||
channels = 1
|
||||
bitsPerSample = 16
|
||||
blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2)
|
||||
bytesPerSec = sampleRate * blockAlign // =32000
|
||||
)
|
||||
|
||||
// writeWAV writes 16-bit PCM as a canonical RIFF/WAVE file.
|
||||
func writeWAV(path string, pcm []byte) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
dataLen := len(pcm)
|
||||
put := func(v any) { _ = binary.Write(f, binary.LittleEndian, v) }
|
||||
f.WriteString("RIFF")
|
||||
put(uint32(36 + dataLen))
|
||||
f.WriteString("WAVE")
|
||||
f.WriteString("fmt ")
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
f.WriteString("data")
|
||||
put(uint32(dataLen))
|
||||
_, err = f.Write(pcm)
|
||||
return err
|
||||
}
|
||||
|
||||
// readWAV reads a PCM WAV and returns the raw sample bytes plus its format.
|
||||
// Handles arbitrary chunk ordering (walks the RIFF chunk list).
|
||||
func readWAV(path string) (pcm []byte, rate, ch, bits int, err error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
if len(b) < 12 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
|
||||
return nil, 0, 0, 0, fmt.Errorf("not a WAVE file")
|
||||
}
|
||||
i := 12
|
||||
for i+8 <= len(b) {
|
||||
id := string(b[i : i+4])
|
||||
size := int(binary.LittleEndian.Uint32(b[i+4 : i+8]))
|
||||
body := i + 8
|
||||
if body+size > len(b) {
|
||||
size = len(b) - body
|
||||
}
|
||||
switch id {
|
||||
case "fmt ":
|
||||
if size >= 16 {
|
||||
ch = int(binary.LittleEndian.Uint16(b[body+2 : body+4]))
|
||||
rate = int(binary.LittleEndian.Uint32(b[body+4 : body+8]))
|
||||
bits = int(binary.LittleEndian.Uint16(b[body+14 : body+16]))
|
||||
}
|
||||
case "data":
|
||||
pcm = b[body : body+size]
|
||||
}
|
||||
i = body + size
|
||||
if size%2 == 1 {
|
||||
i++ // chunks are word-aligned
|
||||
}
|
||||
}
|
||||
if pcm == nil || rate == 0 {
|
||||
return nil, 0, 0, 0, fmt.Errorf("WAV missing fmt/data")
|
||||
}
|
||||
return pcm, rate, ch, bits, nil
|
||||
}
|
||||
Reference in New Issue
Block a user