From 9b8168370ff88c6c501006743d6b846c3eae50af Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 24 Aug 2026 22:36:21 +0200 Subject: [PATCH] fix(tci): derive the sample width instead of trusting the format number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real SunSDR answers format=3 where this expected 0 — and 0 was read from the documentation, which is exactly the kind of detail a memory of a document gets wrong. The stream was refused outright: 'format=3, expected float32', zero frames, silence. Swapping one magic number for another would only move the guess, so the width is now MEASURED: the header says how many samples the payload holds, and dividing gives the bytes per sample. Four is float32, two is 16-bit PCM scaled to the same -1..1 the rest of the audio path uses, and anything else is reported rather than mangled. That stays true whatever number the format field carries on the next firmware. --- internal/cat/tci_audio.go | 54 ++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/internal/cat/tci_audio.go b/internal/cat/tci_audio.go index fbb69c3..9ee4f8a 100644 --- a/internal/cat/tci_audio.go +++ b/internal/cat/tci_audio.go @@ -83,6 +83,9 @@ type tciAudio struct { peakAt time.Time probe int 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. + widthLogged bool // OnSamples receives decoded MONO samples (the two channels averaged) at // the negotiated rate. Mono because everything downstream — the QSO @@ -176,24 +179,63 @@ func (t *TCI) handleBinary(data []byte) { 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)) + if codec != 0 { + t.audioErr(fmt.Sprintf("stream is codec=%d, and nothing here decodes a compressed stream", codec)) return } + // The FORMAT number is decided by measurement, not by the number itself. + // + // A real SunSDR answered format=3, where the code expected 0 — and 0 was a + // guess from reading the documentation, which is exactly the kind of detail + // a memory of a document gets wrong. Rather than swap one magic number for + // another, the sample width is derived from what arrived: the header says + // how many samples the payload holds, so the bytes per sample follow from + // dividing. That is true whatever number the format field carries, on this + // firmware and the next. payload := data[tciHeaderBytes:] - n := len(payload) / 4 + if len(payload) == 0 || length <= 0 { + return + } + width := len(payload) / length + var n int + switch width { + case 4: + n = len(payload) / 4 // float32 + case 2: + n = len(payload) / 2 // 16-bit PCM + default: + t.audioErr(fmt.Sprintf("frame carries %d bytes for %d samples (format=%d) — not a width this reads", + len(payload), length, format)) + return + } if n == 0 { return } + // Under the lock like the rest of the counters: the reader is the only + // writer today, but a fact about the radio that is read from another + // goroutine has no business being the one field left unguarded. + t.audio.mu.Lock() + first := !t.audio.widthLogged + t.audio.widthLogged = true + t.audio.mu.Unlock() + if first { + debugLog.Printf("TCI: audio is %d bytes per sample at %d Hz (format field says %d)", width, rate, format) + } // 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 + sample := func(i int) float32 { + if width == 2 { + // 16-bit PCM, scaled to the same -1…1 the rest of the audio path + // works in, so a change of format cannot change what a level means. + return float32(int16(le.Uint16(payload[i*2:]))) / 32768 + } + return math.Float32frombits(le.Uint32(payload[i*4:])) + } 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 + v := (sample(i) + sample(i+1)) / 2 if a := math.Abs(float64(v)); a > peak { peak = a }