diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index 42648a4..ff92dd7 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -335,12 +335,20 @@ func (r *Recorder) mixTick() { 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:]...) + // Drift guard: two sound cards run on their own clocks, so drop the + // excess to keep them within a second of each other. + // + // ONLY while both are actually running. A starved source is not drift: it + // made this guard throw away the radio audio a second at a time during + // the grace period, so the opening of every recording was lost even + // though it had been captured. Waiting costs nothing now — whatever is + // buffered is written whole the moment the silent source is written off. + if len(r.bufA) > 0 && len(r.bufB) > 0 { + 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)) diff --git a/internal/audio/recorder_deadsource_test.go b/internal/audio/recorder_deadsource_test.go index f9585dd..d1acc0d 100644 --- a/internal/audio/recorder_deadsource_test.go +++ b/internal/audio/recorder_deadsource_test.go @@ -88,3 +88,36 @@ func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) { t.Errorf("sample = %d, want 700", r.acc[0]) } } + +// Nothing captured during the grace period is thrown away. +// +// The drift guard exists for two sound cards running on their own clocks. A +// STARVED source is not drift, and treating it as such discarded the radio +// audio a second at a time while the mixer was still waiting for the mic — so +// the opening of every CW recording was lost although it had been captured. +func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) { + r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate} + r.startedAt = time.Now() + + // Three seconds of radio audio arrive while the mic says nothing at all. + r.bufA = make([]int16, 3*sampleRate) + for i := range r.bufA { + r.bufA[i] = 500 + } + r.lastA = time.Now() + r.mixTick() // still inside the grace period: nothing is written yet… + + if len(r.acc) != 0 { + t.Fatalf("wrote %d samples before the mic was written off", len(r.acc)) + } + if len(r.bufA) != 3*sampleRate { + t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA)) + } + + // …and once the mic is written off, everything captured comes through. + r.startedAt = time.Now().Add(-5 * time.Second) + r.mixTick() + if len(r.acc) != 3*sampleRate { + t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate) + } +}