From 311479c52f2221b5cf9a562a1c9bbf1a04b7dd91 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 31 Jul 2026 00:15:11 +0200 Subject: [PATCH] fix: write off an audio source that never delivers anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator said it from the start: in CW there is no sidetone, only reception. My previous fix did not cover it — it wrote off a source that STOPPED, but a source that never starts has no last-delivery time, so it stayed forever "not yet dry" and held the mixer at min(A,B)=0. Three empty recordings while the radio audio streamed perfectly. A source with no delivery is now measured from when capture started. Tested both ways: nothing is written off in the first moments, and a source that has never spoken is written off once the grace period has passed. --- internal/audio/recorder.go | 26 +++++++++++++--- internal/audio/recorder_deadsource_test.go | 35 ++++++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index d75c89f..2b2f9d3 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -57,6 +57,9 @@ type Recorder struct { // produces anything is not an error anywhere — the capture call just sits // there — so the only way to notice is to watch the clock. lastA, lastB time.Time + // startedAt is when capture began — the reference for a source that has not + // delivered anything at all yet. + startedAt time.Time // deadB (deadA) latches once a source has been declared silent, so the // warning is logged once rather than 25 times a second. deadA, deadB bool @@ -158,7 +161,9 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error { r.twoSrc = micDev != "" && micDev != fromDev r.stopCh = make(chan struct{}) r.running = true + r.startedAt = time.Now() r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil + r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false stop := r.stopCh twoSrc := r.twoSrc r.mu.Unlock() @@ -265,10 +270,23 @@ func (r *Recorder) mixTick() { // path is not running — and the operator got "recording was empty" after // a whole QSO. Half a recording is worth having; silence is not. now := time.Now() - aDry := !r.lastA.IsZero() && now.Sub(r.lastA) > deadSourceAfter - bDry := !r.lastB.IsZero() && now.Sub(r.lastB) > deadSourceAfter - // Never before either source has spoken at all: at startup both are zero - // and neither should be written off. + // A source is dry when it has been quiet for too long — and a source that + // has NEVER delivered is measured from when capture started, because it + // has no last-delivery time of its own. + // + // The first version required a source to have spoken at least once. That + // covers a device that stops, but not the one that actually happens: a + // DAX Mic channel that is simply switched off never delivers a single + // sample, so it stayed "not yet dry" forever and took the whole recording + // down with it. Three empty CW recordings, with the radio audio streaming + // perfectly the entire time. + dry := func(last time.Time) bool { + if last.IsZero() { + return now.Sub(r.startedAt) > deadSourceAfter + } + return now.Sub(last) > deadSourceAfter + } + aDry, bDry := dry(r.lastA), dry(r.lastB) if bDry && !aDry && len(r.bufA) > 0 { if !r.deadB { r.deadB = true diff --git a/internal/audio/recorder_deadsource_test.go b/internal/audio/recorder_deadsource_test.go index 7d675b8..f9585dd 100644 --- a/internal/audio/recorder_deadsource_test.go +++ b/internal/audio/recorder_deadsource_test.go @@ -51,17 +51,40 @@ func TestMixerStillMixesBothSources(t *testing.T) { } } -// Before either source has EVER delivered, neither may be written off: at -// startup both timestamps are zero, and declaring one dead then would drop the -// first moments of every recording. -func TestMixerWaitsForBothAtStartup(t *testing.T) { +// Just after capture starts, a source that has not delivered yet is NOT written +// off: the first samples take a moment to arrive, and declaring the mic dead at +// once would drop the opening of every recording. +func TestMixerWaitsBrieflyAtStartup(t *testing.T) { r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate} + r.startedAt = time.Now() r.bufA = []int16{100, 100} - // lastA/lastB both zero — nothing has been heard from either yet. r.mixTick() if len(r.acc) != 0 { - t.Errorf("recorded %d samples before the second source was known to be silent", len(r.acc)) + t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc)) + } +} + +// A source that has NEVER delivered is written off once enough time has passed. +// +// This is the case that actually happens, and the one the first version missed: +// a DAX Mic channel switched off delivers not one sample, so it had no +// last-delivery time and stayed forever "not yet dry" — taking the whole +// recording down with it while the radio audio streamed perfectly. +func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) { + r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate} + r.startedAt = time.Now().Add(-5 * time.Second) + r.bufA = []int16{700, 700, 700} + r.lastA = time.Now() + // lastB stays zero: the mic has never produced anything at all. + + r.mixTick() + + if len(r.acc) != 3 { + t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc)) + } + if r.acc[0] != 700 { + t.Errorf("sample = %d, want 700", r.acc[0]) } }