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.
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,
|
||||
} 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<any>({ 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<any>(null);
|
||||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||||
const saveBandOpen = async (next: any) => {
|
||||
@@ -6542,6 +6551,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<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>
|
||||
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
|
||||
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={monitorOn ? 'default' : 'outline'}
|
||||
|
||||
@@ -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.tciSilent': 'silent', '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.tciSilent': 'silence', '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…',
|
||||
|
||||
Reference in New Issue
Block a user