feat(tci): read the radio's declared format, and record a test WAV

The SunSDR announces its own stream at connect —
audio_stream_sample_type:float32 and audio_stream_channels:2 — and both
were being logged as unhandled while the code worked the format out from
frame arithmetic. The declaration is better evidence and arrives before
the first frame; the arithmetic stays as the check on it. The channel
count now drives the mix-down instead of an assumed stereo.

Adds a ten-second test recording, written as a WAV beside the QSO
recordings. Counting frames proves a socket is delivering bytes; it says
nothing about whether those bytes are the receiver's audio, at the right
rate, in the right order. A stream decoded with the width wrong or the
samples misaligned counts exactly as well as a correct one and sounds
like a fan — so the test is a file the operator can play, the same way
the CW decoder was settled on the air rather than on a spectrogram.

The file is written at the rate the RADIO reported, not a constant: a
recording at the wrong rate plays at the wrong speed, which is the one
fault that would be blamed on the decoding.
This commit is contained in:
2026-08-25 08:25:29 +02:00
parent 9b8168370f
commit 01a23ccb77
7 changed files with 247 additions and 6 deletions
+32 -3
View File
@@ -86,6 +86,12 @@ type tciAudio struct {
// 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
// What the radio SAID about its stream at connect (audio_stream_sample_type,
// 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.
declaredType string
declaredChans int
// OnSamples receives decoded MONO samples (the two channels averaged) at
// the negotiated rate. Mono because everything downstream — the QSO
@@ -116,6 +122,17 @@ func (t *TCI) StartTCIAudio(rx, rate int) error {
return t.send(fmt.Sprintf("audio_start:%d;", rx))
}
// SetTCIAudioSink installs (or removes) the consumer of the decoded samples.
//
// One sink, not a list: today it is a test recording, tomorrow the QSO
// recorder, and two consumers of a live stream would need a policy about which
// one wins that nothing yet has an opinion about.
func (t *TCI) SetTCIAudioSink(fn func(rate int, samples []float32)) {
t.audio.mu.Lock()
t.audio.OnSamples = fn
t.audio.mu.Unlock()
}
// StopTCIAudio closes the stream.
func (t *TCI) StopTCIAudio() error {
t.audio.mu.Lock()
@@ -224,7 +241,15 @@ func (t *TCI) handleBinary(data []byte) {
}
// 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)
// How many channels are interleaved. The radio says so at connect; two is
// the fallback, which is what every SunSDR seen so far streams.
t.audio.mu.Lock()
chans := t.audio.declaredChans
t.audio.mu.Unlock()
if chans <= 0 {
chans = 2
}
mono := make([]float32, 0, n/chans+1)
var peak float64
sample := func(i int) float32 {
if width == 2 {
@@ -234,8 +259,12 @@ func (t *TCI) handleBinary(data []byte) {
}
return math.Float32frombits(le.Uint32(payload[i*4:]))
}
for i := 0; i+1 < n; i += 2 {
v := (sample(i) + sample(i+1)) / 2
for i := 0; i+chans-1 < n; i += chans {
var sum float32
for c := 0; c < chans; c++ {
sum += sample(i + c)
}
v := sum / float32(chans)
if a := math.Abs(float64(v)); a > peak {
peak = a
}