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
+9 -1
View File
@@ -71,7 +71,15 @@ func tciToRecorderPCM(rate int, samples []float32) []byte {
// for it: opening a 384 kB/s stream on a station that records nothing is work
// the radio does for nobody.
func (a *App) startTCIRecording() {
if a.cat == nil || a.settingOr(keyTCIRecAudio, "") != "1" {
if a.cat == nil {
return
}
// Two ways to ask for the same thing: the option in the TCI section, or
// simply choosing the radio as the "From radio" device. The second is where
// an operator looks first — it is the question they are already answering —
// so it has to work as well as the tick box.
cfg, _ := a.GetAudioSettings()
if a.settingOr(keyTCIRecAudio, "") != "1" && cfg.FromRadio != audio.NetworkDeviceID {
return
}
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {