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:
2026-08-25 08:25:29 +02:00
parent 9b8168370f
commit 01a23ccb77
7 changed files with 247 additions and 6 deletions
+175
View File
@@ -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
}
+22 -1
View File
@@ -58,7 +58,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1947,6 +1947,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// that asks the backend twice a second for a stream nobody started is work
// done for nothing.
const [tciAudio, setTciAudio] = useState<any>({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
const [tciRecBusy, setTciRecBusy] = useState(false);
const [tciRecPath, setTciRecPath] = useState('');
useEffect(() => {
if (!tciAudio.running) return;
const id = window.setInterval(() => { GetTCIAudioStatus().then(setTciAudio).catch(() => {}); }, 500);
@@ -6577,6 +6579,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</span>
)}
</div>
{/* The test that actually settles it. Frames arriving 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 file the operator can PLAY says all three at once. */}
{tciAudio.running && (
<div className="flex items-center gap-3 flex-wrap">
<Button variant="outline" size="sm" className="h-8" disabled={tciRecBusy}
onClick={() => {
setTciRecBusy(true); setTciRecPath('');
RecordTCIAudio(10)
.then((p: string) => setTciRecPath(p))
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })))
.finally(() => setTciRecBusy(false));
}}>
{tciRecBusy ? t('aud.tciRecBusy') : t('aud.tciRec')}
</Button>
{!!tciRecPath && <span className="text-[11px] font-mono text-muted-foreground truncate">{tciRecPath}</span>}
</div>
)}
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
</div>
+2 -2
View File
@@ -494,7 +494,7 @@ const en: Dict = {
'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)',
'aud.fromRadioShort': 'From Radio', 'aud.toRadioShort': 'To Radio', 'aud.explainFrom': '= what you receive (used by the QSO recorder).', 'aud.explainTo': '= where voice-keyer messages are transmitted.',
'aud.monitorTitle': "Hear the rig's RX audio (From Radio) through your Listening device", 'aud.listenRadio': '▶ Listen to radio', 'aud.stopListening': '■ Stop listening',
'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.tciTitle': 'SunSDR receive audio over TCI (experimental)', 'aud.tciStart': 'Open the stream', 'aud.tciStop': 'Close the stream', 'aud.tciSilent': 'silent', 'aud.tciHint': 'Takes the receive audio straight from the radio over TCI, with no virtual audio cable and no second sound card. Receive only for now — the voice keyer still uses the devices above. Requires the CAT backend to be TCI.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).',
'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.tciTitle': 'SunSDR receive audio over TCI (experimental)', 'aud.tciStart': 'Open the stream', 'aud.tciStop': 'Close the stream', 'aud.tciRec': 'Record 10 s to listen', 'aud.tciRecBusy': 'Recording…', 'aud.tciSilent': 'silent', 'aud.tciHint': 'Takes the receive audio straight from the radio over TCI, with no virtual audio cable and no second sound card. Receive only for now — the voice keyer still uses the devices above. Requires the CAT backend to be TCI.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).',
'aud.txTitle': 'Key PTT and pipe your live mic into the rig (To Radio device)', 'aud.talkRadio': '🎙 Talk to radio (TX)', 'aud.stopTalk': '■ Stop talking (TX)',
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
@@ -962,7 +962,7 @@ const fr: Dict = {
'aud.noneDefault': '— aucun / défaut système —', 'aud.defaultTag': '(défaut)',
'aud.fromRadioShort': 'Depuis la radio', 'aud.toRadioShort': 'Vers la radio', 'aud.explainFrom': "= ce que vous recevez (utilisé par l'enregistreur de QSO).", 'aud.explainTo': '= où sont émis les messages du manipulateur vocal.',
'aud.monitorTitle': "Écouter l'audio RX du poste (Depuis la radio) sur votre périphérique d'écoute", 'aud.listenRadio': '▶ Écouter la radio', 'aud.stopListening': "■ Arrêter l'écoute",
'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.tciTitle': 'Audio de réception SunSDR par TCI (expérimental)', 'aud.tciStart': 'Ouvrir le flux', 'aud.tciStop': 'Fermer le flux', 'aud.tciSilent': 'silence', 'aud.tciHint': "Prend l'audio de réception directement sur la radio via TCI, sans câble audio virtuel ni seconde carte son. Réception seulement pour l'instant — le manipulateur vocal utilise toujours les périphériques ci-dessus. Nécessite le CAT réglé sur TCI.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).",
'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.tciTitle': 'Audio de réception SunSDR par TCI (expérimental)', 'aud.tciStart': 'Ouvrir le flux', 'aud.tciStop': 'Fermer le flux', 'aud.tciRec': 'Enregistrer 10 s pour écoute', 'aud.tciRecBusy': 'Enregistrement…', 'aud.tciSilent': 'silence', 'aud.tciHint': "Prend l'audio de réception directement sur la radio via TCI, sans câble audio virtuel ni seconde carte son. Réception seulement pour l'instant — le manipulateur vocal utilise toujours les périphériques ci-dessus. Nécessite le CAT réglé sur TCI.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).",
'aud.txTitle': 'Activer le PTT et envoyer votre micro vers le poste (périphérique « Vers la radio »)', 'aud.talkRadio': '🎙 Parler à la radio (TX)', 'aud.stopTalk': '■ Arrêter de parler (TX)',
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
+2
View File
@@ -905,6 +905,8 @@ export function RecomputeAllAwardRefs():Promise<number>;
export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
export function RecordTCIAudio(arg1:number):Promise<string>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshKenwood():Promise<void>;
+4
View File
@@ -1750,6 +1750,10 @@ export function RecomputeAwardRefsForCode(arg1) {
return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1);
}
export function RecordTCIAudio(arg1) {
return window['go']['main']['App']['RecordTCIAudio'](arg1);
}
export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
+10
View File
@@ -406,6 +406,16 @@ func (t *TCI) handle(msg string) {
switch strings.ToLower(name) {
case "device":
t.device = strings.TrimSpace(args)
// The radio ANNOUNCES its audio format at connect —
// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
// is better evidence than anything derived from a frame, and it arrives
// before the first frame does. Both were being logged as unhandled.
case "audio_stream_sample_type":
t.audio.declaredType = strings.TrimSpace(args)
case "audio_stream_channels":
if n, err := strconv.Atoi(strings.TrimSpace(args)); err == nil && n > 0 && n <= 8 {
t.audio.declaredChans = n
}
case "ready", "start":
t.ready = true
case "stop":
+32 -3
View File
@@ -86,6 +86,12 @@ type tciAudio struct {
// widthLogged keeps the one-line note about the sample width to once a
// session — it is a fact about the radio, not an event.
widthLogged bool
// What the radio SAID about its stream at connect (audio_stream_sample_type,
// audio_stream_channels). Its own declaration, and it arrives before the
// first frame — the frame arithmetic below stays as the check on it rather
// than as the only source.
declaredType string
declaredChans int
// OnSamples receives decoded MONO samples (the two channels averaged) at
// the negotiated rate. Mono because everything downstream — the QSO
@@ -116,6 +122,17 @@ func (t *TCI) StartTCIAudio(rx, rate int) error {
return t.send(fmt.Sprintf("audio_start:%d;", rx))
}
// SetTCIAudioSink installs (or removes) the consumer of the decoded samples.
//
// One sink, not a list: today it is a test recording, tomorrow the QSO
// recorder, and two consumers of a live stream would need a policy about which
// one wins that nothing yet has an opinion about.
func (t *TCI) SetTCIAudioSink(fn func(rate int, samples []float32)) {
t.audio.mu.Lock()
t.audio.OnSamples = fn
t.audio.mu.Unlock()
}
// StopTCIAudio closes the stream.
func (t *TCI) StopTCIAudio() error {
t.audio.mu.Lock()
@@ -224,7 +241,15 @@ func (t *TCI) handleBinary(data []byte) {
}
// Stereo interleaved → mono. Both channels of a receiver carry the same
// audio, and everything downstream works on one.
mono := make([]float32, 0, n/2+1)
// How many channels are interleaved. The radio says so at connect; two is
// the fallback, which is what every SunSDR seen so far streams.
t.audio.mu.Lock()
chans := t.audio.declaredChans
t.audio.mu.Unlock()
if chans <= 0 {
chans = 2
}
mono := make([]float32, 0, n/chans+1)
var peak float64
sample := func(i int) float32 {
if width == 2 {
@@ -234,8 +259,12 @@ func (t *TCI) handleBinary(data []byte) {
}
return math.Float32frombits(le.Uint32(payload[i*4:]))
}
for i := 0; i+1 < n; i += 2 {
v := (sample(i) + sample(i+1)) / 2
for i := 0; i+chans-1 < n; i += chans {
var sum float32
for c := 0; c < chans; c++ {
sum += sample(i + c)
}
v := sum / float32(chans)
if a := math.Abs(float64(v)); a > peak {
peak = a
}