Transmit over TCI is confirmed on a SunSDR: 80 W out of a 1 kHz tone at 70% of full scale into 80% drive, every request answered. Which makes the earlier rule wrong. 'Digital modes only' came from a real observation — SSB silent four times over, DIGU answering at once — but the mode was a coincidence. ExpertSDR3 has a transmit audio SOURCE, microphone or TCI, kept per mode, and it was on the microphone in SSB. Refusing SSB would have blocked the one thing a voice keyer exists for. So nothing is refused on the strength of the mode. The radio declares what it wants by asking for audio, 47 times a second when it wants any: key, wait for one request, and stop within two tenths of a second if none comes — naming the setting to change rather than theorising about it. That is better on three counts. It works in SSB when the source is set right, it cannot be wrong about a mode nobody thought to test (AM, FM, RTTY), and a misconfiguration costs a quarter-second of carrier instead of five seconds.
268 lines
9.9 KiB
Go
268 lines
9.9 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.
|
|
//
|
|
// 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
|
|
}
|