From efa711af78272ae04239285ccd710e5e75496d77 Mon Sep 17 00:00:00 2001
From: rouggy
Date: Mon, 24 Aug 2026 20:34:06 +0200
Subject: [PATCH] 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. */}
+