feat(tci): key the radio ourselves and push a tone, to see what it wants

A first real transmission settled one question and raised a better one.
With the receive stream open and six seconds of transmit, the radio sent
282 frames of receive audio and NOTHING else: no chrono, no transmit
audio. So the chrono the documentation describes is not offered to a
client that merely happens to be connected while the operator keys the
microphone, and waiting for it to appear is waiting for nothing.

The reading that fits is that the radio asks for audio when the
transmission is the CLIENT'S and takes the microphone when it is the
operator's — which makes the experiment obvious. Key it from here, push a
1 kHz tone, and watch. Chrono frames appearing gives their size and
cadence by measurement instead of by guesswork; no chrono but a tone on
the meter is just as useful, because then the pacing is optional and the
voice keyer can push frames at the rate the stream already runs at.

A tone rather than silence so the answer shows on the power meter and not
only in the log.

It transmits, so: an explicit button inside a warning box, five seconds,
capped at ten, and every path out unkeys — including the panic that has
not happened yet and a socket that dies mid-tone. A transmitter left keyed
by a defect is the one fault here that would reach somebody else's band.

Writes are now serialised too. send() held the lock only long enough to
read the connection, which was enough while every command came from the
poll loop; a stream of audio frames from a second goroutine is not, and
gorilla panics on a concurrent write rather than failing quietly.
This commit is contained in:
2026-08-25 23:18:11 +02:00
parent 95e57fb812
commit 848ce68ec5
7 changed files with 232 additions and 3 deletions
+27
View File
@@ -51,3 +51,30 @@ func (a *App) GetTCIAudioStatus() cat.TCIAudioStatus {
st, _ := a.cat.TCIAudioState()
return st
}
// ProbeTCITransmit keys the radio and pushes a tone over TCI, to find out
// whether the transmit half of the stream works at all.
//
// A first real transmission proved that the radio sends nothing extra while the
// OPERATOR keys it — 282 receive frames and no chrono over six seconds — so the
// only way forward is to key it from here and watch. Everything interesting
// lands in the log; see internal/cat/tci_tx_probe.go for what the two possible
// answers mean.
//
// THIS TRANSMITS. The button that calls it says so, and the radio must be on a
// dummy load.
func (a *App) ProbeTCITransmit(seconds int) error {
if a.cat == nil {
return fmt.Errorf("CAT not initialized")
}
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
p, ok := t.(interface {
ProbeTXStream(seconds int, toneHz float64) error
})
if !ok {
return fmt.Errorf("this CAT backend is not a TCI radio")
}
applog.Printf("tci: TX PROBE requested (%d s) — the radio should be on a dummy load", seconds)
return p.ProbeTXStream(seconds, 1000)
})
}
+19 -1
View File
@@ -58,7 +58,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio, ProbeTCITransmit,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1948,6 +1948,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// done for nothing.
const [tciAudio, setTciAudio] = useState<any>({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
const [tciRecBusy, setTciRecBusy] = useState(false);
const [tciTXBusy, setTciTXBusy] = useState(false);
// Whether the QSO recorder takes its audio from the radio's own stream.
const [tciRec, setTciRec] = useState(false);
useEffect(() => { GetTCIRecordAudio().then((v: boolean) => setTciRec(!!v)).catch(() => {}); }, []);
@@ -6601,6 +6602,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{!!tciRecPath && <span className="text-[11px] font-mono text-muted-foreground truncate">{tciRecPath}</span>}
</div>
)}
{/* The transmit experiment. It KEYS THE RADIO, which is why it is
worded as a warning and not as another test button: a first pass
on real hardware showed the radio sends nothing extra while the
operator keys it by hand, so the only way to learn what it wants
is to key it from here and push a tone. */}
<div className="rounded border border-caution-border bg-caution-muted p-2 space-y-1.5">
<Button variant="outline" size="sm" className="h-8" disabled={tciTXBusy}
onClick={() => {
setTciTXBusy(true);
ProbeTCITransmit(5)
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })))
.finally(() => setTciTXBusy(false));
}}>
{tciTXBusy ? t('aud.tciTXBusy') : t('aud.tciTX')}
</Button>
<p className="text-[11px] text-caution-muted-foreground">{t('aud.tciTXWarn')}</p>
</div>
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
<label className="flex items-start gap-2 text-xs cursor-pointer">
<Checkbox className="mt-0.5" checked={tciRec}
+2 -2
View File
@@ -494,7 +494,7 @@ const en: Dict = {
'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)',
'aud.fromRadioShort': 'From Radio', 'aud.toRadioShort': 'To Radio', 'aud.explainFrom': '= what you receive (used by the QSO recorder).', 'aud.explainTo': '= where voice-keyer messages are transmitted.',
'aud.monitorTitle': "Hear the rig's RX audio (From Radio) through your Listening device", 'aud.listenRadio': '▶ Listen to radio', 'aud.stopListening': '■ Stop listening',
'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.tciTitle': 'SunSDR receive audio over TCI (experimental)', 'aud.tciStart': 'Open the stream', 'aud.tciStop': 'Close the stream', 'aud.tciRec': 'Record 10 s to listen', 'aud.tciRecBusy': 'Recording…', 'aud.tciSilent': 'silent', 'aud.tciRecord': 'Record QSOs from this stream', 'aud.tciRecordHint': 'The QSO recorder takes the receive audio from the radio instead of a sound card — no virtual cable, nothing to select above. Your microphone is still recorded from the device chosen there.', 'aud.tciHint': 'Takes the receive audio straight from the radio over TCI, with no virtual audio cable and no second sound card. Receive only for now — the voice keyer still uses the devices above. Requires the CAT backend to be TCI.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).',
'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.tciTitle': 'SunSDR receive audio over TCI (experimental)', 'aud.tciStart': 'Open the stream', 'aud.tciStop': 'Close the stream', 'aud.tciRec': 'Record 10 s to listen', 'aud.tciRecBusy': 'Recording…', 'aud.tciSilent': 'silent', 'aud.tciRecord': 'Record QSOs from this stream', 'aud.tciTX': 'Test transmit — 5 s tone', 'aud.tciTXBusy': 'Transmitting…', 'aud.tciTXWarn': 'THIS TRANSMITS. Put the radio on a dummy load first. It keys the radio and sends a 1 kHz tone over TCI for five seconds, to find out whether the transmit half of the stream works — the log holds the answer.', 'aud.tciRecordHint': 'The QSO recorder takes the receive audio from the radio instead of a sound card — no virtual cable, nothing to select above. Your microphone is still recorded from the device chosen there.', 'aud.tciHint': 'Takes the receive audio straight from the radio over TCI, with no virtual audio cable and no second sound card. Receive only for now — the voice keyer still uses the devices above. Requires the CAT backend to be TCI.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).',
'aud.txTitle': 'Key PTT and pipe your live mic into the rig (To Radio device)', 'aud.talkRadio': '🎙 Talk to radio (TX)', 'aud.stopTalk': '■ Stop talking (TX)',
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
@@ -962,7 +962,7 @@ const fr: Dict = {
'aud.noneDefault': '— aucun / défaut système —', 'aud.defaultTag': '(défaut)',
'aud.fromRadioShort': 'Depuis la radio', 'aud.toRadioShort': 'Vers la radio', 'aud.explainFrom': "= ce que vous recevez (utilisé par l'enregistreur de QSO).", 'aud.explainTo': '= où sont émis les messages du manipulateur vocal.',
'aud.monitorTitle': "Écouter l'audio RX du poste (Depuis la radio) sur votre périphérique d'écoute", 'aud.listenRadio': '▶ Écouter la radio', 'aud.stopListening': "■ Arrêter l'écoute",
'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.tciTitle': 'Audio de réception SunSDR par TCI (expérimental)', 'aud.tciStart': 'Ouvrir le flux', 'aud.tciStop': 'Fermer le flux', 'aud.tciRec': 'Enregistrer 10 s pour écoute', 'aud.tciRecBusy': 'Enregistrement…', 'aud.tciSilent': 'silence', 'aud.tciRecord': 'Enregistrer les QSO depuis ce flux', 'aud.tciRecordHint': "L'enregistreur prend l'audio de réception sur la radio au lieu d'une carte son — aucun câble virtuel, rien à choisir au-dessus. Ton micro reste enregistré depuis le périphérique sélectionné là-haut.", 'aud.tciHint': "Prend l'audio de réception directement sur la radio via TCI, sans câble audio virtuel ni seconde carte son. Réception seulement pour l'instant — le manipulateur vocal utilise toujours les périphériques ci-dessus. Nécessite le CAT réglé sur TCI.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).",
'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.tciTitle': 'Audio de réception SunSDR par TCI (expérimental)', 'aud.tciStart': 'Ouvrir le flux', 'aud.tciStop': 'Fermer le flux', 'aud.tciRec': 'Enregistrer 10 s pour écoute', 'aud.tciRecBusy': 'Enregistrement…', 'aud.tciSilent': 'silence', 'aud.tciRecord': 'Enregistrer les QSO depuis ce flux', 'aud.tciTX': "Test d'émission — tonalité 5 s", 'aud.tciTXBusy': 'Émission…', 'aud.tciTXWarn': "CECI ÉMET. Mettre la radio sur charge fictive d'abord. Le poste est passé en émission et une tonalité de 1 kHz est envoyée par TCI pendant cinq secondes, pour savoir si la moitié émission du flux fonctionne — la réponse est dans le journal.", 'aud.tciRecordHint': "L'enregistreur prend l'audio de réception sur la radio au lieu d'une carte son — aucun câble virtuel, rien à choisir au-dessus. Ton micro reste enregistré depuis le périphérique sélectionné là-haut.", 'aud.tciHint': "Prend l'audio de réception directement sur la radio via TCI, sans câble audio virtuel ni seconde carte son. Réception seulement pour l'instant — le manipulateur vocal utilise toujours les périphériques ci-dessus. Nécessite le CAT réglé sur TCI.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).",
'aud.txTitle': 'Activer le PTT et envoyer votre micro vers le poste (périphérique « Vers la radio »)', 'aud.talkRadio': '🎙 Parler à la radio (TX)', 'aud.stopTalk': '■ Arrêter de parler (TX)',
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
+2
View File
@@ -839,6 +839,8 @@ export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function ProbeTCITransmit(arg1:number):Promise<void>;
export function PublishLogNow():Promise<string>;
export function QSLCopyTemplateToActiveProfile(arg1:number):Promise<number>;
+4
View File
@@ -1618,6 +1618,10 @@ export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function ProbeTCITransmit(arg1) {
return window['go']['main']['App']['ProbeTCITransmit'](arg1);
}
export function PublishLogNow() {
return window['go']['main']['App']['PublishLogNow']();
}
+7
View File
@@ -39,6 +39,11 @@ type TCI struct {
// without a virtual audio cable in the way.
audio tciAudio
// One writer at a time. send() held the lock only long enough to READ conn,
// which was enough while every command came from the poll loop — a stream of
// audio frames from a second goroutine is not, and gorilla panics on a
// concurrent write rather than corrupting the socket quietly.
wmu sync.Mutex // serialises writes to the socket (text AND binary)
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
@@ -346,6 +351,8 @@ func (t *TCI) send(cmd string) error {
if c == nil {
return fmt.Errorf("tci: not connected")
}
t.wmu.Lock()
defer t.wmu.Unlock()
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
debugLog.Printf("TCI: send %q failed: %v", cmd, err)
+171
View File
@@ -0,0 +1,171 @@
package cat
// Sending audio TO the radio over TCI, on purpose, to find out whether it works.
//
// A first transmission on a real SunSDR settled one question and raised a
// better one. With the receive stream open and six seconds of transmit, the
// radio sent 282 frames of receive audio and NOTHING else: no transmit-audio
// frames, no chrono. So the chrono the documentation describes is not offered
// to a client that merely happens to be connected while the operator keys the
// microphone — and waiting for it to appear on its own is waiting for nothing.
//
// The reading that fits: the radio asks for audio when the transmission is the
// CLIENT'S, and takes the microphone when it is the operator's. Which makes the
// experiment obvious — key the radio from here, push a tone, and watch. Two
// things can happen and both are worth having:
//
// - Chrono frames appear. Their size and cadence are then measured rather
// than guessed, and the voice keyer is written against them.
// - No chrono, but the tone comes out of the radio. Then the chrono is
// optional pacing, and a voice keyer can simply push frames at the rate the
// stream runs at, which is far simpler.
//
// A tone, not silence: it makes the power meter move, so the answer is visible
// on the front panel and not only in a log. THIS TRANSMITS — it is behind an
// explicit button, it is capped, and it unkeys on every path out, including a
// panic and a socket that dies mid-tone.
import (
"encoding/binary"
"fmt"
"math"
"time"
"github.com/gorilla/websocket"
)
// tciTXProbeMaxSeconds caps the pass. Long enough to read a power meter and
// count frames, short enough that a carrier left running by a defect is a
// mistake rather than an incident.
const tciTXProbeMaxSeconds = 10
// sendBinaryFrame writes one TCI binary frame: the 16-word header the radio's
// own frames carry, then the payload.
func (t *TCI) sendBinaryFrame(stype, rx, rate, length int, payload []byte) error {
t.mu.Lock()
c := t.conn
t.mu.Unlock()
if c == nil {
return fmt.Errorf("tci: not connected")
}
buf := make([]byte, tciHeaderBytes+len(payload))
le := binary.LittleEndian
le.PutUint32(buf[0:], uint32(rx))
le.PutUint32(buf[4:], uint32(rate))
// format=3, codec=0: mirrored from what this radio SENDS. The field is
// documented as an enumeration whose numbering did not survive contact with
// the firmware — the receive stream answers 3 for four-byte floats — so the
// only defensible choice is to speak back exactly what was spoken to us.
le.PutUint32(buf[8:], 3)
le.PutUint32(buf[12:], 0)
le.PutUint32(buf[16:], 0) // crc — the radio sends 0 and does not check ours
le.PutUint32(buf[20:], uint32(length))
le.PutUint32(buf[24:], uint32(stype))
copy(buf[tciHeaderBytes:], payload)
t.wmu.Lock()
defer t.wmu.Unlock()
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
return c.WriteMessage(websocket.BinaryMessage, buf)
}
// ProbeTXStream keys the radio, streams a tone over TCI for the given number of
// seconds, unkeys, and reports what came back.
//
// INTO A DUMMY LOAD. It is a real transmission at whatever drive the radio is
// set to, and the caller is expected to have said so to the operator.
func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error {
if seconds <= 0 {
seconds = 5
}
if seconds > tciTXProbeMaxSeconds {
seconds = tciTXProbeMaxSeconds
}
if toneHz <= 0 {
toneHz = 1000
}
t.mu.Lock()
allowed, known, connected := t.txAllowed, t.txAllowedKnown, t.conn != nil
t.mu.Unlock()
if !connected {
return fmt.Errorf("not connected to the radio")
}
if known && !allowed {
return fmt.Errorf("the radio refuses transmitting (tx_enable is false)")
}
t.audio.mu.Lock()
rate := t.audio.rate
streaming := t.audio.want
t.audio.mu.Unlock()
if rate <= 0 {
rate = 48000
}
if !streaming {
// Not fatal — the radio may well accept transmit frames on a socket with
// no receive stream — but it is the first thing to suspect if nothing
// happens, and it belongs in the log next to the result.
debugLog.Printf("TCI: TX PROBE — the receive stream is closed; if this produces nothing, open it and try again")
}
// 2048 samples a frame is what the radio told us it streams
// (audio_stream_samples:2048), so it is the size it is built around. Two
// interleaved channels, as its own frames carry.
const samplesPerFrame = 2048
const chans = 2
perFrame := samplesPerFrame / chans
frames := seconds * rate / perFrame
interval := time.Duration(float64(perFrame) / float64(rate) * float64(time.Second))
debugLog.Printf("TCI: TX PROBE starting — %d s of a %.0f Hz tone, %d frames of %d samples every %v, INTO A DUMMY LOAD",
seconds, toneHz, frames, samplesPerFrame, interval.Round(time.Millisecond))
if err := t.SetPTT(true); err != nil {
return fmt.Errorf("could not key the radio: %w", err)
}
// Every path out unkeys, including the panic that has not happened yet. A
// transmitter left keyed by a defect is the one fault in this file that
// would matter to somebody else's band.
defer func() {
if err := t.SetPTT(false); err != nil {
debugLog.Printf("TCI: TX PROBE — UNKEY FAILED (%v) — stop the transmission at the radio", err)
}
}()
payload := make([]byte, samplesPerFrame*4)
le := binary.LittleEndian
phase := 0.0
step := 2 * math.Pi * toneHz / float64(rate)
// A quarter of full scale: enough to read on a meter, short of the level
// where the radio's own processing starts deciding things for us.
const amp = 0.25
var sent int
for i := 0; i < frames; i++ {
for s := 0; s < samplesPerFrame; s += chans {
v := float32(math.Sin(phase) * amp)
phase += step
if phase > 2*math.Pi {
phase -= 2 * math.Pi
}
bits := math.Float32bits(v)
le.PutUint32(payload[s*4:], bits) // left
le.PutUint32(payload[(s+1)*4:], bits) // right
}
if err := t.sendBinaryFrame(tciStreamTXAudio, 0, rate, samplesPerFrame, payload); err != nil {
debugLog.Printf("TCI: TX PROBE — stopped after %d frames: %v", sent, err)
break
}
sent++
time.Sleep(interval)
}
t.audio.mu.Lock()
chrono := t.audio.countByType[tciStreamTXChrono]
txa := t.audio.countByType[tciStreamTXAudio]
t.audio.mu.Unlock()
debugLog.Printf("TCI: TX PROBE finished — sent %d frames; the radio sent %d chrono and %d transmit-audio frames in total this session",
sent, chrono, txa)
return nil
}