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.
176 lines
5.1 KiB
Go
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
|
|
}
|