diff --git a/changelog.json b/changelog.json index 6fdb635..efecc6a 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "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.", "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": [ "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'é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." ] }, { diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index 9bbbcdd..3dd197a 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -42,12 +42,19 @@ type Recorder struct { 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 + srcMu sync.Mutex + bufA []int16 // From Radio + 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 + 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 @@ -133,6 +140,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error { s := bytesToInt16(chunk) r.srcMu.Lock() r.bufA = append(r.bufA, s...) + r.lastA = time.Now() r.srcMu.Unlock() }) }() @@ -145,6 +153,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error { s := bytesToInt16(chunk) r.srcMu.Lock() r.bufB = append(r.bufB, s...) + r.lastB = time.Now() 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 // 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() { r.srcMu.Lock() var mixed []int16 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) if len(r.bufB) < n { n = len(r.bufB) } if n > 0 { + // Both alive again after one was written off. + r.deadA, r.deadB = false, false 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)) @@ -203,6 +263,12 @@ func (r *Recorder) mixTick() { } 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 { return } diff --git a/internal/audio/recorder_deadsource_test.go b/internal/audio/recorder_deadsource_test.go new file mode 100644 index 0000000..7d675b8 --- /dev/null +++ b/internal/audio/recorder_deadsource_test.go @@ -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)) + } +}