From efa711af78272ae04239285ccd710e5e75496d77 Mon Sep 17 00:00:00 2001
From: rouggy
Date: Mon, 24 Aug 2026 20:34:06 +0200
Subject: [PATCH 01/11] feat(tci): receive audio over the TCI WebSocket
(experimental)
A SunSDR already carries its receive audio on the same WebSocket as its
commands, so a virtual audio cable and a second sound card are two pieces
of plumbing an operator installs for no reason. This is the receive half:
what the QSO recorder and the CW decoder need.
The reader now looks at the frame type. It used to ignore it and split
every frame on ';' -- harmless only for as long as no stream was ever
opened, since audio bytes would otherwise have been handed to the command
parser a hundred times a second.
NOTHING HERE IS CONFIRMED ON A RADIO. The header layout comes from the
TCI documentation, and the stream-type numbers are exactly the sort of
detail a document gets right and a memory of it does not -- so the first
forty frames of a session are logged verbatim, and a test bench in
Preferences > Audio reports the sample rate the radio chose, the frames
arriving and the peak level of the last second. 'The stream is open' and
'audio is arriving' are different claims and only the second is worth
anything to whoever tries this first.
Transmit (the voice keyer) is the other half and is deliberately absent:
it has to answer the radio's chrono packets at the right pace, and that
is worth doing once the format is settled on real hardware.
---
app_tci_audio.go | 53 +++++
frontend/package.json.md5 | 2 +-
frontend/src/components/SettingsModal.tsx | 40 +++-
frontend/src/lib/i18n.tsx | 4 +-
frontend/wailsjs/go/main/App.d.ts | 6 +
frontend/wailsjs/go/main/App.js | 12 ++
frontend/wailsjs/go/models.ts | 22 ++
internal/cat/tci.go | 15 +-
internal/cat/tci_audio.go | 251 ++++++++++++++++++++++
internal/cat/tci_manager.go | 39 ++++
10 files changed, 439 insertions(+), 5 deletions(-)
create mode 100644 app_tci_audio.go
create mode 100644 internal/cat/tci_audio.go
create mode 100644 internal/cat/tci_manager.go
diff --git a/app_tci_audio.go b/app_tci_audio.go
new file mode 100644
index 0000000..be2b283
--- /dev/null
+++ b/app_tci_audio.go
@@ -0,0 +1,53 @@
+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
+}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index 693b40b..b826f3b 100644
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-f9b41e192918fa2511f68cd1b361fcd3
\ No newline at end of file
+704fe1bf370b669665df0606fae8a69d
\ No newline at end of file
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index c9437ef..0bd5f3c 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,
+ GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1943,6 +1943,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [spotTTL, setSpotTTL] = useState(0);
const [spotTTLText, setSpotTTLText] = useState('0');
const [spotMaxText, setSpotMaxText] = useState('1000');
+ // 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 });
+ 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) => {
@@ -6542,6 +6551,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('aud.fromRadioShort')} {t('aud.explainFrom')}{' '}
{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}}
+
+ )}
{!!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
}
From 6a6b7ad6c29d84406344604d0e92d09d0d82e435 Mon Sep 17 00:00:00 2001
From: rouggy
Date: Tue, 25 Aug 2026 08:29:24 +0200
Subject: [PATCH 04/11] chore(tci): probe binary frames per stream type
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The frame log had one budget for the whole session, and the first forty
receive-audio frames spend it in under two seconds. A transmit-chrono
frame — the thing the voice keyer will have to answer, and whose size and
cadence cannot be read off the documentation — only appears once the
operator keys the radio, by which time nothing would have been logged.
Counted per type now, so the first frames of each kind are recorded
whenever they turn up.
---
internal/cat/tci_audio.go | 25 +++++++++++++++++++------
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/internal/cat/tci_audio.go b/internal/cat/tci_audio.go
index 882ee39..844ebf4 100644
--- a/internal/cat/tci_audio.go
+++ b/internal/cat/tci_audio.go
@@ -81,8 +81,8 @@ type tciAudio struct {
samples int64
peak float64
peakAt time.Time
- probe int
- lastErr string
+ probeByType map[int]int
+ lastErr string
// 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
@@ -110,7 +110,6 @@ func (t *TCI) StartTCIAudio(rx, rate int) error {
t.audio.rx = rx
t.audio.rate = rate
t.audio.frames, t.audio.samples, t.audio.peak = 0, 0, 0
- t.audio.probe = 0
t.audio.lastErr = ""
t.audio.mu.Unlock()
@@ -182,10 +181,20 @@ func (t *TCI) handleBinary(data []byte) {
length := int(le.Uint32(data[20:]))
stype := int(le.Uint32(data[24:]))
+ // Counted PER STREAM TYPE, not overall.
+ //
+ // A single counter was spent on the first forty receive-audio frames, which
+ // arrive twenty-four times a second — so a transmit-chrono or transmit-audio
+ // frame, the two this needs to see before the voice keyer can be written,
+ // would never have been logged at all. They only appear once the operator
+ // keys the radio, long after any global budget is gone.
t.audio.mu.Lock()
- probe := t.audio.probe
+ if t.audio.probeByType == nil {
+ t.audio.probeByType = map[int]int{}
+ }
+ probe := t.audio.probeByType[stype]
if probe < tciAudioProbeMax {
- t.audio.probe++
+ t.audio.probeByType[stype]++
}
t.audio.mu.Unlock()
if probe < tciAudioProbeMax {
@@ -194,7 +203,11 @@ func (t *TCI) handleBinary(data []byte) {
}
if stype != tciStreamRXAudio {
- return // IQ, TX audio echo, chrono: not this file's business yet
+ // IQ, transmit audio, chrono. Nothing consumes them yet — but the chrono
+ // frames are what a voice keyer over TCI would have to answer, and their
+ // size and cadence cannot be guessed from the documentation. They are
+ // logged (per type, see above) and dropped.
+ return
}
if codec != 0 {
t.audioErr(fmt.Sprintf("stream is codec=%d, and nothing here decodes a compressed stream", codec))
From f50bbc005cb5c53b54eb58a146238f09462108e3 Mon Sep 17 00:00:00 2001
From: rouggy
Date: Tue, 25 Aug 2026 20:14:45 +0200
Subject: [PATCH 05/11] feat(tci): the QSO recorder can take its audio from the
radio
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app.go | 6 +-
app_tci_rec.go | 120 ++++++++++++++++++++++
app_tci_rec_test.go | 47 +++++++++
frontend/src/components/SettingsModal.tsx | 13 ++-
frontend/src/lib/i18n.tsx | 4 +-
frontend/wailsjs/go/main/App.d.ts | 4 +
frontend/wailsjs/go/main/App.js | 8 ++
internal/audio/rate.go | 9 ++
internal/audio/wav.go | 1 +
9 files changed, 208 insertions(+), 4 deletions(-)
create mode 100644 app_tci_rec.go
create mode 100644 app_tci_rec_test.go
create mode 100644 internal/audio/rate.go
diff --git a/app.go b/app.go
index 79d069a..0f4b7ee 100644
--- a/app.go
+++ b/app.go
@@ -15262,7 +15262,11 @@ func (a *App) reloadCAT() {
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
- // TCI-compatible server.
+ // TCI-compatible server. The receive audio rides the same socket, so
+ // the QSO recorder can take it without a virtual cable — see
+ // app_tci_rec.go. Armed after the backend is up, since it is the
+ // backend that carries the stream.
+ defer a.startTCIRecording()
tb := cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots)
// Clicking one of our spots on the ExpertSDR panorama fills the entry form.
tb.OnSpotClick = func(call string, hz int64) {
diff --git a/app_tci_rec.go b/app_tci_rec.go
new file mode 100644
index 0000000..905e587
--- /dev/null
+++ b/app_tci_rec.go
@@ -0,0 +1,120 @@
+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()
+ })
+}
diff --git a/app_tci_rec_test.go b/app_tci_rec_test.go
new file mode 100644
index 0000000..4e0c162
--- /dev/null
+++ b/app_tci_rec_test.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+ "encoding/binary"
+ "testing"
+
+ "hamlog/internal/audio"
+)
+
+// The stream is 48 kHz and the recorder works at 16 — three to one. A
+// recording that keeps every sample plays back three times too fast, which is
+// the fault that gets blamed on the decoding rather than on the rate.
+func TestTheStreamIsResampledToTheRecorderRate(t *testing.T) {
+ const in = 48000
+ samples := make([]float32, in/10) // a tenth of a second
+ pcm := tciToRecorderPCM(in, samples)
+ want := (audio.RecorderSampleRate / 10) * 2 // 16-bit
+ if len(pcm) != want {
+ t.Fatalf("a tenth of a second produced %d bytes, want %d", len(pcm), want)
+ }
+}
+
+// Full scale must arrive as full scale: a conversion that quietly halves the
+// level turns a recording into evidence of a fault that is not there.
+func TestFullScaleSurvivesTheConversion(t *testing.T) {
+ samples := make([]float32, 12)
+ for i := range samples {
+ samples[i] = 1
+ }
+ pcm := tciToRecorderPCM(48000, samples)
+ if len(pcm) < 2 {
+ t.Fatal("no samples came out")
+ }
+ v := int16(binary.LittleEndian.Uint16(pcm[:2]))
+ if v < 32000 {
+ t.Fatalf("full scale came out at %d", v)
+ }
+}
+
+// A rate the recorder already works in is passed through rather than mangled by
+// a division that would round to nothing.
+func TestAStreamAtTheRecorderRateIsNotDecimated(t *testing.T) {
+ samples := make([]float32, 160)
+ if got, want := len(tciToRecorderPCM(audio.RecorderSampleRate, samples)), 160*2; got != want {
+ t.Fatalf("%d bytes, want %d", got, want)
+ }
+}
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 793f6b0..3e8ff26 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,
+ GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1948,6 +1948,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// done for nothing.
const [tciAudio, setTciAudio] = useState({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
const [tciRecBusy, setTciRecBusy] = 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;
@@ -6599,6 +6602,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
{!!tciAudio.last_err &&
{tciAudio.last_err}
}
+
{t('aud.tciHint')}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index a67ea98..c9ce53c 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.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.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.tciRecord': 'Record QSOs from this stream', 'aud.tciRecordHint': 'The QSO recorder takes the receive audio from the radio instead of a sound card — no virtual cable, nothing to select above. Your microphone is still recorded from the device chosen there.', '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.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.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.tciRecord': 'Enregistrer les QSO depuis ce flux', 'aud.tciRecordHint': "L'enregistreur prend l'audio de réception sur la radio au lieu d'une carte son — aucun câble virtuel, rien à choisir au-dessus. Ton micro reste enregistré depuis le périphérique sélectionné là-haut.", '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 7b26378..b3b6e9e 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -577,6 +577,8 @@ export function GetStationStatus():Promise>;
export function GetTCIAudioStatus():Promise;
+export function GetTCIRecordAudio():Promise;
+
export function GetTelemetryEnabled():Promise;
export function GetTrackedAwards():Promise>;
@@ -1159,6 +1161,8 @@ export function SetSpotMax(arg1:number):Promise;
export function SetSpotTTLMinutes(arg1:number):Promise;
+export function SetTCIRecordAudio(arg1:boolean):Promise;
+
export function SetTelemetryEnabled(arg1:boolean):Promise;
export function SetUIPref(arg1:string,arg2:string):Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index b2dc34a..7fc25f9 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -1094,6 +1094,10 @@ export function GetTCIAudioStatus() {
return window['go']['main']['App']['GetTCIAudioStatus']();
}
+export function GetTCIRecordAudio() {
+ return window['go']['main']['App']['GetTCIRecordAudio']();
+}
+
export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
@@ -2258,6 +2262,10 @@ export function SetSpotTTLMinutes(arg1) {
return window['go']['main']['App']['SetSpotTTLMinutes'](arg1);
}
+export function SetTCIRecordAudio(arg1) {
+ return window['go']['main']['App']['SetTCIRecordAudio'](arg1);
+}
+
export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
}
diff --git a/internal/audio/rate.go b/internal/audio/rate.go
new file mode 100644
index 0000000..2f2ab68
--- /dev/null
+++ b/internal/audio/rate.go
@@ -0,0 +1,9 @@
+package audio
+
+// RecorderSampleRate is the rate the QSO recorder works in.
+//
+// Exported because a source that is NOT a sound card — the TCI receive stream,
+// the Icom network audio — has to resample into it, and hard-coding 16000 at
+// each of those call sites is how one of them ends up at the wrong speed after
+// this constant is ever changed.
+const RecorderSampleRate = sampleRate
diff --git a/internal/audio/wav.go b/internal/audio/wav.go
index 0a8953d..02e7368 100644
--- a/internal/audio/wav.go
+++ b/internal/audio/wav.go
@@ -14,6 +14,7 @@ import (
// any device regardless of its native mix format.
const (
sampleRate = 16000
+
channels = 1
bitsPerSample = 16
blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2)
From 95e57fb81239dd98d5937f955e4754ee4269a2a8 Mon Sep 17 00:00:00 2001
From: rouggy
Date: Tue, 25 Aug 2026 23:03:14 +0200
Subject: [PATCH 06/11] chore(tci): mark the transmit passes and count every
frame type
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first transmit test came back with a log that said nothing, which is
the one answer that cannot be read: either no transmit frames arrived, or
they arrived and went unlogged.
So each pass is now bounded by a line of its own, and every stream type is
counted without limit. A pass that reports 'receive audio: 240, and
nothing else' is a result — it says the radio sends no chrono unless
something more is asked of it — where a log with no transmit lines was
merely a silence. The forty-frame logging budget is also handed back to
the transmit types on each pass, since it was always spent on receive
audio long before anyone got round to keying.
The start line says whether the receive stream is even open, because a
radio with nothing streaming has no reason to send chrono, and that is the
likeliest reason the first attempt saw nothing.
---
internal/cat/tci.go | 9 ++++
internal/cat/tci_audio.go | 91 +++++++++++++++++++++++++++++++++------
2 files changed, 88 insertions(+), 12 deletions(-)
diff --git a/internal/cat/tci.go b/internal/cat/tci.go
index 3d4d777..27a2531 100644
--- a/internal/cat/tci.go
+++ b/internal/cat/tci.go
@@ -444,7 +444,16 @@ func (t *TCI) handle(msg string) {
}
case "trx":
if get(0) == "0" {
+ was := t.tx
t.tx = get(1) == "true"
+ // Said out loud, every time. The transmit side of TCI can only be
+ // written from a log of a real transmission, and the first one came
+ // back without a single line to say whether the radio had even been
+ // keyed — which left the interesting question, why no transmit
+ // frames, indistinguishable from nobody having pressed anything.
+ if was != t.tx {
+ t.noteTXTransition(t.tx)
+ }
}
case "tx_enable":
if get(0) == "0" {
diff --git a/internal/cat/tci_audio.go b/internal/cat/tci_audio.go
index 844ebf4..814e3b9 100644
--- a/internal/cat/tci_audio.go
+++ b/internal/cat/tci_audio.go
@@ -33,6 +33,7 @@ import (
"encoding/binary"
"fmt"
"math"
+ "strings"
"sync"
"time"
@@ -60,10 +61,10 @@ const tciAudioProbeMax = 40
// TCIAudioStatus is what the panel polls while testing the stream.
type TCIAudioStatus struct {
- Running bool `json:"running"`
- SampleRate int `json:"sample_rate"`
- Frames int64 `json:"frames"` // binary frames accepted
- Samples int64 `json:"samples"` // audio samples decoded
+ Running bool `json:"running"`
+ SampleRate int `json:"sample_rate"`
+ Frames int64 `json:"frames"` // binary frames accepted
+ Samples int64 `json:"samples"` // audio samples decoded
// PeakDB is the loudest sample of the last second, in dBFS: the one number
// that says "audio is really arriving" rather than "a socket is open".
PeakDB float64 `json:"peak_db"`
@@ -73,15 +74,20 @@ type TCIAudioStatus struct {
// tciAudio is the receive-side state, kept on the backend so it lives exactly
// as long as the connection does.
type tciAudio struct {
- mu sync.Mutex
- want bool // the host asked for audio
- rx int // which receiver
- rate int
- frames int64
- samples int64
- peak float64
- peakAt time.Time
+ mu sync.Mutex
+ want bool // the host asked for audio
+ rx int // which receiver
+ rate int
+ frames int64
+ samples int64
+ peak float64
+ peakAt time.Time
probeByType map[int]int
+ // countByType counts EVERY frame per stream type, capped by nothing.
+ // The probe above stops logging after forty frames of a type; these keep
+ // counting, so a transmission that produced no transmit frames at all can
+ // be reported as a fact rather than inferred from an absence of lines.
+ countByType map[int]int64
lastErr string
// widthLogged keeps the one-line note about the sample width to once a
// session — it is a fact about the radio, not an event.
@@ -90,6 +96,9 @@ type tciAudio struct {
// 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.
+ // txMark is the per-type frame count when transmission began, so the census
+ // at the end reports the pass rather than the whole session.
+ txMark map[int]int64
declaredType string
declaredChans int
@@ -192,6 +201,10 @@ func (t *TCI) handleBinary(data []byte) {
if t.audio.probeByType == nil {
t.audio.probeByType = map[int]int{}
}
+ if t.audio.countByType == nil {
+ t.audio.countByType = map[int]int64{}
+ }
+ t.audio.countByType[stype]++
probe := t.audio.probeByType[stype]
if probe < tciAudioProbeMax {
t.audio.probeByType[stype]++
@@ -333,3 +346,57 @@ func (t *TCI) resumeAudio() {
// ignore the message type entirely and split every frame on ';', which would
// have fed audio bytes to the command parser the moment a stream was opened.
func wsMessageIsBinary(mt int) bool { return mt == websocket.BinaryMessage }
+
+// noteTXTransition reports what the stream did across a transmission.
+//
+// The voice keyer needs two numbers the documentation does not give: the size
+// and the cadence of the frames the radio expects while transmitting. They can
+// only be read off a real transmission — and the first attempt came back with a
+// log that said nothing at all, which is ambiguous: either no transmit frames
+// arrived, or they arrived and went unlogged.
+//
+// So the boundaries are marked and every stream type is counted. A pass that
+// produces "type 1: 240, and nothing else" is a RESULT — it says the radio
+// sends no chrono unless something more is asked of it — where a log with no
+// transmit lines in it was merely a silence.
+func (t *TCI) noteTXTransition(on bool) {
+ t.audio.mu.Lock()
+ if t.audio.countByType == nil {
+ t.audio.countByType = map[int]int64{}
+ }
+ if on {
+ // Let the transmit types speak again on every pass: forty frames is a
+ // budget spent long before the operator gets round to keying.
+ if t.audio.probeByType != nil {
+ delete(t.audio.probeByType, tciStreamTXAudio)
+ delete(t.audio.probeByType, tciStreamTXChrono)
+ }
+ t.audio.txMark = map[int]int64{}
+ for k, v := range t.audio.countByType {
+ t.audio.txMark[k] = v
+ }
+ streaming := t.audio.want
+ t.audio.mu.Unlock()
+ debugLog.Printf("TCI: TRANSMIT started — watching for transmit-audio (type %d) and chrono (type %d) frames; receive stream is %s",
+ tciStreamTXAudio, tciStreamTXChrono, map[bool]string{true: "open", false: "CLOSED (tick the TCI recording option, or the radio has no reason to stream)"}[streaming])
+ return
+ }
+ names := map[int]string{
+ tciStreamIQ: "IQ",
+ tciStreamRXAudio: "receive audio",
+ tciStreamTXAudio: "transmit audio",
+ tciStreamTXChrono: "transmit chrono",
+ }
+ var parts []string
+ for _, k := range []int{tciStreamIQ, tciStreamRXAudio, tciStreamTXAudio, tciStreamTXChrono} {
+ if n := t.audio.countByType[k] - t.audio.txMark[k]; n > 0 {
+ parts = append(parts, fmt.Sprintf("%s (type %d): %d", names[k], k, n))
+ }
+ }
+ t.audio.mu.Unlock()
+ if len(parts) == 0 {
+ debugLog.Printf("TCI: TRANSMIT ended — NO binary frames of any type arrived during it")
+ return
+ }
+ debugLog.Printf("TCI: TRANSMIT ended — frames during the pass: %s", strings.Join(parts, ", "))
+}
From 848ce68ec527b25b019a4404281a6e86e739666d Mon Sep 17 00:00:00 2001
From: rouggy
Date: Tue, 25 Aug 2026 23:18:11 +0200
Subject: [PATCH 07/11] feat(tci): key the radio ourselves and push a tone, to
see what it wants
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A first real transmission settled one question and raised a better one.
With the receive stream open and six seconds of transmit, the radio sent
282 frames of receive audio and NOTHING else: no chrono, no transmit
audio. So the chrono the documentation describes is not offered to a
client that merely happens to be connected while the operator keys the
microphone, and waiting for it to appear is waiting for nothing.
The reading that fits is that the radio asks for audio when the
transmission is the CLIENT'S and takes the microphone when it is the
operator's — which makes the experiment obvious. Key it from here, push a
1 kHz tone, and watch. Chrono frames appearing gives their size and
cadence by measurement instead of by guesswork; no chrono but a tone on
the meter is just as useful, because then the pacing is optional and the
voice keyer can push frames at the rate the stream already runs at.
A tone rather than silence so the answer shows on the power meter and not
only in the log.
It transmits, so: an explicit button inside a warning box, five seconds,
capped at ten, and every path out unkeys — including the panic that has
not happened yet and a socket that dies mid-tone. A transmitter left keyed
by a defect is the one fault here that would reach somebody else's band.
Writes are now serialised too. send() held the lock only long enough to
read the connection, which was enough while every command came from the
poll loop; a stream of audio frames from a second goroutine is not, and
gorilla panics on a concurrent write rather than failing quietly.
---
app_tci_audio.go | 27 ++++
frontend/src/components/SettingsModal.tsx | 20 ++-
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 | 7 +
internal/cat/tci_tx_probe.go | 171 ++++++++++++++++++++++
7 files changed, 232 insertions(+), 3 deletions(-)
create mode 100644 internal/cat/tci_tx_probe.go
diff --git a/app_tci_audio.go b/app_tci_audio.go
index be2b283..c65ea1a 100644
--- a/app_tci_audio.go
+++ b/app_tci_audio.go
@@ -51,3 +51,30 @@ func (a *App) GetTCIAudioStatus() 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/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 3e8ff26..db1ae37 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,
+ GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio, ProbeTCITransmit,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1948,6 +1948,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// 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(() => {}); }, []);
@@ -6601,6 +6602,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{!!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. */}
+