Files
rouggy fdc2378191 feat(tci): name the transmit audio source when keying, per the protocol
The specification settles what a night of experiments could only guess at.
TRX takes an optional THIRD argument naming the signal source — tci, mic1,
mic2, micPC, ecoder2 — and TCI 2.0 says it plainly: 'The signal for
transmitting is always taken from the microphone selected in the
ExpertSDR3. If a third-party software connected via TCI wants to transmit
its audio signal, you must specify the third argument - TCI.'

Without it the radio sends no chrono at all, whatever the mode. That is
what the SSB attempts ran into, and what was misread here as 'digital
modes only' — the mode was never the rule, the missing argument was.

So OpsLog says it, following the 'To radio' device: 'tci' when the voice
keyer owns the transmission, nothing at all otherwise, which leaves the
operator's own microphone alone for every other PTT. Nobody has to find
that setting in ExpertSDR3 and set it again for every mode, which is how
it is remembered there.

Two more things from the same document. A chrono with no audio ready is
now answered with silence rather than left unanswered — the vendor calls
that preferable. And the receive stream declares float32 and two channels
instead of trusting the defaults: they are the documented defaults, but a
default is something another program sharing this radio can have changed,
and a stream in an unexpected format is heard as noise rather than as a
mistake.
2026-08-26 00:21:32 +02:00

118 lines
4.4 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 when its TRANSMIT AUDIO SOURCE is TCI rather than the
// microphone. This first read as "digital modes only" — SSB produced
// nothing four times over, DIGU answered at once — but the mode was a
// coincidence: ExpertSDR3 keeps that source setting per mode, and it was on
// the microphone in SSB. Which is why nothing is refused on the strength of
// the mode: the radio is asked, and it answers by asking or by staying
// quiet.
// 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.
//
// All of it was established with a tone probe — key the radio, push a sine,
// watch — which is gone now that it has served its purpose: it answered the
// three questions above, confirmed 80 W out on a real SunSDR, and had no
// business in front of an operator once the voice keyer worked. What is left is
// the exchange it discovered, with tci_tx_play.go supplying the message.
import (
"encoding/binary"
"fmt"
"time"
"github.com/gorilla/websocket"
)
// 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 {
// Answered with silence rather than left unanswered. TCI 2.0 §3.4: "the
// client may not send a response or may send a signal with zero counts,
// which corresponds to no signal - this option is preferable."
payload = make([]byte, samples*4)
t.audio.mu.Lock()
t.audio.txShort++
t.audio.mu.Unlock()
}
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()
}