//go:build windows package cat // TCI audio — receiving the radio's audio over the same WebSocket that carries // the commands, so a SunSDR needs no virtual audio cable. // // TCI mixes two kinds of frame on one socket: TEXT frames are the commands // ("trx:0,true;"), BINARY frames are streams. A binary frame is a fixed header // followed by float32 samples: // // uint32 receiver which receiver the stream belongs to // uint32 sampleRate Hz // uint32 format 0 = float32 // uint32 codec 0 = uncompressed // uint32 crc unused in practice // uint32 length samples in the payload // uint32 type which stream this is (see tciStream*) // uint32 reserved[9] // float32 payload[…] stereo, interleaved // // The stream is asked for with "audio_samplerate:" then "audio_start:;", // and stopped with "audio_stop:;". // // NOTHING HERE IS CONFIRMED ON A RADIO YET. The layout above is read from the // TCI documentation, and the stream-type numbers in particular are the sort of // detail a document gets right and a memory of it does not — so every header is // logged for the first few seconds of a session, and the numbers the radio // actually sends will settle it. Same discipline as the Yaesu meters and the // Flex spot feed: measure on the real thing, then write the constant down. import ( "encoding/binary" "fmt" "math" "sync" "time" "github.com/gorilla/websocket" ) // TCI stream types. RX audio is the one this file consumes; the others are // named so a log line says what arrived rather than "type 3". const ( tciStreamIQ = 0 tciStreamRXAudio = 1 tciStreamTXAudio = 2 tciStreamTXChrono = 3 ) // tciHeaderWords is the header length in uint32 words (7 named + 9 reserved). const tciHeaderWords = 16 // tciHeaderBytes is the same in bytes. const tciHeaderBytes = tciHeaderWords * 4 // tciAudioProbeMax bounds the header logging. Enough frames to see the shape // and the rate; few enough that an evening of listening does not fill the log. const tciAudioProbeMax = 40 // TCIAudioStatus is what the panel polls while testing the stream. type TCIAudioStatus struct { Running bool `json:"running"` SampleRate int `json:"sample_rate"` Frames int64 `json:"frames"` // binary frames accepted Samples int64 `json:"samples"` // audio samples decoded // PeakDB is the loudest sample of the last second, in dBFS: the one number // that says "audio is really arriving" rather than "a socket is open". PeakDB float64 `json:"peak_db"` LastErr string `json:"last_err,omitempty"` } // tciAudio is the receive-side state, kept on the backend so it lives exactly // as long as the connection does. type tciAudio struct { mu sync.Mutex want bool // the host asked for audio rx int // which receiver rate int frames int64 samples int64 peak float64 peakAt time.Time probe int lastErr string // OnSamples receives decoded MONO samples (the two channels averaged) at // the negotiated rate. Mono because everything downstream — the QSO // recorder, the CW decoder — works on one channel, and a receiver's two // channels carry the same audio. OnSamples func(rate int, samples []float32) } // StartTCIAudio asks the radio to stream receiver rx's audio. func (t *TCI) StartTCIAudio(rx, rate int) error { if rate <= 0 { rate = 48000 } t.audio.mu.Lock() t.audio.want = true t.audio.rx = rx t.audio.rate = rate t.audio.frames, t.audio.samples, t.audio.peak = 0, 0, 0 t.audio.probe = 0 t.audio.lastErr = "" t.audio.mu.Unlock() // Sample rate first: the radio applies it to the stream it is about to // open, and asking afterwards restarts the stream on some firmware. if err := t.send(fmt.Sprintf("audio_samplerate:%d;", rate)); err != nil { return err } return t.send(fmt.Sprintf("audio_start:%d;", rx)) } // StopTCIAudio closes the stream. func (t *TCI) StopTCIAudio() error { t.audio.mu.Lock() t.audio.want = false rx := t.audio.rx t.audio.mu.Unlock() return t.send(fmt.Sprintf("audio_stop:%d;", rx)) } // TCIAudioStatus reports what has arrived. func (t *TCI) TCIAudioStatus() TCIAudioStatus { t.audio.mu.Lock() defer t.audio.mu.Unlock() st := TCIAudioStatus{ Running: t.audio.want, SampleRate: t.audio.rate, Frames: t.audio.frames, Samples: t.audio.samples, LastErr: t.audio.lastErr, } // A peak older than a second is not a level, it is a memory. Reported as // silence rather than left standing, so a stream that has stopped arriving // looks stopped. if time.Since(t.audio.peakAt) < time.Second && t.audio.peak > 0 { st.PeakDB = 20 * math.Log10(t.audio.peak) } else { st.PeakDB = -99 } return st } // handleBinary decodes one binary WebSocket frame. // // Called from the reader goroutine. Anything malformed is counted and dropped: // a stream frame is not worth breaking the command connection over, and the // command connection is what keeps the radio usable. func (t *TCI) handleBinary(data []byte) { if len(data) < tciHeaderBytes { t.audioErr(fmt.Sprintf("binary frame of %d bytes is shorter than a header", len(data))) return } le := binary.LittleEndian receiver := int(le.Uint32(data[0:])) rate := int(le.Uint32(data[4:])) format := le.Uint32(data[8:]) codec := le.Uint32(data[12:]) length := int(le.Uint32(data[20:])) stype := int(le.Uint32(data[24:])) t.audio.mu.Lock() probe := t.audio.probe if probe < tciAudioProbeMax { t.audio.probe++ } t.audio.mu.Unlock() if probe < tciAudioProbeMax { debugLog.Printf("TCI: binary frame — rx=%d rate=%d format=%d codec=%d length=%d type=%d payload=%d bytes", receiver, rate, format, codec, length, stype, len(data)-tciHeaderBytes) } if stype != tciStreamRXAudio { return // IQ, TX audio echo, chrono: not this file's business yet } if format != 0 || codec != 0 { t.audioErr(fmt.Sprintf("stream is format=%d codec=%d, expected float32 uncompressed", format, codec)) return } payload := data[tciHeaderBytes:] n := len(payload) / 4 if n == 0 { return } // Stereo interleaved → mono. Both channels of a receiver carry the same // audio, and everything downstream works on one. mono := make([]float32, 0, n/2+1) var peak float64 for i := 0; i+1 < n; i += 2 { l := math.Float32frombits(le.Uint32(payload[i*4:])) r := math.Float32frombits(le.Uint32(payload[(i+1)*4:])) v := (l + r) / 2 if a := math.Abs(float64(v)); a > peak { peak = a } mono = append(mono, v) } t.audio.mu.Lock() t.audio.frames++ t.audio.samples += int64(len(mono)) if rate > 0 { t.audio.rate = rate } if peak > t.audio.peak || time.Since(t.audio.peakAt) > time.Second { t.audio.peak = peak t.audio.peakAt = time.Now() } cb := t.audio.OnSamples t.audio.mu.Unlock() if cb != nil { cb(rate, mono) } } // audioErr records a decoding complaint, once, so the panel can show it without // the log filling with the same line at fifty frames a second. func (t *TCI) audioErr(msg string) { t.audio.mu.Lock() first := t.audio.lastErr != msg t.audio.lastErr = msg t.audio.mu.Unlock() if first { debugLog.Printf("TCI: audio: %s", msg) } } // resumeAudio re-opens the stream after a reconnect, if the host had asked for // it. A dropped WebSocket takes the audio with it, and an operator who switched // recording on does not expect to switch it on again. func (t *TCI) resumeAudio() { t.audio.mu.Lock() want, rx, rate := t.audio.want, t.audio.rx, t.audio.rate t.audio.mu.Unlock() if !want { return } if err := t.StartTCIAudio(rx, rate); err != nil { debugLog.Printf("TCI: re-opening the audio stream failed: %v", err) } } // wsMessageIsBinary keeps the type test in one place — the reader used to // ignore the message type entirely and split every frame on ';', which would // have fed audio bytes to the command parser the moment a stream was opened. func wsMessageIsBinary(mt int) bool { return mt == websocket.BinaryMessage }