feat(tci): the voice keyer can send its messages through the radio

The radio now appears as a device in both audio lists — 'Radio (TCI
network audio)' — so the receive audio and the voice keyer can both take
the CAT link instead of a sound card. No virtual cable, no second card, no
Windows mixer between the recording and the air.

The transmit side reuses the exchange the tone probe established, with a
WAV in place of the sine: the radio asks, we answer with the next slice,
and it sets the pace. The message is converted once, up front, rather than
per frame — a voice message is a few hundred kilobytes, and resampling
inside a callback that has 21 ms to answer would put arithmetic on the
path where a late frame is a gap on the air.

The PTT is untouched by all this: the keyer keys before and unkeys after
exactly as it does with a sound card, so a transmission is bracketed by
the same code whichever way the audio travels. And the playback runs OFF
the CAT goroutine, since everything else about the rig goes through that
one place and a ten-second message would otherwise freeze the frequency
display and the antenna following for ten seconds.

Two refusals rather than silent failure. A radio that has gone away stops
being offered as a device at all, and a radio whose transmit audio source
is still the microphone is caught within a fifth of a second — a voice
keyer that transmits silence is worse than one that says why it will not.
This commit is contained in:
2026-08-25 23:59:09 +02:00
parent d4d22eb4b2
commit deeb654482
6 changed files with 429 additions and 34 deletions
+67
View File
@@ -0,0 +1,67 @@
package audio
// Playing a message through the RADIO instead of a sound card.
//
// A SunSDR takes its transmit audio over TCI, on the same socket as the
// commands, so the voice keyer can hand it the message directly: no virtual
// cable, no second sound card, no Windows mixer between the recording and the
// air. To everything above, that radio is simply another output device.
//
// The device it presents itself as is a name rather than a WASAPI endpoint id,
// which is why Play checks for it before opening anything: there is no endpoint
// to open, and asking Windows for one produces a confusing error about a device
// that does not exist rather than the truth, which is that nothing is connected
// to the radio.
import (
"errors"
"sync"
)
// NetworkDeviceID is the id the radio-over-network output carries in the
// settings and in the device lists. A fixed string, not a Windows endpoint id:
// it is chosen by us and must survive a radio being switched off and on.
const NetworkDeviceID = "net:radio"
// NetworkPlayer sends already-decoded PCM to the radio, returning when the
// message has been played or when stop is closed.
//
// It carries the same arguments as the sound-card path so that Play can hand
// over whatever it read, and the radio can decide what converting it needs —
// the sample rate a WAV was recorded at is not the radio's business until the
// moment it has to be resampled.
type NetworkPlayer func(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
var (
netMu sync.RWMutex
netPlayer NetworkPlayer
)
// SetNetworkPlayer installs (or clears, with nil) the radio's transmit path.
//
// Package-level rather than per-Manager: there is one radio, the CAT backend
// owns it, and a Manager that happened to be built before the radio connected
// would otherwise be permanently unable to reach it.
func SetNetworkPlayer(fn NetworkPlayer) {
netMu.Lock()
netPlayer = fn
netMu.Unlock()
}
// networkPlayer returns the installed player, or nil.
func networkPlayer() NetworkPlayer {
netMu.RLock()
defer netMu.RUnlock()
return netPlayer
}
// NetworkPlayerReady says whether a radio is currently able to take transmit
// audio, so the settings panel can offer the option honestly rather than
// listing a device that would fail when used.
func NetworkPlayerReady() bool { return networkPlayer() != nil }
// errNoNetworkRadio is what a message played to a radio that is not there
// comes back with. Named, because "the device could not be opened" would send
// an operator hunting through Windows sound settings for a device that never
// existed.
var errNoNetworkRadio = errors.New("no radio is connected to take the audio — check the CAT link (the radio output only works with a TCI radio)")