333 lines
8.1 KiB
Go
333 lines
8.1 KiB
Go
//go:build windows
|
|
|
|
package audio
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
|
|
// Defaults to a no-op so the package is usable without wiring.
|
|
var LogSink = func(string, ...any) {}
|
|
|
|
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
|
// event with a stack trace instead of a silent process-killing crash. (It can't
|
|
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
|
// — but it catches any Go-level panic in capture/mix.)
|
|
func recoverGoroutine(what string) {
|
|
if r := recover(); r != nil {
|
|
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
|
|
}
|
|
}
|
|
|
|
// 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
|
|
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
|
gainB float64 // mic gain
|
|
|
|
// 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{gainA: 1, gainB: 1} }
|
|
|
|
// SetGains sets the per-source mix levels (1.0 = unity). Use this to balance a
|
|
// hot mic against quieter rig RX audio. Values ≤0 fall back to unity.
|
|
func (r *Recorder) SetGains(fromGain, micGain float64) {
|
|
if fromGain <= 0 {
|
|
fromGain = 1
|
|
}
|
|
if micGain <= 0 {
|
|
micGain = 1
|
|
}
|
|
r.srcMu.Lock()
|
|
r.gainA, r.gainB = fromGain, micGain
|
|
r.srcMu.Unlock()
|
|
}
|
|
|
|
// scaleSample applies gain to a sample with clamping.
|
|
func scaleSample(s int16, g float64) int16 {
|
|
if g == 1 {
|
|
return s
|
|
}
|
|
v := float64(s) * g
|
|
if v > 32767 {
|
|
return 32767
|
|
}
|
|
if v < -32768 {
|
|
return -32768
|
|
}
|
|
return int16(v)
|
|
}
|
|
|
|
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()
|
|
defer recoverGoroutine("recorder capture (radio)")
|
|
_ = 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()
|
|
defer recoverGoroutine("recorder capture (mic)")
|
|
_ = 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()
|
|
defer recoverGoroutine("recorder mixer")
|
|
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(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
|
}
|
|
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))
|
|
for i, s := range r.bufA {
|
|
mixed[i] = scaleSample(s, r.gainA)
|
|
}
|
|
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
|
|
}
|
|
|
|
// RestartQSO begins a fresh accumulation even if one is already active —
|
|
// re-seeding from the pre-roll ring. Used when the target QSO changes (a new
|
|
// call+freq from a clicked spot or an external app) so the previous take is
|
|
// dropped and a new one starts from the pre-roll, rather than continuing to
|
|
// accumulate the old contact.
|
|
func (r *Recorder) RestartQSO() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !r.running {
|
|
return
|
|
}
|
|
r.acc = append([]int16(nil), r.ring...)
|
|
r.active = true
|
|
}
|
|
|
|
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
|
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
|
// new take immediately. Pair with WritePCM to encode/write off the hot path so
|
|
// a long recording doesn't delay logging.
|
|
func (r *Recorder) TakeQSO() ([]byte, error) {
|
|
r.mu.Lock()
|
|
if !r.active {
|
|
r.mu.Unlock()
|
|
return nil, fmt.Errorf("no active recording")
|
|
}
|
|
samples := r.acc
|
|
r.acc, r.active = nil, false
|
|
r.mu.Unlock()
|
|
if len(samples) == 0 {
|
|
return nil, fmt.Errorf("recording was empty")
|
|
}
|
|
return int16sToBytes(samples), nil
|
|
}
|
|
|
|
// WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in
|
|
// .mp3). Slow for MP3 — call off the logging path.
|
|
func WritePCM(path string, data []byte) error {
|
|
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
|
|
return writeMP3(path, data)
|
|
}
|
|
return writeWAV(path, data)
|
|
}
|
|
|
|
// SaveQSO snapshots and writes the recording in one call (synchronous).
|
|
func (r *Recorder) SaveQSO(path string) error {
|
|
data, err := r.TakeQSO()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return WritePCM(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
|
|
}
|