From 01a23ccb77bbf3b9ae1457f91c9fa9405838300f Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 25 Aug 2026 08:25:29 +0200 Subject: [PATCH] feat(tci): read the radio's declared format, and record a test WAV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app_tci_record.go | 175 ++++++++++++++++++++++ frontend/src/components/SettingsModal.tsx | 23 ++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + internal/cat/tci.go | 10 ++ internal/cat/tci_audio.go | 35 ++++- 7 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 app_tci_record.go diff --git a/app_tci_record.go b/app_tci_record.go new file mode 100644 index 0000000..fd47e2a --- /dev/null +++ b/app_tci_record.go @@ -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 +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 0bd5f3c..793f6b0 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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({ 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 )} + {/* 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 && ( +
+ + {!!tciRecPath && {tciRecPath}} +
+ )} {!!tciAudio.last_err &&

{tciAudio.last_err}

}

{t('aud.tciHint')}

diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 9a4f43e..a67ea98 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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…', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index cb1b17f..7b26378 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -905,6 +905,8 @@ export function RecomputeAllAwardRefs():Promise; export function RecomputeAwardRefsForCode(arg1:string):Promise; +export function RecordTCIAudio(arg1:number):Promise; + export function RefreshCtyDat():Promise; export function RefreshKenwood():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 54e4a41..b2dc34a 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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'](); } diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 441b8aa..3d4d777 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -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": diff --git a/internal/cat/tci_audio.go b/internal/cat/tci_audio.go index 9ee4f8a..882ee39 100644 --- a/internal/cat/tci_audio.go +++ b/internal/cat/tci_audio.go @@ -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 }