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
+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() {