Confirmed on a real SunSDR: the stream decodes and the test recording plays back clean. So it can do the job a virtual audio cable was doing — this wires it to the QSO recorder, which already accepts a pushed source (the Icom network audio uses the same door). The conversion lives here rather than in internal/cat: the radio's job is to hand over what it sent, not to know that the recorder works in 16 kHz mono. Three samples are AVERAGED rather than two of them dropped — decimating by picking every third folds everything above 8 kHz back into the voice band, and on a receiver that is hiss, which a QSO recording has plenty of already. Off by default, and applied the moment it is switched: it replaces a sound card the operator has already wired up, and an option that needs a restart to take effect reads as an option that does not work.
121 lines
3.8 KiB
Go
121 lines
3.8 KiB
Go
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: 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 || a.settingOr(keyTCIRecAudio, "") != "1" {
|
|
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")
|
|
}
|
|
|
|
// keyTCIRecAudio turns it on. Off by default: it replaces whatever sound card
|
|
// the operator has already wired up, and a setting that changes where a
|
|
// recording comes from should be asked for rather than assumed.
|
|
const keyTCIRecAudio = "audio.tci_rx"
|
|
|
|
// GetTCIRecordAudio reports whether the recorder takes its audio from the radio.
|
|
func (a *App) GetTCIRecordAudio() bool { return a.settingOr(keyTCIRecAudio, "") == "1" }
|
|
|
|
// SetTCIRecordAudio turns it on or off, and applies it NOW rather than at the
|
|
// next restart: an option that needs the application relaunched to take effect
|
|
// reads as an option that does not work.
|
|
func (a *App) SetTCIRecordAudio(on bool) error {
|
|
a.setSetting(keyTCIRecAudio, boolStr(on))
|
|
if on {
|
|
a.startTCIRecording()
|
|
return nil
|
|
}
|
|
if a.cat == nil {
|
|
return nil
|
|
}
|
|
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
|
if s, ok := t.(interface {
|
|
SetTCIAudioSink(func(int, []float32))
|
|
}); ok {
|
|
s.SetTCIAudioSink(nil)
|
|
}
|
|
return t.StopTCIAudio()
|
|
})
|
|
}
|