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. // // 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. Confirmed on a SunSDR: 80 W out of a 1 kHz tone at 70% of // full scale into 80% drive. // // If the radio's transmit audio source is the microphone rather than TCI it // will not ask for anything, and this stops within a fifth of a second and says // which setting to change. 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 drive := t.drive 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) { // A NOTE, not a refusal. // // The first experiments said "digital modes only": SSB produced nothing // four times over, DIGU answered at once. That was a real observation // and the wrong rule. ExpertSDR3 has a TRANSMIT AUDIO SOURCE — the // microphone or TCI — and it was simply set to the microphone; the mode // had nothing to do with it. Refusing SSB would have blocked the one // thing a voice keyer exists for. debugLog.Printf("TCI: TX PROBE — mode is %s, not a digital mode. That is fine IF ExpertSDR3's transmit audio source is set to TCI rather than the microphone; if it is not, the radio will not ask for audio and this stops straight away", 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) // Near full scale. // // A quarter was the first choice, out of caution, and the first real test // showed exactly what that produces: a clean signal on the panadapter and a // wattmeter that never moves. In a digital mode the radio expects a line // level it can drive to full output — the POWER is set by its own drive // control, not by how loud we send — so sending quietly just wastes the // range. Short of 1.0 to leave room for the sine's peaks. const amp = 0.7 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) // The drive is in the line because it is half of "how much power came out". // A tone at full scale into a drive of 15 is still 15% of the radio. debugLog.Printf("TCI: TX PROBE starting — %d s of a %.0f Hz tone at %.0f%% of full scale, answered to the radio's own requests, mode %s, radio drive %d%%, INTO A DUMMY LOAD", seconds, toneHz, amp*100, mode, drive) 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) } }() // Wait for the radio to ask, and give up quickly if it does not. // // The radio declares what it wants by requesting audio — 47 times a second // when it wants any at all. So there is no need to decide in advance whether // this mode or that setting will work: key, listen for one request, and if // none comes in a fifth of a second, stop. That is a quarter of a second of // carrier instead of five, and an answer that names the setting to change. deadline := time.Now().Add(200 * time.Millisecond) for time.Now().Before(deadline) { t.audio.mu.Lock() asked := t.audio.txSent > 0 t.audio.mu.Unlock() if asked { break } time.Sleep(10 * time.Millisecond) } t.audio.mu.Lock() started := t.audio.txSent t.audio.mu.Unlock() if started == 0 { debugLog.Printf("TCI: TX PROBE — the radio never asked for audio; set ExpertSDR3's transmit audio source to TCI (it is on the microphone)") return fmt.Errorf("the radio did not ask for any audio — set ExpertSDR3's transmit audio source to TCI instead of the microphone, then try again") } time.Sleep(time.Duration(seconds)*time.Second - 200*time.Millisecond) 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 }