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 }