diff --git a/internal/cat/tci_audio.go b/internal/cat/tci_audio.go index 814e3b9..9208201 100644 --- a/internal/cat/tci_audio.go +++ b/internal/cat/tci_audio.go @@ -92,13 +92,22 @@ type tciAudio struct { // widthLogged keeps the one-line note about the sample width to once a // session — it is a fact about the radio, not an event. 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, // audio_stream_channels). Its own declaration, and it arrives before the // first frame — the frame arithmetic below stays as the check on it rather // 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 declaredChans int @@ -215,11 +224,17 @@ func (t *TCI) handleBinary(data []byte) { 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 { - // IQ, transmit audio, chrono. Nothing consumes them yet — but the chrono - // frames are what a voice keyer over TCI would have to answer, and their - // size and cadence cannot be guessed from the documentation. They are - // logged (per type, see above) and dropped. + // IQ and transmit audio. The latter is ours to send, not to receive: + // counted above, and dropped. return } if codec != 0 { diff --git a/internal/cat/tci_tx_probe.go b/internal/cat/tci_tx_probe.go index efdd4f2..3317429 100644 --- a/internal/cat/tci_tx_probe.go +++ b/internal/cat/tci_tx_probe.go @@ -1,29 +1,32 @@ 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 -// 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. +// Three transmissions on a real SunSDR settled how this works, and none of it +// was guessable from the documentation: // -// 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: +// 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. // -// - 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. +// 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. // -// 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. +// 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" @@ -34,9 +37,9 @@ import ( "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. +// 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 @@ -69,11 +72,55 @@ func (t *TCI) sendBinaryFrame(stype, rx, rate, length int, payload []byte) error 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. +// serveChrono answers one request for transmit audio. // -// 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. +// 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 @@ -87,6 +134,7 @@ func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error { 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") @@ -94,56 +142,33 @@ func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error { 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 - 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 + // 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 - - var sent int - for i := 0; i < frames; i++ { - for s := 0; s < samplesPerFrame; s += chans { + 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 { @@ -153,19 +178,46 @@ func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error { 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) + 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() - chrono := t.audio.countByType[tciStreamTXChrono] - txa := t.audio.countByType[tciStreamTXAudio] + 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 — sent %d frames; the radio sent %d chrono and %d transmit-audio frames in total this session", - sent, chrono, txa) + 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 +}