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. */}
+