package cat // Sending audio TO the radio over TCI, on purpose, to find out whether it works. // // A first transmission on a real SunSDR settled one question and raised a // better one. With the receive stream open and six seconds of transmit, the // radio sent 282 frames of receive audio and NOTHING else: no transmit-audio // frames, no chrono. So the chrono the documentation describes is not offered // to a client that merely happens to be connected while the operator keys the // microphone — and waiting for it to appear on its own is waiting for nothing. // // The reading that fits: the radio asks for audio when the transmission is the // CLIENT'S, and takes the microphone when it is the operator's. Which makes the // experiment obvious — key the radio from here, push a tone, and watch. Two // things can happen and both are worth having: // // - Chrono frames appear. Their size and cadence are then measured rather // than guessed, and the voice keyer is written against them. // - No chrono, but the tone comes out of the radio. Then the chrono is // optional pacing, and a voice keyer can simply push frames at the rate the // stream runs at, which is far simpler. // // A tone, not silence: it makes the power meter move, so the answer is visible // on the front panel and not only in a log. THIS TRANSMITS — it is behind an // explicit button, it is capped, and it unkeys on every path out, including a // panic and a socket that dies mid-tone. import ( "encoding/binary" "fmt" "math" "time" "github.com/gorilla/websocket" ) // tciTXProbeMaxSeconds caps the pass. Long enough to read a power meter and // count frames, short enough that a carrier left running by a defect is a // mistake rather than 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) } // ProbeTXStream keys the radio, streams a tone over TCI for the given number of // seconds, unkeys, and reports what came back. // // INTO A DUMMY LOAD. It is a real transmission at whatever drive the radio is // set to, and the caller is expected to have said so to the operator. 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 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)") } t.audio.mu.Lock() rate := t.audio.rate streaming := t.audio.want t.audio.mu.Unlock() if rate <= 0 { rate = 48000 } if !streaming { // Not fatal — the radio may well accept transmit frames on a socket with // no receive stream — but it is the first thing to suspect if nothing // happens, and it belongs in the log next to the result. debugLog.Printf("TCI: TX PROBE — the receive stream is closed; if this produces nothing, open it and try again") } // 2048 samples a frame is what the radio told us it streams // (audio_stream_samples:2048), so it is the size it is built around. Two // interleaved channels, as its own frames carry. const samplesPerFrame = 2048 const chans = 2 perFrame := samplesPerFrame / chans frames := seconds * rate / perFrame interval := time.Duration(float64(perFrame) / float64(rate) * float64(time.Second)) debugLog.Printf("TCI: TX PROBE starting — %d s of a %.0f Hz tone, %d frames of %d samples every %v, INTO A DUMMY LOAD", seconds, toneHz, frames, samplesPerFrame, interval.Round(time.Millisecond)) 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 in this file that // would matter to 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) } }() payload := make([]byte, samplesPerFrame*4) le := binary.LittleEndian 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 var sent int for i := 0; i < frames; i++ { for s := 0; s < samplesPerFrame; 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 } if err := t.sendBinaryFrame(tciStreamTXAudio, 0, rate, samplesPerFrame, payload); err != nil { debugLog.Printf("TCI: TX PROBE — stopped after %d frames: %v", sent, err) break } sent++ time.Sleep(interval) } t.audio.mu.Lock() chrono := t.audio.countByType[tciStreamTXChrono] txa := t.audio.countByType[tciStreamTXAudio] t.audio.mu.Unlock() debugLog.Printf("TCI: TX PROBE finished — sent %d frames; the radio sent %d chrono and %d transmit-audio frames in total this session", sent, chrono, txa) return nil }