merge: TCI audio — receive, transmit, and the voice keyer over the CAT link
A SunSDR carries its audio on the same WebSocket as its commands, so
OpsLog can take it directly: no virtual cable, no second sound card, no
Windows mixer between the recording and the air. The radio appears as a
device in both audio lists and can be chosen for either direction.
Everything here was settled on real hardware over one evening, and none of
it was guessable from the documentation:
- The receive stream answers format=3 for four-byte floats, so the
sample width is derived from the frame rather than trusted from the
field.
- The radio asks for transmit audio only when the transmission is the
CLIENT'S, and only when its transmit audio source is TCI rather than
the microphone.
- The chrono is a REQUEST, not a clock: no payload, carrying the size it
wants, 47 times a second. Audio goes out in answer to it and never on
a timer of our own — a timer was the first attempt and all 234 frames
of it were ignored.
Confirmed: a clean test recording, and 80 W out of a 1 kHz tone.
This commit is contained in:
@@ -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, 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';
|
||||
@@ -2035,6 +2035,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
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<any>({ 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<any>(null);
|
||||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||||
const saveBandOpen = async (next: any) => {
|
||||
@@ -6746,6 +6761,79 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
|
||||
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
|
||||
</p>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<div className="text-xs font-medium">{t('aud.tciTitle')}</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button variant={tciAudio.running ? 'default' : 'outline'} size="sm" className="h-8"
|
||||
onClick={() => {
|
||||
const p = tciAudio.running ? StopTCIAudio() : StartTCIAudio(0, 48000);
|
||||
p.then(() => GetTCIAudioStatus().then(setTciAudio))
|
||||
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })));
|
||||
}}>
|
||||
{tciAudio.running ? t('aud.tciStop') : t('aud.tciStart')}
|
||||
</Button>
|
||||
{tciAudio.running && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
{tciAudio.sample_rate || 0} Hz · {tciAudio.frames || 0} frames ·{' '}
|
||||
{tciAudio.peak_db > -90 ? tciAudio.peak_db.toFixed(1) + ' dBFS' : t('aud.tciSilent')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={tciRecBusy}
|
||||
onClick={() => {
|
||||
setTciRecBusy(true); setTciRecPath('');
|
||||
RecordTCIAudio(10)
|
||||
.then((p: string) => setTciRecPath(p))
|
||||
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })))
|
||||
.finally(() => setTciRecBusy(false));
|
||||
}}>
|
||||
{tciRecBusy ? t('aud.tciRecBusy') : t('aud.tciRec')}
|
||||
</Button>
|
||||
{!!tciRecPath && <span className="text-[11px] font-mono text-muted-foreground truncate">{tciRecPath}</span>}
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div className="rounded border border-caution-border bg-caution-muted p-2 space-y-1.5">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={tciTXBusy}
|
||||
onClick={() => {
|
||||
setTciTXBusy(true);
|
||||
ProbeTCITransmit(5)
|
||||
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })))
|
||||
.finally(() => setTciTXBusy(false));
|
||||
}}>
|
||||
{tciTXBusy ? t('aud.tciTXBusy') : t('aud.tciTX')}
|
||||
</Button>
|
||||
<p className="text-[11px] text-caution-muted-foreground">{t('aud.tciTXWarn')}</p>
|
||||
</div>
|
||||
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={tciRec}
|
||||
onCheckedChange={(c) => { setTciRec(!!c); SetTCIRecordAudio(!!c).catch(() => {}); }} />
|
||||
<span>
|
||||
{t('aud.tciRecord')}
|
||||
<span className="block text-muted-foreground">{t('aud.tciRecordHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={monitorOn ? 'default' : 'outline'}
|
||||
|
||||
Reference in New Issue
Block a user