fix(tci): derive the sample width instead of trusting the format number

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.
This commit is contained in:
2026-08-24 22:36:21 +02:00
parent efa711af78
commit 9b8168370f
+48 -6
View File
@@ -83,6 +83,9 @@ type tciAudio struct {
peakAt time.Time peakAt time.Time
probe int probe int
lastErr string 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 // OnSamples receives decoded MONO samples (the two channels averaged) at
// the negotiated rate. Mono because everything downstream — the QSO // the negotiated rate. Mono because everything downstream — the QSO
@@ -176,24 +179,63 @@ func (t *TCI) handleBinary(data []byte) {
if stype != tciStreamRXAudio { if stype != tciStreamRXAudio {
return // IQ, TX audio echo, chrono: not this file's business yet return // IQ, TX audio echo, chrono: not this file's business yet
} }
if format != 0 || codec != 0 { if codec != 0 {
t.audioErr(fmt.Sprintf("stream is format=%d codec=%d, expected float32 uncompressed", format, codec)) t.audioErr(fmt.Sprintf("stream is codec=%d, and nothing here decodes a compressed stream", codec))
return 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:] 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 { if n == 0 {
return 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 // Stereo interleaved → mono. Both channels of a receiver carry the same
// audio, and everything downstream works on one. // audio, and everything downstream works on one.
mono := make([]float32, 0, n/2+1) mono := make([]float32, 0, n/2+1)
var peak float64 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 { for i := 0; i+1 < n; i += 2 {
l := math.Float32frombits(le.Uint32(payload[i*4:])) v := (sample(i) + sample(i+1)) / 2
r := math.Float32frombits(le.Uint32(payload[(i+1)*4:]))
v := (l + r) / 2
if a := math.Abs(float64(v)); a > peak { if a := math.Abs(float64(v)); a > peak {
peak = a peak = a
} }