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. */} +
+
{t('aud.tciTitle')}
+
+ + {tciAudio.running && ( + + {tciAudio.sample_rate || 0} Hz · {tciAudio.frames || 0} frames ·{' '} + {tciAudio.peak_db > -90 ? tciAudio.peak_db.toFixed(1) + ' dBFS' : t('aud.tciSilent')} + + )} +
+ {!!tciAudio.last_err &&

{tciAudio.last_err}

} +

{t('aud.tciHint')}

+