The situation, in the operator's words: working VP2MAA, recording, and he asks to hear his own signal. So: stop, play, and the recording is still saved with the QSO. Stopping FREEZES the take rather than ending it — nothing more is captured, nothing is discarded, and logging writes the file exactly as before. Playback reuses the voice keyer's path: same output device, same PTT keying and release with its generation guard, so a replay cannot be cut short by a stale unkey. Playing is refused while still recording. The transmitted audio would feed back into the same take on any rig monitoring its own transmission, and stopping is one click the operator has already made — it is their statement that the take is finished, not a hoop. The elapsed clock freezes with the recording and resumes from where it stopped, because it now shows the LENGTH OF THE FILE rather than the time since the QSO began. Only a fresh take returns it to zero.
406 lines
11 KiB
Go
406 lines
11 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
|
|
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
|
// captured so far is kept and the take still ends normally when the QSO is
|
|
// logged. It is what lets an operator stop, play the recording back on the
|
|
// air to the station they are working, and still have it saved with the QSO.
|
|
paused 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.paused, r.bufA, r.bufB = nil, nil, false, 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.paused {
|
|
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, r.paused = true, false
|
|
}
|
|
|
|
// 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, r.paused = true, false
|
|
}
|
|
|
|
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
|
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
|
|
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
|
|
// contain only audio from this moment onward. Used when the contact you entered
|
|
// was already in a long QSO and you want to record just your own exchange.
|
|
// No-op if not running; if no take is active it begins one (empty).
|
|
func (r *Recorder) ResetQSOClock() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !r.running {
|
|
return
|
|
}
|
|
r.acc = nil
|
|
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, r.paused = nil, false, 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, r.paused = nil, false, 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, r.paused = nil, nil, false, 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
|
|
}
|
|
|
|
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
|
// nothing is thrown away, and logging the QSO still writes the file.
|
|
//
|
|
// This exists for one situation, which is common enough on the bands to be
|
|
// worth the state: you are working a station, you have been recording, and they
|
|
// ask to hear it. You stop, you play it back to them on the air, and the
|
|
// recording is still saved with the QSO afterwards.
|
|
func (r *Recorder) PauseQSO() bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !r.active {
|
|
return false
|
|
}
|
|
r.paused = true
|
|
return true
|
|
}
|
|
|
|
// ResumeQSO continues an interrupted take, appending to what is already there.
|
|
func (r *Recorder) ResumeQSO() bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !r.active {
|
|
return false
|
|
}
|
|
r.paused = false
|
|
return true
|
|
}
|
|
|
|
// Paused reports whether the active take is frozen.
|
|
func (r *Recorder) Paused() bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.active && r.paused
|
|
}
|
|
|
|
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
|
//
|
|
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
|
// back and then still saved with the QSO. The copy is deliberate: the caller
|
|
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
|
func (r *Recorder) PeekQSO() ([]byte, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !r.active {
|
|
return nil, fmt.Errorf("no active recording")
|
|
}
|
|
if len(r.acc) == 0 {
|
|
return nil, fmt.Errorf("recording is empty")
|
|
}
|
|
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
|
}
|