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 }