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() }