feat: added record qso dvk

This commit is contained in:
2026-06-04 00:46:35 +02:00
parent 1a425a1b0d
commit a2a29c66d2
24 changed files with 3098 additions and 346 deletions
+250
View File
@@ -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
}