fix: say when a capture device is streaming nothing

Second empty recording from the same station, and this time NEITHER source had
delivered a sample — so the fix for a silent second source could not apply, and
the log still said only "recording was empty" at the end of the QSO.

Two blind spots, both now closed:

  - captureStream's error was discarded. A device that cannot be opened — renamed,
    unplugged, held by another program — looked exactly like one that is merely
    quiet.
  - A WASAPI device can open cleanly and then produce nothing at all. A DAX
    channel with no stream behind it does precisely that, and it is
    indistinguishable from silence until the file turns out to be empty.

A watchdog now reports both, three seconds in, and the first goes to the OPERATOR
as a toast rather than only to the log: they are mid-QSO, and by the time they
find out at save time the audio is gone for good.
This commit is contained in:
2026-07-30 23:46:27 +02:00
parent c4c5db3921
commit 1718bf6f33
3 changed files with 55 additions and 7 deletions
+5 -1
View File
@@ -844,7 +844,11 @@ func (a *App) startup(ctx context.Context) {
// Route CAT/OmniRig debug lines into the unified app log (they used to go
// to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
// A recorder that captures nothing must reach the OPERATOR, not just the log:
// they are mid-QSO, and by the time they notice at save time the audio is
// gone for good.
audio.AlertSink = func(format string, args ...any) { a.toast(fmt.Sprintf(format, args...)) }
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
db.LogSink = applog.Printf // which schema migrations ran, and how long they took
+4 -2
View File
@@ -7,14 +7,16 @@
"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.",
"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."
"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.",
"The recorder now warns on screen within three seconds when a capture device delivers no audio, instead of producing an empty file discovered when the QSO is logged."
],
"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.",
"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."
"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.",
"L'enregistreur avertit désormais à l'écran en trois secondes lorsqu'un périphérique de capture ne délivre aucun audio, au lieu de produire un fichier vide découvert à l'enregistrement du QSO."
]
},
{
+46 -4
View File
@@ -15,6 +15,14 @@ import (
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// AlertSink receives the few audio problems an operator must see WHILE they are
// operating, not afterwards in a log file — a capture device that opens but
// never streams being the one that matters: the recording is silently empty and
// nothing says so until the QSO is logged and gone.
//
// Set to a toast emitter at startup; a no-op keeps the package standalone.
var AlertSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
@@ -136,29 +144,63 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) {
// The error was discarded here. A device that cannot be opened — renamed,
// unplugged, held by another program — then looked exactly like a device
// that is merely quiet, and the recording came out empty with nothing in
// the log to say why.
if err := captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufA = append(r.bufA, s...)
r.lastA = time.Now()
r.srcMu.Unlock()
})
}); err != nil {
LogSink("recorder: capture from %q failed: %v", fromDev, err)
}
}()
if twoSrc {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) {
if err := captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufB = append(r.bufB, s...)
r.lastB = time.Now()
r.srcMu.Unlock()
})
}); err != nil {
LogSink("recorder: capture from %q failed: %v", micDev, err)
}
}()
}
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
// all — a DAX channel with no stream behind it does exactly that, and it is
// indistinguishable from silence until the recording turns out to be empty
// at the end of a QSO. Say it once, three seconds in, while there is still
// time to fix the setup.
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder watchdog")
select {
case <-stop:
return
case <-time.After(3 * time.Second):
}
r.srcMu.Lock()
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
r.srcMu.Unlock()
if aQuiet {
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", fromDev)
AlertSink("No audio from %s — nothing is being recorded", fromDev)
}
if twoSrc && bQuiet {
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", micDev)
}
}()
// Mixer goroutine.
r.wg.Add(1)
go func() {