feat: stop a QSO recording and play it back on the air

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.
This commit is contained in:
2026-07-30 21:36:42 +02:00
parent f221c0ee1e
commit 069728ac68
7 changed files with 226 additions and 16 deletions
+64 -7
View File
@@ -52,6 +52,11 @@ type Recorder struct {
// 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)
}
@@ -114,7 +119,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
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
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()
@@ -206,7 +211,7 @@ func (r *Recorder) mixTick() {
if len(r.ring) > r.prerollSamples {
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
}
if r.active {
if r.active && !r.paused {
r.acc = append(r.acc, mixed...)
}
r.mu.Unlock()
@@ -221,7 +226,7 @@ func (r *Recorder) BeginQSO() {
return
}
r.acc = append([]int16(nil), r.ring...)
r.active = true
r.active, r.paused = true, false
}
// RestartQSO begins a fresh accumulation even if one is already active —
@@ -236,7 +241,7 @@ func (r *Recorder) RestartQSO() {
return
}
r.acc = append([]int16(nil), r.ring...)
r.active = true
r.active, r.paused = true, false
}
// ResetQSOClock restarts the active accumulation from ZERO — discarding
@@ -266,7 +271,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
return nil, fmt.Errorf("no active recording")
}
samples := r.acc
r.acc, r.active = nil, false
r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock()
if len(samples) == 0 {
return nil, fmt.Errorf("recording was empty")
@@ -295,7 +300,7 @@ func (r *Recorder) SaveQSO(path string) error {
// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
r.mu.Lock()
r.acc, r.active = nil, false
r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock()
}
@@ -313,7 +318,7 @@ func (r *Recorder) Stop() {
close(stop)
r.wg.Wait()
r.mu.Lock()
r.ring, r.acc, r.active = nil, nil, false
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
r.mu.Unlock()
r.srcMu.Lock()
r.bufA, r.bufB = nil, nil
@@ -346,3 +351,55 @@ func int16sToBytes(s []int16) []byte {
}
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
}