From ceb88d2e296683e4fe5dc5d102bcf80a56e83810 Mon Sep 17 00:00:00 2001
From: rouggy
Date: Wed, 26 Aug 2026 00:13:04 +0200
Subject: [PATCH] refactor(tci): remove the test bench now that choosing the
device is the setup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The TCI section of the audio settings was an investigation: open the
stream, read what arrives, record ten seconds to listen to, key a tone.
It answered every question it was built for — the frame layout, the
sample width, that the radio asks rather than follows a clock, that the
transmit audio source decides — and confirmed 80 W on real hardware.
None of that belongs in front of an operator now. The radio is simply one
of the devices in the two dropdowns, and choosing it IS the configuration:
one control, in the place where the question is already being asked.
Keeping the tick box beside it would have been two switches for one
decision, with the second one where nobody looks.
Gone with it: the stream/record/probe bindings, the audio.tci_rx setting,
and twenty-four translation keys. The plumbing they proved out stays and
now carries the voice keyer.
---
app.go | 2 +-
app_tci_audio.go | 80 ----------
app_tci_rec.go | 44 +-----
app_tci_record.go | 175 ----------------------
frontend/src/components/SettingsModal.tsx | 93 ++----------
frontend/src/lib/i18n.tsx | 4 +-
frontend/wailsjs/go/main/App.d.ts | 14 --
frontend/wailsjs/go/main/App.js | 28 ----
frontend/wailsjs/go/models.ts | 22 ---
internal/cat/tci_tx_probe.go | 163 +-------------------
10 files changed, 24 insertions(+), 601 deletions(-)
delete mode 100644 app_tci_audio.go
delete mode 100644 app_tci_record.go
diff --git a/app.go b/app.go
index a12c976..1757ea4 100644
--- a/app.go
+++ b/app.go
@@ -8500,7 +8500,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
// reads as a device that does not work.
if s.FromRadio == audio.NetworkDeviceID {
a.startTCIRecording()
- } else if a.tciAudioAvailable() && a.settingOr(keyTCIRecAudio, "") != "1" {
+ } else if a.tciAudioAvailable() {
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
}
// Apply device/preroll/enable changes to the running recorder.
diff --git a/app_tci_audio.go b/app_tci_audio.go
deleted file mode 100644
index c65ea1a..0000000
--- a/app_tci_audio.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package main
-
-// TCI receive audio — bindings.
-//
-// A SunSDR carries its receive audio on the same WebSocket as its commands, so
-// OpsLog can take it directly instead of asking the operator to install a
-// virtual audio cable and wire ExpertSDR's output into it. This is the first
-// half: RECEIVE only, which is what the QSO recorder and the CW decoder need.
-// Transmit audio (the voice keyer) is the other half and is not here yet — it
-// has to answer the radio's chrono packets at the right pace, and that is worth
-// doing once the receive side has proved the format on a real radio.
-
-import (
- "fmt"
-
- "hamlog/internal/applog"
- "hamlog/internal/cat"
-)
-
-// StartTCIAudio opens the receive-audio stream for one receiver.
-//
-// rate 0 means 48 kHz, which is what ExpertSDR streams by default and what the
-// recorder wants anyway.
-func (a *App) StartTCIAudio(rx, rate int) error {
- if a.cat == nil {
- return fmt.Errorf("CAT not initialized")
- }
- applog.Printf("tci: opening the receive-audio stream (rx %d, %d Hz)", rx, rate)
- return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StartTCIAudio(rx, rate) })
-}
-
-// StopTCIAudio closes it.
-func (a *App) StopTCIAudio() error {
- if a.cat == nil {
- return fmt.Errorf("CAT not initialized")
- }
- applog.Printf("tci: closing the receive-audio stream")
- return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
-}
-
-// GetTCIAudioStatus reports what is arriving: the sample rate the radio chose,
-// how much has come in, and the peak level of the last second.
-//
-// The level is the point. "The stream is open" and "audio is arriving" are
-// different claims, and only the second one is worth anything to someone
-// testing this on a radio for the first time.
-func (a *App) GetTCIAudioStatus() cat.TCIAudioStatus {
- if a.cat == nil {
- return cat.TCIAudioStatus{}
- }
- st, _ := a.cat.TCIAudioState()
- return st
-}
-
-// ProbeTCITransmit keys the radio and pushes a tone over TCI, to find out
-// whether the transmit half of the stream works at all.
-//
-// A first real transmission proved that the radio sends nothing extra while the
-// OPERATOR keys it — 282 receive frames and no chrono over six seconds — so the
-// only way forward is to key it from here and watch. Everything interesting
-// lands in the log; see internal/cat/tci_tx_probe.go for what the two possible
-// answers mean.
-//
-// THIS TRANSMITS. The button that calls it says so, and the radio must be on a
-// dummy load.
-func (a *App) ProbeTCITransmit(seconds int) error {
- if a.cat == nil {
- return fmt.Errorf("CAT not initialized")
- }
- return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
- p, ok := t.(interface {
- ProbeTXStream(seconds int, toneHz float64) error
- })
- if !ok {
- return fmt.Errorf("this CAT backend is not a TCI radio")
- }
- applog.Printf("tci: TX PROBE requested (%d s) — the radio should be on a dummy load", seconds)
- return p.ProbeTXStream(seconds, 1000)
- })
-}
diff --git a/app_tci_rec.go b/app_tci_rec.go
index ced5a51..f932d82 100644
--- a/app_tci_rec.go
+++ b/app_tci_rec.go
@@ -68,18 +68,18 @@ func tciToRecorderPCM(rate int, samples []float32) []byte {
// 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.
+// 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
}
- // Two ways to ask for the same thing: the option in the TCI section, or
- // simply choosing the radio as the "From radio" device. The second is where
- // an operator looks first — it is the question they are already answering —
- // so it has to work as well as the tick box.
+ // 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 a.settingOr(keyTCIRecAudio, "") != "1" && cfg.FromRadio != audio.NetworkDeviceID {
+ if cfg.FromRadio != audio.NetworkDeviceID {
return
}
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
@@ -96,33 +96,3 @@ func (a *App) startTCIRecording() {
}
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()
- })
-}
diff --git a/app_tci_record.go b/app_tci_record.go
deleted file mode 100644
index fd47e2a..0000000
--- a/app_tci_record.go
+++ /dev/null
@@ -1,175 +0,0 @@
-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 13b7663..3533c67 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, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio, ProbeTCITransmit,
+ GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -2038,18 +2038,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// TCI receive-audio test bench. Polled only while the stream is open: a panel
// 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 [tciTXBusy, setTciTXBusy] = useState(false);
+
+
+
// Whether the QSO recorder takes its audio from the radio's own stream.
- const [tciRec, setTciRec] = useState(false);
- useEffect(() => { GetTCIRecordAudio().then((v: boolean) => setTciRec(!!v)).catch(() => {}); }, []);
- const [tciRecPath, setTciRecPath] = useState('');
- useEffect(() => {
- if (!tciAudio.running) return;
- const id = window.setInterval(() => { GetTCIAudioStatus().then(setTciAudio).catch(() => {}); }, 500);
- return () => window.clearInterval(id);
- }, [tciAudio.running]);
const [gridStat, setGridStat] = useState(null);
const [pskrStatus, setPskrStatus] = useState(null);
const saveBandOpen = async (next: any) => {
@@ -6762,78 +6754,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{t('aud.toRadioShort')} {t('aud.explainTo')}
- {/* TCI receive audio — EXPERIMENTAL, and the panel says so.
- A SunSDR already carries its receive audio on the WebSocket that
- carries its commands, so none of the devices above need to exist
- for it: no virtual cable, no second sound card. This is the test
- bench for that path — it opens the stream and reports what really
- arrives, because "the stream is open" and "audio is arriving" are
- different claims and only the second one is worth anything. */}
-
- {/* 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}}
-
- )}
- {/* The transmit experiment. It KEYS THE RADIO, which is why it is
- worded as a warning and not as another test button: a first pass
- on real hardware showed the radio sends nothing extra while the
- operator keys it by hand, so the only way to learn what it wants
- is to key it from here and push a tone. */}
-
-
-
{t('aud.tciTXWarn')}
-
- {!!tciAudio.last_err &&
{tciAudio.last_err}
}
-
-
{t('aud.tciHint')}
-
+ {/* The radio is one of the devices above when it can carry its own
+ audio — see ListAudioInputDevices. What used to be here was a test
+ bench: open the stream, record ten seconds, key a tone. It settled
+ how TCI works and has no business in front of an operator now that
+ choosing the device is the whole of the setup. */}