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:
2026-08-26 00:05:40 +02:00
21 changed files with 1768 additions and 11 deletions
+1 -1
View File
@@ -1 +1 @@
f9b41e192918fa2511f68cd1b361fcd3
704fe1bf370b669665df0606fae8a69d
+89 -1
View File
@@ -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'}
+2 -2
View File
@@ -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.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.tciTX': 'Test transmit — 5 s tone', 'aud.tciTXBusy': 'Transmitting…', 'aud.tciTXWarn': 'THIS TRANSMITS. Put the radio on a dummy load first. It keys the radio and sends a 1 kHz tone over TCI for five seconds, to find out whether the transmit half of the stream works — the log holds the answer.', '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.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.tciTX': "Test d'émission — tonalité 5 s", 'aud.tciTXBusy': 'Émission…', 'aud.tciTXWarn': "CECI ÉMET. Mettre la radio sur charge fictive d'abord. Le poste est passé en émission et une tonalité de 1 kHz est envoyée par TCI pendant cinq secondes, pour savoir si la moitié émission du flux fonctionne — la réponse est dans le journal.", '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…',
+14
View File
@@ -575,6 +575,10 @@ export function GetStationSettings():Promise<main.StationSettings>;
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTCIAudioStatus():Promise<cat.TCIAudioStatus>;
export function GetTCIRecordAudio():Promise<boolean>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetTrackedAwards():Promise<Array<string>>;
@@ -837,6 +841,8 @@ export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function ProbeTCITransmit(arg1:number):Promise<void>;
export function PublishLogNow():Promise<string>;
export function QSLCopyTemplateToActiveProfile(arg1:number):Promise<number>;
@@ -905,6 +911,8 @@ export function RecomputeAllAwardRefs():Promise<number>;
export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
export function RecordTCIAudio(arg1:number):Promise<string>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshKenwood():Promise<void>;
@@ -1157,6 +1165,8 @@ export function SetSpotMax(arg1:number):Promise<void>;
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
export function SetTCIRecordAudio(arg1:boolean):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
@@ -1209,10 +1219,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StartTCIAudio(arg1:number,arg2:number):Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function StopTCIAudio():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncFolderNow():Promise<number>;
+28
View File
@@ -1090,6 +1090,14 @@ export function GetStationStatus() {
return window['go']['main']['App']['GetStationStatus']();
}
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']();
}
@@ -1614,6 +1622,10 @@ export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function ProbeTCITransmit(arg1) {
return window['go']['main']['App']['ProbeTCITransmit'](arg1);
}
export function PublishLogNow() {
return window['go']['main']['App']['PublishLogNow']();
}
@@ -1750,6 +1762,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']();
}
@@ -2254,6 +2270,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);
}
@@ -2358,6 +2378,10 @@ export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StartTCIAudio(arg1, arg2) {
return window['go']['main']['App']['StartTCIAudio'](arg1, arg2);
}
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
@@ -2366,6 +2390,10 @@ export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function StopTCIAudio() {
return window['go']['main']['App']['StopTCIAudio']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
+22
View File
@@ -1210,6 +1210,28 @@ export namespace cat {
this.fixed = source["fixed"];
}
}
export class TCIAudioStatus {
running: boolean;
sample_rate: number;
frames: number;
samples: number;
peak_db: number;
last_err?: string;
static createFrom(source: any = {}) {
return new TCIAudioStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
this.sample_rate = source["sample_rate"];
this.frames = source["frames"];
this.samples = source["samples"];
this.peak_db = source["peak_db"];
this.last_err = source["last_err"];
}
}
export class YaesuTXState {
available: boolean;
model?: string;