Files
OpsLog/app_tci_record.go
T
rouggy 01a23ccb77 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.
2026-08-25 08:25:29 +02:00

176 lines
5.1 KiB
Go

package main
// Recording a few seconds of the TCI stream to a WAV file.
//
// Counting frames proves a socket is delivering bytes. It does not prove those
// bytes are the receiver's audio, at the right rate, in the right order — a
// stream decoded with the channels swapped, 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. It is the same reason the CW
// decoder was validated on the air rather than on a spectrogram.
import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
"sync"
"time"
"hamlog/internal/applog"
"hamlog/internal/cat"
)
// tciRec collects samples while a test recording is running.
type tciRec struct {
mu sync.Mutex
active bool
rate int
samples []float32
want int // how many samples to collect before stopping
}
var tciRecorder tciRec
// RecordTCIAudio captures seconds of the TCI receive stream and writes a WAV
// next to the QSO recordings. Returns the path.
//
// The stream has to be open already — this listens to what is arriving rather
// than opening anything, so a recording can never leave a stream running that
// the operator did not ask for.
func (a *App) RecordTCIAudio(seconds int) (string, error) {
if a.cat == nil {
return "", fmt.Errorf("CAT not initialized")
}
if seconds <= 0 || seconds > 60 {
seconds = 10
}
// Rate from the radio, not assumed: the file's header has to match what was
// actually streamed or the recording plays at the wrong speed, which is the
// one fault that would be blamed on the decoding.
st := a.GetTCIAudioStatus()
if !st.Running {
return "", fmt.Errorf("open the TCI audio stream first")
}
rate := st.SampleRate
if rate <= 0 {
rate = 48000
}
tciRecorder.mu.Lock()
if tciRecorder.active {
tciRecorder.mu.Unlock()
return "", fmt.Errorf("a test recording is already running")
}
tciRecorder.active = true
tciRecorder.rate = rate
tciRecorder.want = rate * seconds
tciRecorder.samples = make([]float32, 0, tciRecorder.want)
tciRecorder.mu.Unlock()
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
s, ok := t.(interface{ SetTCIAudioSink(func(int, []float32)) })
if !ok {
return fmt.Errorf("this backend has no audio sink")
}
s.SetTCIAudioSink(func(_ int, samples []float32) {
tciRecorder.mu.Lock()
defer tciRecorder.mu.Unlock()
if !tciRecorder.active {
return
}
tciRecorder.samples = append(tciRecorder.samples, samples...)
})
return nil
})
if err != nil {
tciRecorder.mu.Lock()
tciRecorder.active = false
tciRecorder.mu.Unlock()
return "", err
}
// Wait for the samples rather than for the clock: a stream that stalls
// halfway should produce a short file that says so, not a long one padded
// with silence that hides it.
deadline := time.Now().Add(time.Duration(seconds+5) * time.Second)
for {
tciRecorder.mu.Lock()
got := len(tciRecorder.samples)
want := tciRecorder.want
tciRecorder.mu.Unlock()
if got >= want || time.Now().After(deadline) {
break
}
time.Sleep(100 * time.Millisecond)
}
tciRecorder.mu.Lock()
tciRecorder.active = false
pcm := tciRecorder.samples
tciRecorder.samples = nil
tciRecorder.mu.Unlock()
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
if s, ok := t.(interface{ SetTCIAudioSink(func(int, []float32)) }); ok {
s.SetTCIAudioSink(nil)
}
return nil
})
if len(pcm) == 0 {
return "", fmt.Errorf("nothing arrived on the stream")
}
path := filepath.Join(a.qsoRecDir(), fmt.Sprintf("tci-test-%s.wav", time.Now().Format("20060102-150405")))
if err := writeMonoWAV(path, pcm, rate); err != nil {
return "", err
}
applog.Printf("tci: wrote %.1f s of receive audio to %s (%d Hz)", float64(len(pcm))/float64(rate), path, rate)
return path, nil
}
// writeMonoWAV writes float samples as 16-bit mono PCM.
//
// Its own writer rather than internal/audio's: that one is nailed to the voice
// keyer's rate, and a test recording written at the wrong rate would play back
// at the wrong speed — the one fault that looks exactly like a decoding error.
func writeMonoWAV(path string, samples []float32, rate int) error {
data := make([]byte, len(samples)*2)
for i, v := range samples {
s := int(math.Round(float64(v) * 32767))
if s > 32767 {
s = 32767
}
if s < -32768 {
s = -32768
}
binary.LittleEndian.PutUint16(data[i*2:], uint16(int16(s)))
}
var hdr [44]byte
copy(hdr[0:], "RIFF")
binary.LittleEndian.PutUint32(hdr[4:], uint32(36+len(data)))
copy(hdr[8:], "WAVEfmt ")
binary.LittleEndian.PutUint32(hdr[16:], 16) // PCM chunk size
binary.LittleEndian.PutUint16(hdr[20:], 1) // PCM
binary.LittleEndian.PutUint16(hdr[22:], 1) // mono
binary.LittleEndian.PutUint32(hdr[24:], uint32(rate))
binary.LittleEndian.PutUint32(hdr[28:], uint32(rate*2))
binary.LittleEndian.PutUint16(hdr[32:], 2) // block align
binary.LittleEndian.PutUint16(hdr[34:], 16) // bits
copy(hdr[36:], "data")
binary.LittleEndian.PutUint32(hdr[40:], uint32(len(data)))
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(hdr[:]); err != nil {
return err
}
_, err = f.Write(data)
return err
}