From 1718bf6f3391bb9f953288d170ec547f2cffb5e8 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 30 Jul 2026 23:46:27 +0200 Subject: [PATCH] fix: say when a capture device is streaming nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 6 ++++- changelog.json | 6 +++-- internal/audio/recorder.go | 50 +++++++++++++++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/app.go b/app.go index 4dda0ae..4146a14 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/changelog.json b/changelog.json index efecc6a..67dbce5 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index 3dd197a..3c52d85 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -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() {