feat(tci): receive audio over the TCI WebSocket (experimental)

A SunSDR already carries its receive audio on the same WebSocket as its
commands, so a virtual audio cable and a second sound card are two pieces
of plumbing an operator installs for no reason. This is the receive half:
what the QSO recorder and the CW decoder need.

The reader now looks at the frame type. It used to ignore it and split
every frame on ';' -- harmless only for as long as no stream was ever
opened, since audio bytes would otherwise have been handed to the command
parser a hundred times a second.

NOTHING HERE IS CONFIRMED ON A RADIO. The header layout comes from the
TCI documentation, and the stream-type numbers are exactly the sort of
detail a document gets right and a memory of it does not -- so the first
forty frames of a session are logged verbatim, and a test bench in
Preferences > Audio reports the sample rate the radio chose, the frames
arriving and the peak level of the last second. 'The stream is open' and
'audio is arriving' are different claims and only the second is worth
anything to whoever tries this first.

Transmit (the voice keyer) is the other half and is deliberately absent:
it has to answer the radio's chrono packets at the right pace, and that
is worth doing once the format is settled on real hardware.
This commit is contained in:
2026-08-24 20:34:06 +02:00
parent ea302966d5
commit efa711af78
10 changed files with 439 additions and 5 deletions
+14 -1
View File
@@ -34,6 +34,11 @@ type TCI struct {
OnSpotClick func(callsign string, freqHz int64)
unhandledSeen map[string]bool // log each unknown TCI message type once
// audio holds the receive-audio stream — see tci_audio.go. TCI carries it
// on this same WebSocket, which is what lets a SunSDR record and decode
// without a virtual audio cable in the way.
audio tciAudio
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
@@ -354,10 +359,18 @@ func (t *TCI) send(cmd string) error {
// connection closes.
func (t *TCI) reader(conn *websocket.Conn) {
for {
_, data, err := conn.ReadMessage()
mt, data, err := conn.ReadMessage()
if err != nil {
break
}
// TEXT frames are commands, BINARY frames are streams. The type used to
// be ignored and every frame split on ';' — harmless only for as long as
// no stream was ever opened, since audio bytes would then have been fed
// to the command parser a hundred times a second.
if wsMessageIsBinary(mt) {
t.handleBinary(data)
continue
}
// A frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") {
t.handle(strings.TrimSpace(cmd))