//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 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() _ = 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(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 } // 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 }