fix: a silent audio source no longer empties the whole recording

From an operator's log: a Flex with From Radio on DAX RX and Recording mic on
DAX Mic, a full CW QSO recorded, and at log time "snapshot failed: recording
was empty".

The mixer took min(len(A), len(B)) samples. In CW the mic path does not run, so
DAX Mic delivered nothing, min stayed 0, and the drift guard then threw the
radio audio away a second at a time. Two devices configured meant the recording
depended on the quieter one being alive.

A source that has delivered nothing for 1.5 s is now treated as absent and the
other is recorded alone, with one line in the log saying so — half a recording
is worth having, silence is not. Both are mixed again the moment the missing one
comes back.

Neither is written off before it has EVER spoken: at startup both timestamps are
zero, and declaring one dead then would clip the opening of every recording.
That case is tested too.
This commit is contained in:
2026-07-30 23:39:54 +02:00
parent a1ceea978b
commit c4c5db3921
3 changed files with 143 additions and 8 deletions
+4 -2
View File
@@ -6,13 +6,15 @@
"FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.", "FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.",
"QSO recording works on CW again. Your own sidetone is not in the file, the other station is.", "QSO recording works on CW again. Your own sidetone is not in the file, the other station is.",
"Playing a recording on the air now says why when it cannot — most often no audio output to the radio is configured.", "Playing a recording on the air now says why when it cannot — most often no audio output to the radio is configured.",
"The play-on-air button is hidden on CW: the transmitter builds its tone from the keyer and ignores the audio path. Recording, stopping and resuming stay available." "The play-on-air button is hidden on CW: the transmitter builds its tone from the keyer and ignores the audio path. Recording, stopping and resuming stay available.",
"A QSO recording no longer comes out empty when one of the two audio sources is silent — the case in CW, where the mic device delivers nothing. The live source is recorded alone."
], ],
"fr": [ "fr": [
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.", "FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.",
"L'enregistrement des QSO fonctionne à nouveau en CW. Votre propre signal d'écoute n'est pas dans le fichier, celui du correspondant l'est.", "L'enregistrement des QSO fonctionne à nouveau en CW. Votre propre signal d'écoute n'est pas dans le fichier, celui du correspondant l'est.",
"L'émission d'un enregistrement indique désormais pourquoi elle échoue — le plus souvent aucune sortie audio vers la radio n'est configurée.", "L'émission d'un enregistrement indique désormais pourquoi elle échoue — le plus souvent aucune sortie audio vers la radio n'est configurée.",
"Le bouton d'émission de l'enregistrement est masqué en CW : l'émetteur fabrique sa tonalité à partir du manipulateur et ignore la chaîne audio. Enregistrer, arrêter et reprendre restent disponibles." "Le bouton d'émission de l'enregistrement est masqué en CW : l'émetteur fabrique sa tonalité à partir du manipulateur et ignore la chaîne audio. Enregistrer, arrêter et reprendre restent disponibles.",
"Un enregistrement de QSO n'est plus vide quand l'une des deux sources audio est muette — le cas en CW, où le périphérique micro ne débite rien. La source active est enregistrée seule."
] ]
}, },
{ {
+66
View File
@@ -45,6 +45,13 @@ type Recorder struct {
srcMu sync.Mutex srcMu sync.Mutex
bufA []int16 // From Radio bufA []int16 // From Radio
bufB []int16 // mic bufB []int16 // mic
// When each source last delivered samples. A configured device that never
// 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
// 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
twoSrc bool twoSrc bool
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
gainB float64 // mic gain gainB float64 // mic gain
@@ -133,6 +140,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
r.bufA = append(r.bufA, s...) r.bufA = append(r.bufA, s...)
r.lastA = time.Now()
r.srcMu.Unlock() r.srcMu.Unlock()
}) })
}() }()
@@ -145,6 +153,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
r.bufB = append(r.bufB, s...) r.bufB = append(r.bufB, s...)
r.lastB = time.Now()
r.srcMu.Unlock() r.srcMu.Unlock()
}) })
}() }()
@@ -171,15 +180,66 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
// mixTick drains the source queues, mixes what's available, and appends to the // mixTick drains the source queues, mixes what's available, and appends to the
// ring + active accumulation. // ring + active accumulation.
// deadSourceAfter is how long a source may deliver nothing before the recorder
// carries on without it. Long enough not to trip on a scheduling hiccup, short
// enough that almost nothing is lost from the source that IS working.
const deadSourceAfter = 1500 * time.Millisecond
func (r *Recorder) mixTick() { func (r *Recorder) mixTick() {
r.srcMu.Lock() r.srcMu.Lock()
var mixed []int16 var mixed []int16
if r.twoSrc { if r.twoSrc {
// A source that has been silent for a while is treated as ABSENT and the
// other one is recorded alone.
//
// Two sources used to mean min(len(A), len(B)) samples: if one device
// delivered nothing, NOTHING was recorded, and the drift guard below
// then threw the live source away a second at a time. That is exactly
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
// 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.
if bDry && !aDry && len(r.bufA) > 0 {
if !r.deadB {
r.deadB = true
LogSink("recorder: the second audio source is silent — recording the radio alone")
}
mixed = make([]int16, len(r.bufA))
for i, v := range r.bufA {
mixed[i] = scaleSample(v, r.gainA)
}
r.bufA = r.bufA[:0]
r.bufB = r.bufB[:0]
r.srcMu.Unlock()
r.store(mixed)
return
}
if aDry && !bDry && len(r.bufB) > 0 {
if !r.deadA {
r.deadA = true
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
}
mixed = make([]int16, len(r.bufB))
for i, v := range r.bufB {
mixed[i] = scaleSample(v, r.gainB)
}
r.bufA = r.bufA[:0]
r.bufB = r.bufB[:0]
r.srcMu.Unlock()
r.store(mixed)
return
}
n := len(r.bufA) n := len(r.bufA)
if len(r.bufB) < n { if len(r.bufB) < n {
n = len(r.bufB) n = len(r.bufB)
} }
if n > 0 { if n > 0 {
// Both alive again after one was written off.
r.deadA, r.deadB = false, false
mixed = make([]int16, n) mixed = make([]int16, n)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB)) mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
@@ -203,6 +263,12 @@ func (r *Recorder) mixTick() {
} }
r.srcMu.Unlock() r.srcMu.Unlock()
r.store(mixed)
}
// store appends mixed samples to the pre-roll ring and, when a take is running,
// to the take itself.
func (r *Recorder) store(mixed []int16) {
if len(mixed) == 0 { if len(mixed) == 0 {
return return
} }
@@ -0,0 +1,67 @@
package audio
import (
"testing"
"time"
)
// A silent second source must not silence the recording.
//
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
// recorded. The whole QSO ended in "recording was empty".
func TestMixerSurvivesASilentSource(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
// The radio has been talking; the mic has not spoken for well over the
// grace period.
r.bufA = make([]int16, 800)
for i := range r.bufA {
r.bufA[i] = 1000
}
r.lastA = time.Now()
r.lastB = time.Now().Add(-5 * time.Second)
r.mixTick()
if len(r.acc) == 0 {
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
}
if r.acc[0] != 1000 {
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
}
}
// While BOTH sources are alive the two are mixed, as before.
func TestMixerStillMixesBothSources(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.bufA = []int16{100, 100, 100}
r.bufB = []int16{50, 50, 50}
now := time.Now()
r.lastA, r.lastB = now, now
r.mixTick()
if len(r.acc) != 3 {
t.Fatalf("recorded %d samples, want 3", len(r.acc))
}
if r.acc[0] != 150 {
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
}
}
// 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) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
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))
}
}