package main // Feeding the QSO recorder from the TCI stream. // // The recorder works in 16 kHz mono, which is what its files and its mixing are // built around; TCI delivers 48 kHz stereo float32. The conversion is the whole // of this file, and it happens here rather than in internal/cat because the // radio's job is to hand over what it sent, not to know what the recorder wants. // // Confirmed on a SunSDR (ExpertSDR3 1.5): 2048 samples a frame, 8192 bytes, // four bytes per sample — and a test recording that plays back clean. import ( "encoding/binary" "hamlog/internal/applog" "hamlog/internal/audio" "hamlog/internal/cat" ) // tciRecordSink pushes the receive stream into the QSO recorder. // // Installed whenever the TCI backend starts, and harmless when nothing is // recording: PushRX drops what arrives unless a QSO is being captured, so the // cost while idle is a decimation and a function call. func (a *App) tciRecordSink(rate int, samples []float32) { if a.qsoRec == nil || len(samples) == 0 { return } a.qsoRec.PushRX(tciToRecorderPCM(rate, samples)) } // tciToRecorderPCM converts the stream's mono float samples to the recorder's // 16-bit PCM at its own rate. // // Averaging rather than picking every third sample: dropping samples aliases // everything above 8 kHz back down into the voice band, and on a receiver that // is hiss — the one thing a QSO recording has plenty of. A three-tap mean is a // crude low-pass, but it is a low-pass, and it costs two additions. func tciToRecorderPCM(rate int, samples []float32) []byte { if rate <= 0 { rate = 48000 } step := rate / audio.RecorderSampleRate if step < 1 { step = 1 } out := make([]byte, 0, (len(samples)/step)*2) for i := 0; i+step <= len(samples); i += step { var sum float32 for j := 0; j < step; j++ { sum += samples[i+j] } v := sum / float32(step) if v > 1 { v = 1 } if v < -1 { v = -1 } var b [2]byte binary.LittleEndian.PutUint16(b[:], uint16(int16(v*32767))) out = append(out, b[0], b[1]) } return out } // startTCIRecording opens the receive stream and routes it to the recorder. // // Called when the TCI backend comes up, and only when the operator has asked // for it by choosing the radio as their receive device: opening a 384 kB/s // stream on a station that records nothing is work the radio does for nobody. func (a *App) startTCIRecording() { if a.cat == nil { return } // One switch, and it is the one an operator is already looking at: the // "From radio" device. There used to be a tick box here as well, from when // this was an experiment with no device to choose — two controls for one // question, and the second was where nobody would look. cfg, _ := a.GetAudioSettings() if cfg.FromRadio != audio.NetworkDeviceID { return } err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { if s, ok := t.(interface { SetTCIAudioSink(func(int, []float32)) }); ok { s.SetTCIAudioSink(a.tciRecordSink) } return t.StartTCIAudio(0, 48000) }) if err != nil { applog.Printf("tci: could not open the receive stream for recording: %v", err) return } applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed") }