feat(tci): answer the radio's requests instead of pushing audio at it
Three transmissions on a real SunSDR settled how the transmit side works, and none of it was guessable from the documentation. 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. And it asks only in a DIGITAL mode — keyed from here in SSB it stayed silent four times over, and answered in DIGU immediately. In SSB the modulator is wired to the microphone, which is also the honest answer to 'why can I hear myself but not the tone'. The chrono turns out to be a REQUEST, not a clock. It carries no payload — the message itself is the ask — and it names the size it wants in the header: 2048 samples, two channels interleaved, 47 times a second, which is 1024 pairs at 48 kHz, exactly real time. So audio goes out in answer to a request and never on a timer of our own. The timer was the first attempt and the radio ignored all 234 frames of it. Answering also hands the pacing to the radio: no drift, no buffer to tune, and the size taken from what it asked for rather than from what we assumed. The sine keeps its phase across frames, since one restarted every frame is a click 47 times a second. A pass in SSB is now refused rather than attempted. It keys the transmitter, produces nothing and teaches nobody anything — and it is still a transmission. The feed mechanism is the one the voice keyer will use: WAV samples in place of the sine, everything else unchanged.
This commit is contained in:
@@ -92,13 +92,22 @@ type tciAudio struct {
|
|||||||
// widthLogged keeps the one-line note about the sample width to once a
|
// widthLogged keeps the one-line note about the sample width to once a
|
||||||
// session — it is a fact about the radio, not an event.
|
// session — it is a fact about the radio, not an event.
|
||||||
widthLogged bool
|
widthLogged bool
|
||||||
|
// txMark is the per-type frame count when transmission began, so the census
|
||||||
|
// at the end reports the pass rather than the whole session.
|
||||||
|
txMark map[int]int64
|
||||||
|
|
||||||
|
// txFeed supplies the next frame of transmit audio when the radio asks for
|
||||||
|
// one, or is nil when nothing is being sent. Set under this same lock, and
|
||||||
|
// read on the reader goroutine — the radio's request and our answer are two
|
||||||
|
// halves of one exchange and must not straddle a race.
|
||||||
|
txFeed func(samples int) []byte
|
||||||
|
txSent int64
|
||||||
|
txShort int64 // requests the feed could not fill (it had run out)
|
||||||
|
|
||||||
// What the radio SAID about its stream at connect (audio_stream_sample_type,
|
// What the radio SAID about its stream at connect (audio_stream_sample_type,
|
||||||
// audio_stream_channels). Its own declaration, and it arrives before the
|
// audio_stream_channels). Its own declaration, and it arrives before the
|
||||||
// first frame — the frame arithmetic below stays as the check on it rather
|
// first frame — the frame arithmetic below stays as the check on it rather
|
||||||
// than as the only source.
|
// than as the only source.
|
||||||
// txMark is the per-type frame count when transmission began, so the census
|
|
||||||
// at the end reports the pass rather than the whole session.
|
|
||||||
txMark map[int]int64
|
|
||||||
declaredType string
|
declaredType string
|
||||||
declaredChans int
|
declaredChans int
|
||||||
|
|
||||||
@@ -215,11 +224,17 @@ func (t *TCI) handleBinary(data []byte) {
|
|||||||
receiver, rate, format, codec, length, stype, len(data)-tciHeaderBytes)
|
receiver, rate, format, codec, length, stype, len(data)-tciHeaderBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stype == tciStreamTXChrono {
|
||||||
|
// The radio asking for the next frame of transmit audio. It is empty —
|
||||||
|
// the whole message IS the request — and it carries the size it wants in
|
||||||
|
// the header's length field, so the answer is written from what it says
|
||||||
|
// rather than from what we assumed.
|
||||||
|
t.serveChrono(rate, length)
|
||||||
|
return
|
||||||
|
}
|
||||||
if stype != tciStreamRXAudio {
|
if stype != tciStreamRXAudio {
|
||||||
// IQ, transmit audio, chrono. Nothing consumes them yet — but the chrono
|
// IQ and transmit audio. The latter is ours to send, not to receive:
|
||||||
// frames are what a voice keyer over TCI would have to answer, and their
|
// counted above, and dropped.
|
||||||
// size and cadence cannot be guessed from the documentation. They are
|
|
||||||
// logged (per type, see above) and dropped.
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if codec != 0 {
|
if codec != 0 {
|
||||||
|
|||||||
+126
-74
@@ -1,29 +1,32 @@
|
|||||||
package cat
|
package cat
|
||||||
|
|
||||||
// Sending audio TO the radio over TCI, on purpose, to find out whether it works.
|
// Sending audio TO the radio over TCI.
|
||||||
//
|
//
|
||||||
// A first transmission on a real SunSDR settled one question and raised a
|
// Three transmissions on a real SunSDR settled how this works, and none of it
|
||||||
// better one. With the receive stream open and six seconds of transmit, the
|
// was guessable from the documentation:
|
||||||
// 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
|
// 1. The radio asks for audio only when the transmission is the CLIENT'S. With
|
||||||
// CLIENT'S, and takes the microphone when it is the operator's. Which makes the
|
// the operator keying the microphone it sent 282 receive frames and nothing
|
||||||
// experiment obvious — key the radio from here, push a tone, and watch. Two
|
// else, over six seconds.
|
||||||
// things can happen and both are worth having:
|
// 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.
|
||||||
//
|
//
|
||||||
// - Chrono frames appear. Their size and cadence are then measured rather
|
// So audio is sent in ANSWER to chrono, never on a timer of our own. A timer
|
||||||
// than guessed, and the voice keyer is written against them.
|
// was the first attempt and the radio ignored every frame of it: 234 sent, none
|
||||||
// - No chrono, but the tone comes out of the radio. Then the chrono is
|
// used. Answering the request is what makes the difference, and it also means
|
||||||
// optional pacing, and a voice keyer can simply push frames at the rate the
|
// the radio sets the pace — no drift, no buffer to tune.
|
||||||
// stream runs at, which is far simpler.
|
|
||||||
//
|
//
|
||||||
// A tone, not silence: it makes the power meter move, so the answer is visible
|
// What remains here is the probe: a tone, on demand, to prove the path end to
|
||||||
// on the front panel and not only in a log. THIS TRANSMITS — it is behind an
|
// end on real hardware. The voice keyer will use the same feed mechanism with
|
||||||
// explicit button, it is capped, and it unkeys on every path out, including a
|
// WAV samples in place of the sine.
|
||||||
// panic and a socket that dies mid-tone.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
@@ -34,9 +37,9 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tciTXProbeMaxSeconds caps the pass. Long enough to read a power meter and
|
// tciTXProbeMaxSeconds caps the pass. Long enough to read a power meter, short
|
||||||
// count frames, short enough that a carrier left running by a defect is a
|
// enough that a carrier left running by a defect is a mistake and not an
|
||||||
// mistake rather than an incident.
|
// incident.
|
||||||
const tciTXProbeMaxSeconds = 10
|
const tciTXProbeMaxSeconds = 10
|
||||||
|
|
||||||
// sendBinaryFrame writes one TCI binary frame: the 16-word header the radio's
|
// sendBinaryFrame writes one TCI binary frame: the 16-word header the radio's
|
||||||
@@ -69,11 +72,55 @@ func (t *TCI) sendBinaryFrame(stype, rx, rate, length int, payload []byte) error
|
|||||||
return c.WriteMessage(websocket.BinaryMessage, buf)
|
return c.WriteMessage(websocket.BinaryMessage, buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProbeTXStream keys the radio, streams a tone over TCI for the given number of
|
// serveChrono answers one request for transmit audio.
|
||||||
// seconds, unkeys, and reports what came back.
|
|
||||||
//
|
//
|
||||||
// INTO A DUMMY LOAD. It is a real transmission at whatever drive the radio is
|
// Called from the reader goroutine, so it does the least it can: take the
|
||||||
// set to, and the caller is expected to have said so to the operator.
|
// 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 {
|
func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error {
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
seconds = 5
|
seconds = 5
|
||||||
@@ -87,6 +134,7 @@ func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error {
|
|||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
allowed, known, connected := t.txAllowed, t.txAllowedKnown, t.conn != nil
|
allowed, known, connected := t.txAllowed, t.txAllowedKnown, t.conn != nil
|
||||||
|
mode := t.mode
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
return fmt.Errorf("not connected to the radio")
|
return fmt.Errorf("not connected to the radio")
|
||||||
@@ -94,56 +142,33 @@ func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error {
|
|||||||
if known && !allowed {
|
if known && !allowed {
|
||||||
return fmt.Errorf("the radio refuses transmitting (tx_enable is false)")
|
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()
|
t.audio.mu.Lock()
|
||||||
rate := t.audio.rate
|
rate := t.audio.rate
|
||||||
streaming := t.audio.want
|
|
||||||
t.audio.mu.Unlock()
|
t.audio.mu.Unlock()
|
||||||
if rate <= 0 {
|
if rate <= 0 {
|
||||||
rate = 48000
|
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
|
// The tone, generated on demand: the radio asks for a size and gets exactly
|
||||||
// (audio_stream_samples:2048), so it is the size it is built around. Two
|
// that, at whatever pace it asks. Phase is carried across the calls, since a
|
||||||
// interleaved channels, as its own frames carry.
|
// sine restarted every frame is a click 47 times a second.
|
||||||
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
|
phase := 0.0
|
||||||
step := 2 * math.Pi * toneHz / float64(rate)
|
step := 2 * math.Pi * toneHz / float64(rate)
|
||||||
// A quarter of full scale: enough to read on a meter, short of the level
|
// 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.
|
// where the radio's own processing starts deciding things for us.
|
||||||
const amp = 0.25
|
const amp = 0.25
|
||||||
|
const chans = 2
|
||||||
var sent int
|
le := binary.LittleEndian
|
||||||
for i := 0; i < frames; i++ {
|
t.setTXFeed(func(samples int) []byte {
|
||||||
for s := 0; s < samplesPerFrame; s += chans {
|
payload := make([]byte, samples*4)
|
||||||
|
for s := 0; s+chans-1 < samples; s += chans {
|
||||||
v := float32(math.Sin(phase) * amp)
|
v := float32(math.Sin(phase) * amp)
|
||||||
phase += step
|
phase += step
|
||||||
if phase > 2*math.Pi {
|
if phase > 2*math.Pi {
|
||||||
@@ -153,19 +178,46 @@ func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error {
|
|||||||
le.PutUint32(payload[s*4:], bits) // left
|
le.PutUint32(payload[s*4:], bits) // left
|
||||||
le.PutUint32(payload[(s+1)*4:], bits) // right
|
le.PutUint32(payload[(s+1)*4:], bits) // right
|
||||||
}
|
}
|
||||||
if err := t.sendBinaryFrame(tciStreamTXAudio, 0, rate, samplesPerFrame, payload); err != nil {
|
return payload
|
||||||
debugLog.Printf("TCI: TX PROBE — stopped after %d frames: %v", sent, err)
|
})
|
||||||
break
|
defer t.setTXFeed(nil)
|
||||||
}
|
|
||||||
sent++
|
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",
|
||||||
time.Sleep(interval)
|
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()
|
t.audio.mu.Lock()
|
||||||
chrono := t.audio.countByType[tciStreamTXChrono]
|
sent, short := t.audio.txSent, t.audio.txShort
|
||||||
txa := t.audio.countByType[tciStreamTXAudio]
|
chrono := t.audio.countByType[tciStreamTXChrono] - t.audio.txMark[tciStreamTXChrono]
|
||||||
t.audio.mu.Unlock()
|
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",
|
debugLog.Printf("TCI: TX PROBE finished — the radio asked %d times, %d frames sent, %d requests unanswered",
|
||||||
sent, chrono, txa)
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user