A first real transmission 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 chrono, no transmit audio. 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 is waiting for nothing. The reading that fits is that 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 it from here, push a 1 kHz tone, and watch. Chrono frames appearing gives their size and cadence by measurement instead of by guesswork; no chrono but a tone on the meter is just as useful, because then the pacing is optional and the voice keyer can push frames at the rate the stream already runs at. A tone rather than silence so the answer shows on the power meter and not only in the log. It transmits, so: an explicit button inside a warning box, five seconds, capped at ten, and every path out unkeys — including the panic that has not happened yet and a socket that dies mid-tone. A transmitter left keyed by a defect is the one fault here that would reach somebody else's band. Writes are now serialised too. send() held the lock only long enough to read the connection, which was enough while every command came from the poll loop; a stream of audio frames from a second goroutine is not, and gorilla panics on a concurrent write rather than failing quietly.
172 lines
6.3 KiB
Go
172 lines
6.3 KiB
Go
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
|
|
}
|