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:
@@ -0,0 +1,175 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user