Files
OpsLog/internal/cat/tci_tx_probe.go
T
rouggy c6294d9eb3 feat(tci): answer the radio's requests instead of pushing audio at it
Three transmissions on a real SunSDR settled how the transmit side works,
and none of it was guessable from the documentation.

The radio asks for audio only when the transmission is the CLIENT'S: with
the operator keying the microphone it sent 282 receive frames and nothing
else. And it asks only in a DIGITAL mode — keyed from here in SSB it stayed
silent four times over, and answered in DIGU immediately. In SSB the
modulator is wired to the microphone, which is also the honest answer to
'why can I hear myself but not the tone'.

The chrono turns out to be a REQUEST, not a clock. It carries no payload —
the message itself is the ask — and it names the size it wants in the
header: 2048 samples, two channels interleaved, 47 times a second, which
is 1024 pairs at 48 kHz, exactly real time.

So audio goes out in answer to a request and never on a timer of our own.
The timer was the first attempt and the radio ignored all 234 frames of
it. Answering also hands the pacing to the radio: no drift, no buffer to
tune, and the size taken from what it asked for rather than from what we
assumed. The sine keeps its phase across frames, since one restarted every
frame is a click 47 times a second.

A pass in SSB is now refused rather than attempted. It keys the
transmitter, produces nothing and teaches nobody anything — and it is
still a transmission.

The feed mechanism is the one the voice keyer will use: WAV samples in
place of the sine, everything else unchanged.
2026-08-25 23:27:26 +02:00

224 lines
7.7 KiB
Go

package cat
// Sending audio TO the radio over TCI.
//
// Three transmissions on a real SunSDR settled how this works, and none of it
// was guessable from the documentation:
//
// 1. The radio asks for audio only when the transmission is the CLIENT'S. With
// the operator keying the microphone it sent 282 receive frames and nothing
// else, over six seconds.
// 2. It asks only in a DIGITAL mode. Keyed from here in SSB: nothing, four
// times over. The same button in DIGU: chrono frames immediately. In SSB the
// modulator is wired to the microphone and no amount of network audio will
// reach it — which is also the honest answer to "why can I hear myself but
// not the tone".
// 3. The chrono is a REQUEST, not a clock to follow. It carries no payload —
// the message itself is the ask — and it names the size it wants in the
// header's length field: 2048 samples, two channels interleaved, arriving
// 47 times a second. Which is 1024 sample-pairs at 48 kHz, exactly real
// time, measured rather than assumed.
//
// So audio is sent in ANSWER to chrono, never on a timer of our own. A timer
// was the first attempt and the radio ignored every frame of it: 234 sent, none
// used. Answering the request is what makes the difference, and it also means
// the radio sets the pace — no drift, no buffer to tune.
//
// What remains here is the probe: a tone, on demand, to prove the path end to
// end on real hardware. The voice keyer will use the same feed mechanism with
// WAV samples in place of the sine.
import (
"encoding/binary"
"fmt"
"math"
"time"
"github.com/gorilla/websocket"
)
// tciTXProbeMaxSeconds caps the pass. Long enough to read a power meter, short
// enough that a carrier left running by a defect is a mistake and not 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)
}
// serveChrono answers one request for transmit audio.
//
// Called from the reader goroutine, so it does the least it can: take the
// frame from whatever is feeding, and write it. A feed that has run out returns
// nil and the request is counted rather than answered with silence — silence
// would be indistinguishable from a working stream on a meter.
func (t *TCI) serveChrono(rate, samples int) {
t.audio.mu.Lock()
feed := t.audio.txFeed
t.audio.mu.Unlock()
if feed == nil {
return
}
if samples <= 0 {
samples = 2048
}
payload := feed(samples)
if payload == nil {
t.audio.mu.Lock()
t.audio.txShort++
t.audio.mu.Unlock()
return
}
if rate <= 0 {
rate = 48000
}
if err := t.sendBinaryFrame(tciStreamTXAudio, 0, rate, samples, payload); err != nil {
debugLog.Printf("TCI: could not send transmit audio: %v", err)
return
}
t.audio.mu.Lock()
t.audio.txSent++
t.audio.mu.Unlock()
}
// setTXFeed installs (or clears) the source of transmit audio.
func (t *TCI) setTXFeed(fn func(samples int) []byte) {
t.audio.mu.Lock()
t.audio.txFeed = fn
t.audio.txSent, t.audio.txShort = 0, 0
t.audio.mu.Unlock()
}
// ProbeTXStream keys the radio, answers its chrono requests with a tone for the
// given number of seconds, unkeys, and reports what happened.
//
// INTO A DUMMY LOAD, AND IN A DIGITAL MODE. In SSB the radio takes the
// microphone and this produces nothing — which is a property of the radio, not
// a fault here, so it is said rather than worked around.
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
mode := t.mode
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)")
}
if !tciDigitalMode(mode) {
// Refused rather than attempted. A pass in SSB keys the transmitter,
// produces nothing, and teaches nobody anything — and it is still a
// transmission.
return fmt.Errorf("the radio is in %s: transmit audio over TCI only reaches the modulator in a digital mode (DIGU, DIGL, or an FT8/data mode) — switch mode and try again", mode)
}
t.audio.mu.Lock()
rate := t.audio.rate
t.audio.mu.Unlock()
if rate <= 0 {
rate = 48000
}
// The tone, generated on demand: the radio asks for a size and gets exactly
// that, at whatever pace it asks. Phase is carried across the calls, since a
// sine restarted every frame is a click 47 times a second.
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
const chans = 2
le := binary.LittleEndian
t.setTXFeed(func(samples int) []byte {
payload := make([]byte, samples*4)
for s := 0; s+chans-1 < samples; 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
}
return payload
})
defer t.setTXFeed(nil)
debugLog.Printf("TCI: TX PROBE starting — %d s of a %.0f Hz tone answered to the radio's own requests, mode %s, INTO A DUMMY LOAD",
seconds, toneHz, mode)
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 here that would reach
// 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)
}
}()
time.Sleep(time.Duration(seconds) * time.Second)
t.audio.mu.Lock()
sent, short := t.audio.txSent, t.audio.txShort
chrono := t.audio.countByType[tciStreamTXChrono] - t.audio.txMark[tciStreamTXChrono]
t.audio.mu.Unlock()
debugLog.Printf("TCI: TX PROBE finished — the radio asked %d times, %d frames sent, %d requests unanswered",
chrono, sent, short)
if sent == 0 {
debugLog.Printf("TCI: TX PROBE — the radio never asked for audio; in a digital mode it should, so check that ExpertSDR3 takes its transmit audio from TCI")
}
return nil
}
// tciDigitalMode says whether the radio's current mode is one where network
// audio reaches the modulator. Measured on a SunSDR: DIGU asks for audio, SSB
// never does.
func tciDigitalMode(mode string) bool {
switch mode {
case "digu", "digl", "DIGU", "DIGL", "FT8", "ft8", "FT4", "ft4", "DATA", "data", "RTTY", "rtty":
return true
}
return false
}