diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 3d4d777..27a2531 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -444,7 +444,16 @@ func (t *TCI) handle(msg string) { } case "trx": if get(0) == "0" { + was := t.tx t.tx = get(1) == "true" + // Said out loud, every time. The transmit side of TCI can only be + // written from a log of a real transmission, and the first one came + // back without a single line to say whether the radio had even been + // keyed — which left the interesting question, why no transmit + // frames, indistinguishable from nobody having pressed anything. + if was != t.tx { + t.noteTXTransition(t.tx) + } } case "tx_enable": if get(0) == "0" { diff --git a/internal/cat/tci_audio.go b/internal/cat/tci_audio.go index 844ebf4..814e3b9 100644 --- a/internal/cat/tci_audio.go +++ b/internal/cat/tci_audio.go @@ -33,6 +33,7 @@ import ( "encoding/binary" "fmt" "math" + "strings" "sync" "time" @@ -60,10 +61,10 @@ 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 + 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"` @@ -73,15 +74,20 @@ type TCIAudioStatus struct { // 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 + 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 probeByType map[int]int + // countByType counts EVERY frame per stream type, capped by nothing. + // The probe above stops logging after forty frames of a type; these keep + // counting, so a transmission that produced no transmit frames at all can + // be reported as a fact rather than inferred from an absence of lines. + countByType map[int]int64 lastErr string // widthLogged keeps the one-line note about the sample width to once a // session — it is a fact about the radio, not an event. @@ -90,6 +96,9 @@ type tciAudio struct { // 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 @@ -192,6 +201,10 @@ func (t *TCI) handleBinary(data []byte) { if t.audio.probeByType == nil { t.audio.probeByType = map[int]int{} } + if t.audio.countByType == nil { + t.audio.countByType = map[int]int64{} + } + t.audio.countByType[stype]++ probe := t.audio.probeByType[stype] if probe < tciAudioProbeMax { t.audio.probeByType[stype]++ @@ -333,3 +346,57 @@ func (t *TCI) resumeAudio() { // 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 } + +// noteTXTransition reports what the stream did across a transmission. +// +// The voice keyer needs two numbers the documentation does not give: the size +// and the cadence of the frames the radio expects while transmitting. They can +// only be read off a real transmission — and the first attempt came back with a +// log that said nothing at all, which is ambiguous: either no transmit frames +// arrived, or they arrived and went unlogged. +// +// So the boundaries are marked and every stream type is counted. A pass that +// produces "type 1: 240, and nothing else" is a RESULT — it says the radio +// sends no chrono unless something more is asked of it — where a log with no +// transmit lines in it was merely a silence. +func (t *TCI) noteTXTransition(on bool) { + t.audio.mu.Lock() + if t.audio.countByType == nil { + t.audio.countByType = map[int]int64{} + } + if on { + // Let the transmit types speak again on every pass: forty frames is a + // budget spent long before the operator gets round to keying. + if t.audio.probeByType != nil { + delete(t.audio.probeByType, tciStreamTXAudio) + delete(t.audio.probeByType, tciStreamTXChrono) + } + t.audio.txMark = map[int]int64{} + for k, v := range t.audio.countByType { + t.audio.txMark[k] = v + } + streaming := t.audio.want + t.audio.mu.Unlock() + debugLog.Printf("TCI: TRANSMIT started — watching for transmit-audio (type %d) and chrono (type %d) frames; receive stream is %s", + tciStreamTXAudio, tciStreamTXChrono, map[bool]string{true: "open", false: "CLOSED (tick the TCI recording option, or the radio has no reason to stream)"}[streaming]) + return + } + names := map[int]string{ + tciStreamIQ: "IQ", + tciStreamRXAudio: "receive audio", + tciStreamTXAudio: "transmit audio", + tciStreamTXChrono: "transmit chrono", + } + var parts []string + for _, k := range []int{tciStreamIQ, tciStreamRXAudio, tciStreamTXAudio, tciStreamTXChrono} { + if n := t.audio.countByType[k] - t.audio.txMark[k]; n > 0 { + parts = append(parts, fmt.Sprintf("%s (type %d): %d", names[k], k, n)) + } + } + t.audio.mu.Unlock() + if len(parts) == 0 { + debugLog.Printf("TCI: TRANSMIT ended — NO binary frames of any type arrived during it") + return + } + debugLog.Printf("TCI: TRANSMIT ended — frames during the pass: %s", strings.Join(parts, ", ")) +}